From b2af990d3cf0d4b9a0b074de28c87328ced891d6 Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Mon, 23 Sep 2013 15:19:32 +0000
Subject: [PATCH 05/54] If setting COURSES_ARE_BROWSABLE is off return 404 on
/courses
---
lms/djangoapps/branding/views.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py
index 6a8ef8056b..cdf7aa5eda 100644
--- a/lms/djangoapps/branding/views.py
+++ b/lms/djangoapps/branding/views.py
@@ -1,5 +1,6 @@
from django.conf import settings
from django.core.urlresolvers import reverse
+from django.http import Http404
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from mitxmako.shortcuts import render_to_response
@@ -48,6 +49,9 @@ def courses(request):
if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False):
return redirect(marketing_link('COURSES'), permanent=True)
+ if not settings.MITX_FEATURES.get('COURSES_ARE_BROWSABLE'):
+ raise Http404
+
university = branding.get_university(request.META.get('HTTP_HOST'))
if university == 'edge':
return render_to_response('university_profile/edge.html', {})
From 231b130911264bf27f5f042a5ff5d3d72d539420 Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Mon, 23 Sep 2013 15:30:00 +0000
Subject: [PATCH 06/54] Removed hardcoded case for edge for /courses
---
lms/djangoapps/branding/views.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py
index cdf7aa5eda..3ce8dbd401 100644
--- a/lms/djangoapps/branding/views.py
+++ b/lms/djangoapps/branding/views.py
@@ -52,10 +52,6 @@ def courses(request):
if not settings.MITX_FEATURES.get('COURSES_ARE_BROWSABLE'):
raise Http404
- university = branding.get_university(request.META.get('HTTP_HOST'))
- if university == 'edge':
- return render_to_response('university_profile/edge.html', {})
-
# we do not expect this case to be reached in cases where
- # marketing and edge are enabled
+ # marketing is enabled or the courses are not browsable
return courseware.views.courses(request)
From ecddd932caba633ea1c8f04d797f8ced80ae13ff Mon Sep 17 00:00:00 2001
From: Julian Arni
Date: Thu, 19 Sep 2013 11:35:10 -0400
Subject: [PATCH 07/54] Adding Pillow jpeg and tiff system libs
---
requirements/system/mac_os_x/brew-formulas.txt | 2 ++
requirements/system/ubuntu/apt-packages.txt | 2 ++
2 files changed, 4 insertions(+)
diff --git a/requirements/system/mac_os_x/brew-formulas.txt b/requirements/system/mac_os_x/brew-formulas.txt
index 5bc35aeb5f..22558042c4 100644
--- a/requirements/system/mac_os_x/brew-formulas.txt
+++ b/requirements/system/mac_os_x/brew-formulas.txt
@@ -11,3 +11,5 @@ mysql
geos
mongodb
lynx
+libjpeg
+libtiff
diff --git a/requirements/system/ubuntu/apt-packages.txt b/requirements/system/ubuntu/apt-packages.txt
index 0e9687b19a..6f130107da 100644
--- a/requirements/system/ubuntu/apt-packages.txt
+++ b/requirements/system/ubuntu/apt-packages.txt
@@ -16,6 +16,8 @@ gfortran
libfreetype6-dev
libpng12-dev
libjpeg-dev
+libtiff4-dev
+zlib1g-dev
libxml2-dev
libxslt-dev
yui-compressor
From d0f0e5927243974646fac2bd9dea7ad37641527c Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Wed, 25 Sep 2013 22:52:51 +0500
Subject: [PATCH 08/54] Fixed js coding style for pdf arrows toggle
---
common/static/js/pdfviewer.js | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/common/static/js/pdfviewer.js b/common/static/js/pdfviewer.js
index 889c1e7d71..ef30f180ae 100644
--- a/common/static/js/pdfviewer.js
+++ b/common/static/js/pdfviewer.js
@@ -202,10 +202,16 @@ PDFJS.disableWorker = true;
$("#pageNumber").val(pageNum);
// Enable/disable the previous/next buttons
- if (pageNum > 1) $("#previous").addClass("enabled");
- else $("#previous").removeClass("enabled");
- if (pageNum < pdfDocument.numPages) $("#next").addClass("enabled");
- else $("#next").removeClass("enabled");
+ if (pageNum > 1) {
+ $("#previous").addClass("enabled");
+ } else {
+ $("#previous").removeClass("enabled");
+ }
+ if (pageNum < pdfDocument.numPages) {
+ $("#next").addClass("enabled");
+ } else {
+ $("#next").removeClass("enabled");
+ }
}
// Go to previous page
From 724fc5adbd68739d3726c84258a608c7a2f84206 Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Thu, 26 Sep 2013 15:04:43 +0500
Subject: [PATCH 09/54] Updated copy in dashboard
---
lms/templates/dashboard.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index bca2bf8c38..121cc42791 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -338,7 +338,7 @@
${_("Find courses now!")}
% else:
-
${_("Looks like you haven't joined any courses yet.")}
+
${_("Looks like you haven't been enrolled in any courses yet.")}
%endif
% endif
From 94c74b5c80a818df0453e87f42693f03a6819c20 Mon Sep 17 00:00:00 2001
From: Jay Zoldak
Date: Thu, 26 Sep 2013 16:30:35 -0400
Subject: [PATCH 10/54] update discussion forum doc
---
docs/internal/discussion.md | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/docs/internal/discussion.md b/docs/internal/discussion.md
index 9885cfabf6..e2956bb165 100644
--- a/docs/internal/discussion.md
+++ b/docs/internal/discussion.md
@@ -22,11 +22,11 @@ For debugging, it's often more convenient to have elasticsearch running in a ter
## Setting up the discussion service
-First, make sure that you have access to the [github repository](https://github.com/rll/cs_comments_service). If this were not the case, send an email to dementrock@gmail.com.
+You can retrieve the source code from the [github repository](https://github.com/edx/cs_comments_service).
-First go into the mitx_all directory. Then type
+First go into the edx_all directory. Then type
- git clone git@github.com:rll/cs_comments_service.git
+ git clone https://github.com/edx/cs_comments_service.git
cd cs_comments_service/
If you see a prompt asking "Do you wish to trust this .rvmrc file?", type "y"
@@ -52,6 +52,13 @@ It's done! Launch the app now:
ruby app.rb
+## Integrating with the edx platform
+
+The API key must match on both sides. It is configured here:
+* edx-platform: COMMENTS_SERVICE_KEY in your dev.py file (dev environment) or ENV_TOKENS (prod environment)
+* cs_comments_service: api_key in the application.yml file (dev environment) or ENV variable (prod environment)
+
+
## Running the delayed job worker
In the discussion service, notifications are handled asynchronously using a third party gem called delayed_job. If you want to test this functionality, run the following command in a separate tab:
From ebcb134c2ea36f9bc02dc964951f8b8c267adc7f Mon Sep 17 00:00:00 2001
From: jmclaus
Date: Fri, 27 Sep 2013 15:50:24 +0200
Subject: [PATCH 11/54] First caption does not display outline on initial
mouseover.
---
common/lib/xmodule/xmodule/js/src/video/09_video_caption.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index 3dc675b4c2..2c14d74244 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -345,8 +345,8 @@ function () {
// Keeps track of where the focus is situated in the array of captions.
// Used to implement the automatic scrolling behavior and decide if the
// outline around a caption has to be hidden or shown on a mouseenter or
- // mouseleave.
- this.videoCaption.currentCaptionIndex = 0;
+ // mouseleave. Initially, no caption has the focus, set the index to -1.
+ this.videoCaption.currentCaptionIndex = -1;
// Used to track if the focus is coming from a click or tabbing. This
// has to be known to decide if, when a caption gets the focus, an
// outline has to be drawn (tabbing) or not (mouse click).
From 21a9446b9936431cf0483f07d9572a476667f395 Mon Sep 17 00:00:00 2001
From: Shiwen Cheng
Date: Wed, 25 Sep 2013 11:33:48 +0800
Subject: [PATCH 12/54] fix the bug that the first caption and the second one
will become current at the same time when displaying right captions
---
.../lib/xmodule/xmodule/js/src/video/09_video_caption.js | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index 3dc675b4c2..446111ffee 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -453,6 +453,9 @@ function () {
min = 0;
max = this.videoCaption.start.length - 1;
+ if (time < this.videoCaption.start[min]) {
+ return -1;
+ }
while (min < max) {
index = Math.ceil((max + min) / 2);
@@ -507,10 +510,11 @@ function () {
newIndex = this.videoCaption.search(time);
if (
- newIndex !== void 0 &&
+ typeof newIndex !== 'undefined' &&
+ newIndex !== -1 &&
this.videoCaption.currentIndex !== newIndex
) {
- if (this.videoCaption.currentIndex) {
+ if (typeof this.videoCaption.currentIndex !== 'undefined') {
this.videoCaption.subtitlesEl
.find('li.current')
.removeClass('current');
From 77f3e4f96479b92b5de6556e622e3e1e7f2b5c99 Mon Sep 17 00:00:00 2001
From: Shiwen Cheng
Date: Fri, 27 Sep 2013 18:43:39 +0800
Subject: [PATCH 13/54] fix the bug: when click a caption, the previous one
will be in bold
---
common/lib/xmodule/xmodule/js/src/video/09_video_caption.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index 446111ffee..ff5455c70c 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -500,11 +500,11 @@ function () {
// Total play time changes with speed change. Also there is
// a 250 ms delay we have to take into account.
time = Math.round(
- Time.convert(time, this.speed, '1.0') * 1000 + 250
+ Time.convert(time, this.speed, '1.0') * 1000 + 150
);
} else {
// Total play time remains constant when speed changes.
- time = Math.round(parseInt(time, 10) * 1000);
+ time = Math.round(parseInt(time, 10) * 1000 + 150);
}
newIndex = this.videoCaption.search(time);
From 522cb8b48c15abd82512f38cd97fda3bffe20e3f Mon Sep 17 00:00:00 2001
From: Shiwen Cheng
Date: Fri, 27 Sep 2013 21:52:54 +0800
Subject: [PATCH 14/54] fix the video caption lag issue
---
common/lib/xmodule/xmodule/js/src/video/09_video_caption.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index ff5455c70c..a78e08d648 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -500,11 +500,11 @@ function () {
// Total play time changes with speed change. Also there is
// a 250 ms delay we have to take into account.
time = Math.round(
- Time.convert(time, this.speed, '1.0') * 1000 + 150
+ Time.convert(time, this.speed, '1.0') * 1000 + 100
);
} else {
// Total play time remains constant when speed changes.
- time = Math.round(parseInt(time, 10) * 1000 + 150);
+ time = Math.round(time * 1000 + 100);
}
newIndex = this.videoCaption.search(time);
From 813795ddf4bbac0c22108ab84b92b130ba186810 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Mon, 16 Sep 2013 11:17:44 -0400
Subject: [PATCH 15/54] Add a management command to dump xml courses
This command loads the courses with the XMLModuleStore, and then exports
the fields contents as json in a diff-friendly way. This can then be
used to validate against regressions in the xml parsing code.
---
lms/djangoapps/debug/management/__init__.py | 0
.../debug/management/commands/__init__.py | 0
.../management/commands/dump_xml_courses.py | 59 +++++++++++++++++++
3 files changed, 59 insertions(+)
create mode 100644 lms/djangoapps/debug/management/__init__.py
create mode 100644 lms/djangoapps/debug/management/commands/__init__.py
create mode 100644 lms/djangoapps/debug/management/commands/dump_xml_courses.py
diff --git a/lms/djangoapps/debug/management/__init__.py b/lms/djangoapps/debug/management/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lms/djangoapps/debug/management/commands/__init__.py b/lms/djangoapps/debug/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lms/djangoapps/debug/management/commands/dump_xml_courses.py b/lms/djangoapps/debug/management/commands/dump_xml_courses.py
new file mode 100644
index 0000000000..f0834282b7
--- /dev/null
+++ b/lms/djangoapps/debug/management/commands/dump_xml_courses.py
@@ -0,0 +1,59 @@
+"""
+Export all xml courses in a diffable format.
+
+This command loads all of the xml courses in the configured DATA_DIR.
+For each of the courses, it loops through all of the modules, and dumps
+each as a separate output file containing the json representation
+of each of its fields (including those fields that are set as default values).
+"""
+
+from __future__ import print_function
+
+import json
+from path import path
+
+from django.core.management.base import BaseCommand, CommandError
+from django.conf import settings
+
+from xmodule.modulestore.xml import XMLModuleStore
+
+
+class Command(BaseCommand):
+ """
+ Django management command to export diffable representations of all xml courses
+ """
+ help = '''Dump the in-memory representation of all xml courses in a diff-able format'''
+ args = ''
+
+ def handle(self, *args, **options):
+ if len(args) != 1:
+ raise CommandError('Must called with arguments: {}'.format(self.args))
+
+ xml_module_store = XMLModuleStore(
+ data_dir=settings.DATA_DIR,
+ default_class='xmodule.hidden_module.HiddenDescriptor',
+ load_error_modules=True,
+ xblock_mixins=settings.XBLOCK_MIXINS,
+ )
+
+ export_dir = path(args[0])
+
+ for course_id, course_modules in xml_module_store.modules.iteritems():
+ course_path = course_id.replace('/', '_')
+ for location, descriptor in course_modules.iteritems():
+ location_path = location.url().replace('/', '_')
+ data = {}
+ for field_name, field in descriptor.fields.iteritems():
+ try:
+ data[field_name] = field.read_json(descriptor)
+ except Exception as exc: # pylint: disable=broad-except
+ data[field_name] = {
+ '$type': str(type(exc)),
+ '$value': descriptor._field_data.get(descriptor, field_name) # pylint: disable=protected-access
+ }
+
+ outdir = export_dir / course_path
+ outdir.makedirs_p()
+ with open(outdir / location_path + '.json', 'w') as outfile:
+ json.dump(data, outfile, sort_keys=True, indent=4)
+ print('', file=outfile)
From f500b72290e3edf1e3c60b87bced55316d1466ee Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Tue, 17 Sep 2013 11:55:34 -0400
Subject: [PATCH 16/54] Make sure that we have the right set of fields
available during xml parsing
We had a bug where mixins weren't being applied before `load_from_xml`
was called. This meant that not all of the fields were being loaded
correctly. To fix it, we used the mixoligist from the runtime to apply
the mixins earlier in the process. However, that caused the mixins to be
applied twice.
The included fixes to xblock resolved the multiply-applied mixins, and
the fixes to the parsing code make it simpler to understand, and add
some unit tests of the parsing to boot.
---
.../lib/xmodule/xmodule/tests/test_import.py | 1 -
.../xmodule/xmodule/tests/test_xml_module.py | 97 ++++++++++++-
.../lib/xmodule/xmodule/tests/xml/__init__.py | 46 +++++++
.../xmodule/xmodule/tests/xml/factories.py | 128 ++++++++++++++++++
.../xmodule/tests/xml/test_inheritance.py | 29 ++++
.../xmodule/xmodule/tests/xml/test_policy.py | 30 ++++
common/lib/xmodule/xmodule/video_module.py | 35 ++---
common/lib/xmodule/xmodule/xml_module.py | 77 +++--------
lms/djangoapps/courseware/module_render.py | 2 +-
pylintrc | 8 +-
requirements/edx/base.txt | 2 +-
requirements/edx/github.txt | 4 +-
12 files changed, 368 insertions(+), 91 deletions(-)
create mode 100644 common/lib/xmodule/xmodule/tests/xml/__init__.py
create mode 100644 common/lib/xmodule/xmodule/tests/xml/factories.py
create mode 100644 common/lib/xmodule/xmodule/tests/xml/test_inheritance.py
create mode 100644 common/lib/xmodule/xmodule/tests/xml/test_policy.py
diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py
index cd7749dc09..19232e383f 100644
--- a/common/lib/xmodule/xmodule/tests/test_import.py
+++ b/common/lib/xmodule/xmodule/tests/test_import.py
@@ -91,7 +91,6 @@ class ImportTestCase(BaseCourseTestCase):
self.assertNotEqual(descriptor1.location, descriptor2.location)
- @unittest.skip('Temporarily disabled')
def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly'''
diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py
index 1515c76237..9d11b2b168 100644
--- a/common/lib/xmodule/xmodule/tests/test_xml_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py
@@ -1,17 +1,25 @@
# disable missing docstring
#pylint: disable=C0111
-from xmodule.x_module import XModuleFields
-from xblock.fields import Scope, String, Dict, Boolean, Integer, Float, Any, List
-from xblock.field_data import DictFieldData
-from xmodule.fields import Date, Timedelta
-from xmodule.xml_module import XmlDescriptor, serialize_field, deserialize_field
import unittest
-from nose.tools import assert_equals # pylint: disable=E0611
+
from mock import Mock
-from xmodule.modulestore.inheritance import InheritanceKeyValueStore, InheritanceMixin
+from nose.tools import assert_equals, assert_not_equals, assert_true, assert_false, assert_in, assert_not_in # pylint: disable=E0611
+
+from xblock.field_data import DictFieldData
+from xblock.fields import Scope, String, Dict, Boolean, Integer, Float, Any, List
from xblock.runtime import DbModel
+
+from xmodule.fields import Date, Timedelta
+from xmodule.modulestore.inheritance import InheritanceKeyValueStore, InheritanceMixin
+from xmodule.x_module import XModuleFields
+from xmodule.xml_module import XmlDescriptor, serialize_field, deserialize_field
+from xmodule.course_module import CourseDescriptor
+from xmodule.seq_module import SequenceDescriptor
+
from xmodule.tests import get_test_descriptor_system
+from xmodule.tests.xml import XModuleXmlImportTest
+from xmodule.tests.xml.factories import CourseFactory, SequenceFactory, ProblemFactory
class CrazyJsonString(String):
@@ -379,3 +387,78 @@ class TestDeserializeTimedelta(TestDeserialize):
self.assertDeserializeEqual('1 day 12 hours 59 minutes 59 seconds',
'"1 day 12 hours 59 minutes 59 seconds"')
self.assertDeserializeNonString()
+
+
+class TestXmlAttributes(XModuleXmlImportTest):
+
+ def test_unknown_attribute(self):
+ assert_false(hasattr(CourseDescriptor, 'unknown_attr'))
+ course = self.process_xml(CourseFactory.build(unknown_attr='value'))
+ assert_false(hasattr(course, 'unknown_attr'))
+ assert_equals('value', course.xml_attributes['unknown_attr'])
+
+ def test_known_attribute(self):
+ assert_true(hasattr(CourseDescriptor, 'show_chat'))
+ course = self.process_xml(CourseFactory.build(show_chat='true'))
+ assert_true(course.show_chat)
+ assert_not_in('show_chat', course.xml_attributes)
+
+ def test_rerandomize_in_policy(self):
+ # Rerandomize isn't a basic attribute of Sequence
+ assert_false(hasattr(SequenceDescriptor, 'rerandomize'))
+
+ root = SequenceFactory.build(policy={'rerandomize': 'never'})
+ ProblemFactory.build(parent=root)
+
+ seq = self.process_xml(root)
+
+ # Rerandomize is added to the constructed sequence via the InheritanceMixin
+ assert_equals('never', seq.rerandomize)
+
+ # Rerandomize is a known value coming from policy, and shouldn't appear
+ # in xml_attributes
+ assert_not_in('rerandomize', seq.xml_attributes)
+
+ def test_attempts_in_policy(self):
+ # attempts isn't a basic attribute of Sequence
+ assert_false(hasattr(SequenceDescriptor, 'attempts'))
+
+ root = SequenceFactory.build(policy={'attempts': '1'})
+ ProblemFactory.build(parent=root)
+
+ seq = self.process_xml(root)
+
+ # attempts isn't added to the constructed sequence, because
+ # it's not in the InheritanceMixin
+ assert_false(hasattr(seq, 'attempts'))
+
+ # attempts is an unknown attribute, so we should include it
+ # in xml_attributes so that it gets written out (despite the misleading
+ # name)
+ assert_in('attempts', seq.xml_attributes)
+
+ def test_inheritable_attribute(self):
+ # days_early_for_beta isn't a basic attribute of Sequence
+ assert_false(hasattr(SequenceDescriptor, 'days_early_for_beta'))
+
+ # days_early_for_beta is added by InheritanceMixin
+ assert_true(hasattr(InheritanceMixin, 'days_early_for_beta'))
+
+ root = SequenceFactory.build(policy={'days_early_for_beta': '2'})
+ ProblemFactory.build(parent=root)
+
+ # InheritanceMixin will be used when processing the XML
+ assert_in(InheritanceMixin, root.xblock_mixins)
+
+ seq = self.process_xml(root)
+
+ assert_equals(seq.unmixed_class, SequenceDescriptor)
+ assert_not_equals(type(seq), SequenceDescriptor)
+
+ # days_early_for_beta is added to the constructed sequence, because
+ # it's in the InheritanceMixin
+ assert_equals(2, seq.days_early_for_beta)
+
+ # days_early_for_beta is a known attribute, so we shouldn't include it
+ # in xml_attributes
+ assert_not_in('days_early_for_beta', seq.xml_attributes)
diff --git a/common/lib/xmodule/xmodule/tests/xml/__init__.py b/common/lib/xmodule/xmodule/tests/xml/__init__.py
new file mode 100644
index 0000000000..32cefadca7
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/xml/__init__.py
@@ -0,0 +1,46 @@
+"""
+Xml parsing tests for XModules
+"""
+import pprint
+from mock import Mock
+
+from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
+from xmodule.mako_module import MakoDescriptorSystem
+
+
+class InMemorySystem(XMLParsingSystem, MakoDescriptorSystem): # pylint: disable=abstract-method
+ """
+ The simplest possible XMLParsingSystem
+ """
+ def __init__(self, xml_import_data):
+ self.org = xml_import_data.org
+ self.course = xml_import_data.course
+ self.default_class = xml_import_data.default_class
+ self._descriptors = {}
+ super(InMemorySystem, self).__init__(
+ policy=xml_import_data.policy,
+ process_xml=self.process_xml,
+ load_item=self.load_item,
+ error_tracker=Mock(),
+ resources_fs=xml_import_data.filesystem,
+ mixins=xml_import_data.xblock_mixins,
+ render_template=lambda template, context: pprint.pformat((template, context))
+ )
+
+ def process_xml(self, xml): # pylint: disable=method-hidden
+ """Parse `xml` as an XModuleDescriptor, and add it to `self._descriptors`"""
+ descriptor = XModuleDescriptor.load_from_xml(xml, self, self.org, self.course, self.default_class)
+ self._descriptors[descriptor.location.url()] = descriptor
+ return descriptor
+
+ def load_item(self, location): # pylint: disable=method-hidden
+ """Return the descriptor loaded for `location`"""
+ return self._descriptors[location]
+
+
+class XModuleXmlImportTest(object):
+ """Base class for tests that use basic `XModuleDescriptor.load_from_xml` xml parsing"""
+ def process_xml(self, xml_import_data):
+ """Use the `xml_import_data` to import an :class:`XModuleDescriptor` from xml"""
+ system = InMemorySystem(xml_import_data)
+ return system.process_xml(xml_import_data.xml_string)
diff --git a/common/lib/xmodule/xmodule/tests/xml/factories.py b/common/lib/xmodule/xmodule/tests/xml/factories.py
new file mode 100644
index 0000000000..168dcd579b
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/xml/factories.py
@@ -0,0 +1,128 @@
+"""
+Factories for generating edXML for testing XModule import
+"""
+
+import inspect
+
+from fs.memoryfs import MemoryFS
+from factory import Factory, lazy_attribute, post_generation, Sequence
+from lxml import etree
+
+from xmodule.modulestore.inheritance import InheritanceMixin
+
+
+class XmlImportData(object):
+ """
+ Class to capture all of the data needed to actually run an XML import,
+ so that the Factories have something to generate
+ """
+ def __init__(self, xml_node, xml=None, org=None, course=None,
+ default_class=None, policy=None,
+ filesystem=None, parent=None,
+ xblock_mixins=()):
+
+ self._xml_node = xml_node
+ self._xml_string = xml
+ self.org = org
+ self.course = course
+ self.default_class = default_class
+ self.filesystem = filesystem
+ self.xblock_mixins = xblock_mixins
+ self.parent = parent
+
+ if policy is None:
+ self.policy = {}
+ else:
+ self.policy = policy
+
+ @property
+ def xml_string(self):
+ """Return the stringified version of the generated xml"""
+ if self._xml_string is not None:
+ return self._xml_string
+
+ return etree.tostring(self._xml_node)
+
+ def __repr__(self):
+ return u"XmlImportData{!r}".format((
+ self._xml_node, self._xml_string, self.org,
+ self.course, self.default_class, self.policy,
+ self.filesystem, self.parent, self.xblock_mixins
+ ))
+
+
+# Extract all argument names used to construct XmlImportData objects,
+# so that the factory doesn't treat them as XML attributes
+XML_IMPORT_ARGS = inspect.getargspec(XmlImportData.__init__).args
+
+
+class XmlImportFactory(Factory):
+ """
+ Factory for generating XmlImportData's, which can hold all the data needed
+ to run an XModule XML import
+ """
+ FACTORY_FOR = XmlImportData
+
+ filesystem = MemoryFS()
+ xblock_mixins = (InheritanceMixin,)
+ url_name = Sequence(str)
+ attribs = {}
+ policy = {}
+ tag = 'unknown'
+
+ @classmethod
+ def _adjust_kwargs(cls, **kwargs):
+ """
+ Adjust the kwargs to be passed to the generated class.
+
+ Any kwargs that match :fun:`XmlImportData.__init__` will be passed
+ through. Any other unknown `kwargs` will be treated as XML attributes
+
+ :param tag: xml tag for the generated :class:`Element` node
+ :param text: (Optional) specifies the text of the generated :class:`Element`.
+ :param policy: (Optional) specifies data for the policy json file for this node
+ :type policy: dict
+ :param attribs: (Optional) specify attributes for the XML node
+ :type attribs: dict
+ """
+ tag = kwargs.pop('tag', 'unknown')
+ kwargs['policy'] = {'{tag}/{url_name}'.format(tag=tag, url_name=kwargs['url_name']): kwargs['policy']}
+
+ kwargs['xml_node'].text = kwargs.pop('text', None)
+
+ kwargs['xml_node'].attrib.update(kwargs.pop('attribs', {}))
+ for key in kwargs.keys():
+ if key not in XML_IMPORT_ARGS:
+ kwargs['xml_node'].set(key, kwargs.pop(key))
+
+ return kwargs
+
+ @lazy_attribute
+ def xml_node(self):
+ """An :class:`xml.etree.Element`"""
+ return etree.Element(self.tag)
+
+ @post_generation
+ def parent(self, _create, extracted, **_):
+ """Hook to merge this xml into a parent xml node"""
+ if extracted is None:
+ return
+
+ extracted._xml_node.append(self._xml_node) # pylint: disable=no-member, protected-access
+ extracted.policy.update(self.policy)
+
+
+class CourseFactory(XmlImportFactory):
+ """Factory for nodes"""
+ tag = 'course'
+
+
+class SequenceFactory(XmlImportFactory):
+ """Factory for nodes"""
+ tag = 'sequential'
+
+
+class ProblemFactory(XmlImportFactory):
+ """Factory for nodes"""
+ tag = 'problem'
+ text = '
Empty Problem!
'
diff --git a/common/lib/xmodule/xmodule/tests/xml/test_inheritance.py b/common/lib/xmodule/xmodule/tests/xml/test_inheritance.py
new file mode 100644
index 0000000000..dc27f0c792
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/xml/test_inheritance.py
@@ -0,0 +1,29 @@
+"""
+Test that inherited fields work correctly when parsing XML
+"""
+from nose.tools import assert_equals # pylint: disable=no-name-in-module
+
+from xmodule.tests.xml import XModuleXmlImportTest
+from xmodule.tests.xml.factories import CourseFactory, SequenceFactory, ProblemFactory
+
+
+class TestInheritedFieldParsing(XModuleXmlImportTest):
+ """
+ Test that inherited fields work correctly when parsing XML
+
+ """
+ def test_null_string(self):
+ # Test that the string inherited fields are passed through 'deserialize_field',
+ # which converts the string "null" to the python value None
+ root = CourseFactory.build(days_early_for_beta="null")
+ sequence = SequenceFactory.build(parent=root)
+ ProblemFactory.build(parent=sequence)
+
+ course = self.process_xml(root)
+ assert_equals(None, course.days_early_for_beta)
+
+ sequence = course.get_children()[0]
+ assert_equals(None, sequence.days_early_for_beta)
+
+ problem = sequence.get_children()[0]
+ assert_equals(None, problem.days_early_for_beta)
diff --git a/common/lib/xmodule/xmodule/tests/xml/test_policy.py b/common/lib/xmodule/xmodule/tests/xml/test_policy.py
new file mode 100644
index 0000000000..2e702cc82d
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/xml/test_policy.py
@@ -0,0 +1,30 @@
+"""
+Tests that policy json files import correctly when loading XML
+"""
+
+from nose.tools import assert_equals, assert_raises # pylint: disable=no-name-in-module
+
+from xmodule.tests.xml.factories import CourseFactory
+from xmodule.tests.xml import XModuleXmlImportTest
+
+
+class TestPolicy(XModuleXmlImportTest):
+ """
+ Tests that policy json files import correctly when loading xml
+ """
+ def test_no_attribute_mapping(self):
+ # Policy files are json, and thus the values aren't passed through 'deserialize_field'
+ # Therefor, the string 'null' is passed unchanged to the Float field, which will trigger
+ # a ValueError
+ with assert_raises(ValueError):
+ course = self.process_xml(CourseFactory.build(policy={'days_early_for_beta': 'null'}))
+
+ # Trigger the exception by looking at the imported data
+ course.days_early_for_beta # pylint: disable=pointless-statement
+
+ def test_course_policy(self):
+ course = self.process_xml(CourseFactory.build(policy={'days_early_for_beta': None}))
+ assert_equals(None, course.days_early_for_beta)
+
+ course = self.process_xml(CourseFactory.build(policy={'days_early_for_beta': 9}))
+ assert_equals(9, course.days_early_for_beta)
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index d97d5aa144..febfe5961f 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -24,7 +24,7 @@ from django.conf import settings
from xmodule.x_module import XModule
from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
-from xmodule.xml_module import is_pointer_tag, name_to_pathname
+from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field
from xmodule.modulestore import Location
from xblock.fields import Scope, String, Boolean, Float, List, Integer, ScopeIds
@@ -217,7 +217,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
# For backwards compatibility -- if we've got XML data, parse
# it out and set the metadata fields
if self.data:
- field_data = VideoDescriptor._parse_video_xml(self.data)
+ field_data = self._parse_video_xml(self.data)
self._field_data.set_many(self, field_data)
del self.data
@@ -241,7 +241,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
if is_pointer_tag(xml_object):
filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name))
xml_data = etree.tostring(cls.load_file(filepath, system.resources_fs, location))
- field_data = VideoDescriptor._parse_video_xml(xml_data)
+ field_data = cls._parse_video_xml(xml_data)
field_data['location'] = location
kvs = InheritanceKeyValueStore(initial_values=field_data)
field_data = DbModel(kvs)
@@ -292,8 +292,8 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
xml.append(ele)
return xml
- @staticmethod
- def _parse_youtube(data):
+ @classmethod
+ def _parse_youtube(cls, data):
"""
Parses a string of Youtube IDs such as "1.0:AXdE34_U,1.5:VO3SxfeD"
into a dictionary. Necessary for backwards compatibility with
@@ -310,14 +310,14 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
# Handle the fact that youtube IDs got double-quoted for a period of time.
# Note: we pass in "VideoFields.youtube_id_1_0" so we deserialize as a String--
# it doesn't matter what the actual speed is for the purposes of deserializing.
- youtube_id = VideoDescriptor._deserialize(VideoFields.youtube_id_1_0.name, pieces[1])
+ youtube_id = deserialize_field(cls.youtube_id_1_0, pieces[1])
ret[speed] = youtube_id
except (ValueError, IndexError):
log.warning('Invalid YouTube ID: %s' % video)
return ret
- @staticmethod
- def _parse_video_xml(xml_data):
+ @classmethod
+ def _parse_video_xml(cls, xml_data):
"""
Parse video fields out of xml_data. The fields are set if they are
present in the XML.
@@ -326,8 +326,8 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
field_data = {}
conversions = {
- 'start_time': VideoDescriptor._parse_time,
- 'end_time': VideoDescriptor._parse_time
+ 'start_time': cls._parse_time,
+ 'end_time': cls._parse_time
}
# Convert between key names for certain attributes --
@@ -349,10 +349,10 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
for attr, value in xml.items():
if attr in compat_keys:
attr = compat_keys[attr]
- if attr in VideoDescriptor.metadata_to_strip + ('url_name', 'name'):
+ if attr in cls.metadata_to_strip + ('url_name', 'name'):
continue
if attr == 'youtube':
- speeds = VideoDescriptor._parse_youtube(value)
+ speeds = cls._parse_youtube(value)
for speed, youtube_id in speeds.items():
# should have made these youtube_id_1_00 for
# cleanliness, but hindsight doesn't need glasses
@@ -367,20 +367,13 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor
else:
# We export values with json.dumps (well, except for Strings, but
# for about a month we did it for Strings also).
- value = VideoDescriptor._deserialize(attr, value)
+ value = deserialize_field(cls.fields[attr], value)
field_data[attr] = value
return field_data
@classmethod
- def _deserialize(cls, attr, value):
- """
- Handles deserializing values that may have been encoded with json.dumps.
- """
- return cls.get_map_for_field(attr).from_xml(value)
-
- @staticmethod
- def _parse_time(str_time):
+ def _parse_time(cls, str_time):
"""Converts s in '12:34:45' format to seconds. If s is
None, returns empty string"""
if not str_time:
diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py
index 5fa9ff3260..83bb6faffd 100644
--- a/common/lib/xmodule/xmodule/xml_module.py
+++ b/common/lib/xmodule/xmodule/xml_module.py
@@ -62,23 +62,6 @@ def get_metadata_from_xml(xml_object, remove=True):
xml_object.remove(meta)
return dmdata
-_AttrMapBase = namedtuple('_AttrMap', 'from_xml to_xml')
-
-
-class AttrMap(_AttrMapBase):
- """
- A class that specifies two functions:
-
- from_xml: convert value from the xml representation into
- an internal python representation
-
- to_xml: convert the internal python representation into
- the value to store in the xml.
- """
- def __new__(_cls, from_xml=lambda x: x,
- to_xml=lambda x: x):
- return _AttrMapBase.__new__(_cls, from_xml, to_xml)
-
def serialize_field(value):
"""
@@ -166,20 +149,6 @@ class XmlDescriptor(XModuleDescriptor):
metadata_to_export_to_policy = ('discussion_topics', 'checklists')
- @classmethod
- def get_map_for_field(cls, attr):
- """
- Returns a serialize/deserialize AttrMap for the given field of a class.
-
- Searches through fields defined by cls to find one named attr.
- """
- if attr in cls.fields:
- from_xml = lambda val: deserialize_field(cls.fields[attr], val)
- to_xml = lambda val: serialize_field(val)
- return AttrMap(from_xml, to_xml)
- else:
- return AttrMap()
-
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -274,19 +243,19 @@ class XmlDescriptor(XModuleDescriptor):
Returns a dictionary {key: value}.
"""
- metadata = {}
- for attr in xml_object.attrib:
- val = xml_object.get(attr)
- if val is not None:
- # VS[compat]. Remove after all key translations done
- attr = cls._translate(attr)
+ metadata = {'xml_attributes': {}}
+ for attr, val in xml_object.attrib.iteritems():
+ # VS[compat]. Remove after all key translations done
+ attr = cls._translate(attr)
- if attr in cls.metadata_to_strip:
- # don't load these
- continue
+ if attr in cls.metadata_to_strip:
+ # don't load these
+ continue
- attr_map = cls.get_map_for_field(attr)
- metadata[attr] = attr_map.from_xml(val)
+ if attr not in cls.fields:
+ metadata['xml_attributes'][attr] = val
+ else:
+ metadata[attr] = deserialize_field(cls.fields[attr], val)
return metadata
@classmethod
@@ -295,9 +264,14 @@ class XmlDescriptor(XModuleDescriptor):
Add the keys in policy to metadata, after processing them
through the attrmap. Updates the metadata dict in place.
"""
- for attr in policy:
- attr_map = cls.get_map_for_field(attr)
- metadata[cls._translate(attr)] = attr_map.from_xml(policy[attr])
+ for attr, value in policy.iteritems():
+ attr = cls._translate(attr)
+ if attr not in cls.fields:
+ # Store unknown attributes coming from policy.json
+ # in such a way that they will export to xml unchanged
+ metadata['xml_attributes'][attr] = value
+ else:
+ metadata[attr] = value
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
@@ -357,11 +331,7 @@ class XmlDescriptor(XModuleDescriptor):
field_data.update(definition)
field_data['children'] = children
- field_data['xml_attributes'] = {}
field_data['xml_attributes']['filename'] = definition.get('filename', ['', None]) # for git link
- for key, value in metadata.items():
- if key not in cls.fields:
- field_data['xml_attributes'][key] = value
field_data['location'] = location
field_data['category'] = xml_object.tag
kvs = InheritanceKeyValueStore(initial_values=field_data)
@@ -415,18 +385,11 @@ class XmlDescriptor(XModuleDescriptor):
# Set the tag so we get the file path right
xml_object.tag = self.category
- def val_for_xml(attr):
- """Get the value for this attribute that we want to store.
- (Possible format conversion through an AttrMap).
- """
- attr_map = self.get_map_for_field(attr)
- return attr_map.to_xml(self._field_data.get(self, attr))
-
# Add the non-inherited metadata
for attr in sorted(own_metadata(self)):
# don't want e.g. data_dir
if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
- val = val_for_xml(attr)
+ val = serialize_field(self._field_data.get(self, attr))
try:
xml_object.set(attr, val)
except Exception, e:
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index acdd4f0aa0..f86bef29ad 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -306,7 +306,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
# Construct the key for the module
key = KeyValueStore.Key(
scope=Scope.user_state,
- student_id=user.id,
+ user_id=user.id,
block_scope_id=descriptor.location,
field_name='grade'
)
diff --git a/pylintrc b/pylintrc
index 9525f04362..a3c84c1555 100644
--- a/pylintrc
+++ b/pylintrc
@@ -89,6 +89,9 @@ evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / stateme
# evaluation report (RP0004).
comment=no
+# Display symbolic names of messages in reports
+symbols=yes
+
[TYPECHECK]
@@ -120,7 +123,10 @@ generated-members=
content,
status_code,
# For factory_boy factories
- create
+ create,
+ build,
+# For xblocks
+ fields,
[BASIC]
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index addef7b629..52fe3ce44f 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -47,7 +47,7 @@ Pillow==1.7.8
pip>=1.4
polib==1.0.3
pycrypto>=2.6
-pygments==1.5
+pygments==1.6
pygraphviz==1.1
pymongo==2.4.1
pyparsing==1.5.6
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index add9bda1d1..e0c88b217e 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -14,8 +14,8 @@
-e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk
# Our libraries:
--e git+https://github.com/edx/XBlock.git@aa0d60627#egg=XBlock
+-e git+https://github.com/edx/XBlock.git@a8de02c0#egg=XBlock
-e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail
--e git+https://github.com/edx/diff-cover.git@v0.2.3#egg=diff_cover
+-e git+https://github.com/edx/diff-cover.git@v0.2.4#egg=diff_cover
-e git+https://github.com/edx/js-test-tool.git@v0.0.7#egg=js_test_tool
-e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle
From 36fc1a62434a28cba8556dbed54ac28261b696c5 Mon Sep 17 00:00:00 2001
From: Valera Rozuvan
Date: Fri, 27 Sep 2013 18:43:58 +0300
Subject: [PATCH 17/54] Fix for PR 1119.
A Jasmine test was falling. It went unnoticed. The PR was merged. This
is a fix for it.
---
common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
index a94a35e124..0f729da62d 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
@@ -261,7 +261,7 @@
describe('search', function() {
it('return a correct caption index', function() {
- expect(videoCaption.search(0)).toEqual(0);
+ expect(videoCaption.search(0)).toEqual(-1);
expect(videoCaption.search(3120)).toEqual(1);
expect(videoCaption.search(6270)).toEqual(2);
expect(videoCaption.search(8490)).toEqual(2);
From 5ca778ef0508047fad5db1de0753120d0b62ba4f Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Fri, 27 Sep 2013 21:06:34 +0500
Subject: [PATCH 18/54] Display:none arrows in disabled state
---
common/static/js/pdfviewer.js | 12 ++++++------
lms/static/sass/course/_textbook.scss | 13 ++++++++-----
2 files changed, 14 insertions(+), 11 deletions(-)
mode change 100644 => 100755 common/static/js/pdfviewer.js
mode change 100644 => 100755 lms/static/sass/course/_textbook.scss
diff --git a/common/static/js/pdfviewer.js b/common/static/js/pdfviewer.js
old mode 100644
new mode 100755
index ef30f180ae..a76e2f397c
--- a/common/static/js/pdfviewer.js
+++ b/common/static/js/pdfviewer.js
@@ -202,15 +202,15 @@ PDFJS.disableWorker = true;
$("#pageNumber").val(pageNum);
// Enable/disable the previous/next buttons
- if (pageNum > 1) {
- $("#previous").addClass("enabled");
+ if (pageNum <= 1) {
+ $("#previous").addClass("is-disabled");
} else {
- $("#previous").removeClass("enabled");
+ $("#previous").removeClass("is-disabled");
}
- if (pageNum < pdfDocument.numPages) {
- $("#next").addClass("enabled");
+ if (pageNum >= pdfDocument.numPages) {
+ $("#next").addClass("is-disabled");
} else {
- $("#next").removeClass("enabled");
+ $("#next").removeClass("is-disabled");
}
}
diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss
old mode 100644
new mode 100755
index f839c0dd10..d3632be2c5
--- a/lms/static/sass/course/_textbook.scss
+++ b/lms/static/sass/course/_textbook.scss
@@ -160,13 +160,16 @@ div.book-wrapper {
@include transition(none);
vertical-align: middle;
width: 100%;
- }
- a.enabled:hover {
- opacity: 1.0;
- filter: alpha(opacity=100);
- }
+ &:hover {
+ opacity: 1.0;
+ }
+ &.is-disabled {
+ display:none;
+ }
+ }
+
&.last {
left: 0;
From 59403644ae38e9e54d8099b2e1f1a2651e6639e6 Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Fri, 27 Sep 2013 21:35:16 +0500
Subject: [PATCH 19/54] Moved jquery event handling to parent since hidden
elements do not get events attached
---
common/static/js/pdfviewer.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/static/js/pdfviewer.js b/common/static/js/pdfviewer.js
index a76e2f397c..3424a09892 100755
--- a/common/static/js/pdfviewer.js
+++ b/common/static/js/pdfviewer.js
@@ -327,11 +327,11 @@ PDFJS.disableWorker = true;
loadUrl(chapterUrl, pageVal);
}
- $("#previous").click(function(event) {
+ $(".last").on('click','#previous',function(){
prevPage();
});
- $("#next").click(function(event) {
+ $(".next").on('click','#next',function(){
nextPage();
});
From 07f94d46a65e9573d748f05f157c6a04fba84569 Mon Sep 17 00:00:00 2001
From: Brian Wilson
Date: Fri, 27 Sep 2013 12:43:27 -0400
Subject: [PATCH 20/54] Move Pearson courseware imports to function level.
---
common/djangoapps/external_auth/views.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
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):
From d77491e46a11b913a31616b4f9adc035c53cc3c6 Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Fri, 6 Sep 2013 14:21:30 -0400
Subject: [PATCH 21/54] Pull XModule attributes out into a mixin that can be
applied to xblocks
---
cms/envs/common.py | 3 +-
.../xmodule/modulestore/split_mongo/split.py | 2 -
.../tests/test_split_modulestore.py | 3 +-
common/lib/xmodule/xmodule/tests/__init__.py | 8 +-
.../lib/xmodule/xmodule/tests/test_import.py | 3 +-
.../xmodule/xmodule/tests/test_xml_module.py | 6 +-
common/lib/xmodule/xmodule/x_module.py | 325 ++++++++----------
lms/envs/common.py | 3 +-
8 files changed, 148 insertions(+), 205 deletions(-)
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/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index d85092b363..60bfa66b6a 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -43,8 +43,6 @@ log = logging.getLogger(__name__)
#==============================================================================
-
-
class SplitMongoModuleStore(ModuleStoreBase):
"""
A Mongodb backed ModuleStore supporting versions, inheritance,
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index 0f1b65d0a3..be5c61a143 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -15,6 +15,7 @@ from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemN
DuplicateItemError
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator, VersionTree, DescriptionLocator
from xmodule.modulestore.inheritance import InheritanceMixin
+from xmodule.x_module import XModuleMixin
from pytz import UTC
from path import path
import re
@@ -34,7 +35,7 @@ class SplitModuleTest(unittest.TestCase):
'db': 'test_xmodule',
'collection': 'modulestore{0}'.format(uuid.uuid4().hex),
'fs_root': '',
- 'xblock_mixins': (InheritanceMixin,)
+ 'xblock_mixins': (InheritanceMixin, XModuleMixin)
}
MODULESTORE = {
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_import.py b/common/lib/xmodule/xmodule/tests/test_import.py
index 19232e383f..16c82753a5 100644
--- a/common/lib/xmodule/xmodule/tests/test_import.py
+++ b/common/lib/xmodule/xmodule/tests/test_import.py
@@ -13,6 +13,7 @@ from xmodule.xml_module import is_pointer_tag
from xmodule.modulestore import Location
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from xmodule.modulestore.inheritance import compute_inherited_metadata
+from xmodule.x_module import XModuleMixin
from xmodule.fields import Date
from xmodule.tests import DATA_DIR
from xmodule.modulestore.inheritance import InheritanceMixin
@@ -42,7 +43,7 @@ class DummySystem(ImportSystem):
error_tracker=error_tracker,
parent_tracker=parent_tracker,
load_error_modules=load_error_modules,
- mixins=(InheritanceMixin,)
+ mixins=(InheritanceMixin, XModuleMixin)
)
def render_template(self, _template, _context):
diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py
index 9d11b2b168..da6062f662 100644
--- a/common/lib/xmodule/xmodule/tests/test_xml_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py
@@ -12,10 +12,10 @@ from xblock.runtime import DbModel
from xmodule.fields import Date, Timedelta
from xmodule.modulestore.inheritance import InheritanceKeyValueStore, InheritanceMixin
-from xmodule.x_module import XModuleFields
from xmodule.xml_module import XmlDescriptor, serialize_field, deserialize_field
from xmodule.course_module import CourseDescriptor
from xmodule.seq_module import SequenceDescriptor
+from xmodule.x_module import XModuleMixin
from xmodule.tests import get_test_descriptor_system
from xmodule.tests.xml import XModuleXmlImportTest
@@ -65,7 +65,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
# Also tests that xml_attributes is filtered out of XmlDescriptor.
self.assertEqual(1, len(editable_fields), editable_fields)
self.assert_field_values(
- editable_fields, 'display_name', XModuleFields.display_name,
+ editable_fields, 'display_name', XModuleMixin.display_name,
explicitly_set=False, value=None, default_value=None
)
@@ -73,7 +73,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
# Tests that explicitly_set is correct when a value overrides the default (not inheritable).
editable_fields = self.get_xml_editable_fields(DictFieldData({'display_name': 'foo'}))
self.assert_field_values(
- editable_fields, 'display_name', XModuleFields.display_name,
+ editable_fields, 'display_name', XModuleMixin.display_name,
explicitly_set=True, value='foo', default_value=None
)
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 63a73c4530..63ff80b9d9 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -1,5 +1,4 @@
import logging
-import copy
import yaml
import os
@@ -11,7 +10,7 @@ from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError
from xblock.core import XBlock
-from xblock.fields import Scope, String, Integer, Float, List
+from xblock.fields import Scope, Integer, Float, List, XBlockMixin, String
from xblock.fragment import Fragment
from xblock.runtime import Runtime
from xmodule.modulestore.locator import BlockUsageLocator
@@ -83,7 +82,13 @@ class HTMLSnippet(object):
.format(self.__class__))
-class XModuleFields(object):
+class XModuleMixin(XBlockMixin):
+ """
+ Fields and methods used by XModules internally.
+
+ Adding this Mixin to an :class:`XBlock` allows it to cooperate with old-style :class:`XModules`
+ """
+
display_name = String(
display_name="Display Name",
help="This name appears in the horizontal navigation at the top of the page.",
@@ -93,41 +98,6 @@ class XModuleFields(object):
default=None
)
-
-class XModule(XModuleFields, HTMLSnippet, XBlock):
- ''' Implements a generic learning module.
-
- Subclasses must at a minimum provide a definition for get_html in order
- to be displayed to users.
-
- See the HTML module for a simple example.
- '''
-
- # The default implementation of get_icon_class returns the icon_class
- # attribute of the class
- #
- # This attribute can be overridden by subclasses, and
- # the function can also be overridden if the icon class depends on the data
- # in the module
- icon_class = 'other'
-
-
- def __init__(self, descriptor, *args, **kwargs):
- '''
- Construct a new xmodule
-
- runtime: An XBlock runtime allowing access to external resources
-
- descriptor: the XModuleDescriptor that this module is an instance of.
-
- field_data: A dictionary-like object that maps field names to values
- for those fields.
- '''
- super(XModule, self).__init__(*args, **kwargs)
- self.system = self.runtime
- self.descriptor = descriptor
- self._loaded_children = None
-
@property
def id(self):
return self.location.url()
@@ -155,31 +125,133 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
@property
def url_name(self):
- if self.descriptor:
- return self.descriptor.url_name
- elif isinstance(self.location, Location):
+ if isinstance(self.location, Location):
return self.location.name
elif isinstance(self.location, BlockUsageLocator):
return self.location.usage_id
else:
raise InsufficientSpecificationError()
-
@property
def display_name_with_default(self):
- '''
+ """
Return a display name for the module: use display_name if defined in
metadata, otherwise convert the url name.
- '''
+ """
name = self.display_name
if name is None:
name = self.url_name.replace('_', ' ')
return name
+ def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
+ """
+ Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
+ any set to None.)
+ """
+ result = {}
+ for field in self.fields.values():
+ if (field.scope == scope and field.is_set_on(self)):
+ result[field.name] = field.read_json(self)
+ return result
+
+ @property
+ def xblock_kvs(self):
+ """
+ Use w/ caution. Really intended for use by the persistence layer.
+ """
+ # if caller wants kvs, caller's assuming it's up to date; so, decache it
+ self.save()
+ return self._field_data._kvs # pylint: disable=protected-access
+
def get_children(self):
- '''
+ """Returns a list of XBlock instances for the children of
+ this module"""
+
+ if not self.has_children:
+ return []
+
+ if getattr(self, '_child_instances', None) is None:
+ self._child_instances = [] # pylint: disable=attribute-defined-outside-init
+ for child_loc in self.children:
+ try:
+ child = self.runtime.get_block(child_loc)
+ except ItemNotFoundError:
+ log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc))
+ continue
+ self._child_instances.append(child)
+
+ return self._child_instances
+
+ def get_required_module_descriptors(self):
+ """Returns a list of XModuleDescriptor instances upon which this module depends, but are
+ not children of this module"""
+ return []
+
+ def get_display_items(self):
+ """
+ Returns a list of descendent module instances that will display
+ immediately inside this module.
+ """
+ items = []
+ for child in self.get_children():
+ items.extend(child.displayable_items())
+
+ return items
+
+ def displayable_items(self):
+ """
+ Returns list of displayable modules contained by this module. If this
+ module is visible, should return [self].
+ """
+ return [self]
+
+ def get_child_by(self, selector):
+ """
+ Return a child XBlock that matches the specified selector
+ """
+ for child in self.get_children():
+ if selector(child):
+ return child
+ return None
+
+
+class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-method
+ """ Implements a generic learning module.
+
+ Subclasses must at a minimum provide a definition for get_html in order
+ to be displayed to users.
+
+ See the HTML module for a simple example.
+ """
+
+ # The default implementation of get_icon_class returns the icon_class
+ # attribute of the class
+ #
+ # This attribute can be overridden by subclasses, and
+ # the function can also be overridden if the icon class depends on the data
+ # in the module
+ icon_class = 'other'
+
+ def __init__(self, descriptor, *args, **kwargs):
+ """
+ Construct a new xmodule
+
+ runtime: An XBlock runtime allowing access to external resources
+
+ descriptor: the XModuleDescriptor that this module is an instance of.
+
+ field_data: A dictionary-like object that maps field names to values
+ for those fields.
+ """
+ super(XModule, self).__init__(*args, **kwargs)
+ self.system = self.runtime
+ self.descriptor = descriptor
+ self._loaded_children = None
+
+ def get_children(self):
+ """
Return module instances for all the children of this module.
- '''
+ """
if self._loaded_children is None:
child_descriptors = self.get_child_descriptors()
@@ -200,7 +272,7 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
return ''.format(self.id)
def get_child_descriptors(self):
- '''
+ """
Returns the descriptors of the child modules
Overriding this changes the behavior of get_children and
@@ -211,40 +283,13 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
These children will be the same children returned by the
descriptor unless descriptor.has_dynamic_children() is true.
- '''
+ """
return self.descriptor.get_children()
- def get_child_by(self, selector):
- """
- Return a child XModuleDescriptor with the specified url_name, if it exists, and None otherwise.
- """
- for child in self.get_children():
- if selector(child):
- return child
- return None
-
- def get_display_items(self):
- '''
- Returns a list of descendent module instances that will display
- immediately inside this module.
- '''
- items = []
- for child in self.get_children():
- items.extend(child.displayable_items())
-
- return items
-
- def displayable_items(self):
- '''
- Returns list of displayable modules contained by this module. If this
- module is visible, should return [self].
- '''
- return [self]
-
def get_icon_class(self):
- '''
+ """
Return a css class identifying this module in the context of an icon
- '''
+ """
return self.icon_class
# Functions used in the LMS
@@ -267,7 +312,7 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
return None
def max_score(self):
- ''' Maximum score. Two notes:
+ """ Maximum score. Two notes:
* This is generic; in abstract, a problem could be 3/5 points on one
randomization, and 5/7 on another
@@ -275,22 +320,22 @@ class XModule(XModuleFields, HTMLSnippet, XBlock):
* In practice, this is a Very Bad Idea, and (a) will break some code
in place (although that code should get fixed), and (b) break some
analytics we plan to put in place.
- '''
+ """
return None
def get_progress(self):
- ''' Return a progress.Progress object that represents how far the
+ """ Return a progress.Progress object that represents how far the
student has gone in this module. Must be implemented to get correct
progress tracking behavior in nesting modules like sequence and
vertical.
If this module has no notion of progress, return None.
- '''
+ """
return None
def handle_ajax(self, _dispatch, _data):
- ''' dispatch is last part of the URL.
- data is a dictionary-like object with the content of the request'''
+ """ dispatch is last part of the URL.
+ data is a dictionary-like object with the content of the request"""
return ""
@@ -381,7 +426,7 @@ class ResourceTemplates(object):
return None
-class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
+class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
"""
An XModuleDescriptor is a specification for an element of a course. This
could be a problem, an organizational element (a group of content), or a
@@ -446,86 +491,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
self.edited_by = self.edited_on = self.previous_version = self.update_version = self.definition_locator = None
self._child_instances = None
- @property
- def id(self):
- return self.location.url()
-
- @property
- def category(self):
- return self.scope_ids.block_type
-
- @property
- def location(self):
- try:
- return Location(self.scope_ids.usage_id)
- except InvalidLocationError:
- if isinstance(self.scope_ids.usage_id, BlockUsageLocator):
- return self.scope_ids.usage_id
- else:
- return BlockUsageLocator(self.scope_ids.usage_id)
-
- @location.setter
- def location(self, value):
- self.scope_ids = self.scope_ids._replace(
- def_id=value,
- usage_id=value,
- )
-
- @property
- def url_name(self):
- if isinstance(self.location, Location):
- return self.location.name
- elif isinstance(self.location, BlockUsageLocator):
- return self.location.usage_id
- else:
- raise InsufficientSpecificationError()
-
-
- @property
- def display_name_with_default(self):
- '''
- Return a display name for the module: use display_name if defined in
- metadata, otherwise convert the url name.
- '''
- name = self.display_name
- if name is None:
- name = self.url_name.replace('_', ' ')
- return name
-
- def get_required_module_descriptors(self):
- """Returns a list of XModuleDescritpor instances upon which this module depends, but are
- not children of this module"""
- return []
-
- def get_children(self):
- """Returns a list of XModuleDescriptor instances for the children of
- this module"""
- if not self.has_children:
- return []
-
- if self._child_instances is None:
- self._child_instances = []
- for child_loc in self.children:
- if isinstance(child_loc, XModuleDescriptor):
- child = child_loc
- else:
- try:
- child = self.runtime.get_block(child_loc)
- except ItemNotFoundError:
- log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc))
- continue
- self._child_instances.append(child)
-
- return self._child_instances
-
- def get_child_by(self, selector):
- """
- Return a child XModuleDescriptor with the specified url_name, if it exists, and None otherwise.
- """
- for child in self.get_children():
- if selector(child):
- return child
- return None
def xmodule(self, system):
"""
@@ -619,15 +584,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
raise NotImplementedError(
'Modules must implement export_to_xml to enable xml export')
- @property
- def xblock_kvs(self):
- """
- Use w/ caution. Really intended for use by the persistence layer.
- """
- # if caller wants kvs, caller's assuming it's up to date; so, decache it
- self.save()
- return self._field_data._kvs
-
# =============================== BUILTIN METHODS ==========================
def __eq__(self, other):
return (self.scope_ids == other.scope_ids and
@@ -655,17 +611,6 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
return [XBlock.tags, XBlock.name]
- def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
- """
- Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
- any set to None.)
- """
- result = {}
- for field in self.fields.values():
- if (field.scope == scope and field.is_set_on(self)):
- result[field.name] = field.read_json(self)
- return result
-
@property
def editable_metadata_fields(self):
"""
@@ -825,7 +770,7 @@ class XMLParsingSystem(DescriptorSystem):
class ModuleSystem(Runtime):
- '''
+ """
This is an abstraction such that x_modules can function independent
of the courseware (e.g. import into other types of courseware, LMS,
or if we want to have a sandbox server for user-contributed content)
@@ -835,7 +780,7 @@ class ModuleSystem(Runtime):
Note that these functions can be closures over e.g. a django request
and user, or other environment-specific info.
- '''
+ """
def __init__(
self, ajax_url, track_function, get_module, render_template,
replace_urls, xmodule_field_data, user=None, filestore=None,
@@ -844,7 +789,7 @@ class ModuleSystem(Runtime):
open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
replace_jump_to_id_urls=None, **kwargs):
- '''
+ """
Create a closure around the system environment.
ajax_url - the url where ajax calls to the encapsulating module go.
@@ -893,7 +838,7 @@ class ModuleSystem(Runtime):
can_execute_unsafe_code - A function returning a boolean, whether or
not to allow the execution of unsafe, unsandboxed code.
- '''
+ """
super(ModuleSystem, self).__init__(**kwargs)
self.ajax_url = ajax_url
@@ -926,11 +871,11 @@ class ModuleSystem(Runtime):
self.replace_jump_to_id_urls = replace_jump_to_id_urls
def get(self, attr):
- ''' provide uniform access to attributes (like etree).'''
+ """ provide uniform access to attributes (like etree)."""
return self.__dict__.get(attr)
def set(self, attr, val):
- '''provide uniform access to attributes (like etree)'''
+ """provide uniform access to attributes (like etree)"""
self.__dict__[attr] = val
def __repr__(self):
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 941809b82f..3858b1cfb1 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -32,6 +32,7 @@ from .discussionsettings import *
from lms.xblock.mixin import LmsBlockMixin
from xmodule.modulestore.inheritance import InheritanceMixin
+from xmodule.x_module import XModuleMixin
################################### FEATURES ###################################
# The display name of the platform to be used in templates/emails/etc.
@@ -363,7 +364,7 @@ CONTENTSTORE = None
# 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, InheritanceMixin)
+XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin)
#################### Python sandbox ############################################
From d75cdda064b00924a42abab77476e2967682edb6 Mon Sep 17 00:00:00 2001
From: Giulio Gratta
Date: Thu, 26 Sep 2013 11:22:16 -0700
Subject: [PATCH 22/54] An assortment of small OE changes, both for ICE and
standard
- Changed save OE message text
- Fix ICE legend positioning and content
- Add space to "offensive" checkbox
- Change tracker variable in undo to correct one
---
.../xmodule/xmodule/js/src/combinedopenended/display.coffee | 2 +-
.../xmodule/xmodule/js/src/peergrading/track_changes.coffee | 2 +-
lms/static/sass/course/_staff_grading.scss | 2 +-
lms/templates/peer_grading/peer_grading_problem.html | 5 +++--
4 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
index d1006ae9a9..7103d2e841 100644
--- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
@@ -353,7 +353,7 @@ class @CombinedOpenEnded
@save_button.attr("disabled",true)
$.postWithPrefix "#{@ajax_url}/store_answer", data, (response) =>
if response.success
- @gentle_alert("Answer saved.")
+ @gentle_alert("Answer saved, but not yet submitted.")
else
@errors_area.html(response.error)
@save_button.attr("disabled",false)
diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/track_changes.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/track_changes.coffee
index ff0b4111f2..72df455b0b 100644
--- a/common/lib/xmodule/xmodule/js/src/peergrading/track_changes.coffee
+++ b/common/lib/xmodule/xmodule/js/src/peergrading/track_changes.coffee
@@ -56,7 +56,7 @@ class @TrackChanges
key = parseInt(@attr('data-cid'))
if key > keyOfLatestChange
keyOfLatestChange = key
- ICEtracker.rejectChange('[data-cid="'+ keyOfLatestChange + '"]')
+ @tracker.rejectChange('[data-cid="'+ keyOfLatestChange + '"]')
stop_tracking_on_submit: () =>
@tracker.stopTracking()
\ No newline at end of file
diff --git a/lms/static/sass/course/_staff_grading.scss b/lms/static/sass/course/_staff_grading.scss
index 6810fe1bff..fd48ac28dc 100644
--- a/lms/static/sass/course/_staff_grading.scss
+++ b/lms/static/sass/course/_staff_grading.scss
@@ -18,7 +18,7 @@ div.peer-grading{
overflow: auto;
}
- div.feedback-area.track-changes, p.legend {
+ div.feedback-area.track-changes, p.ice-legend {
.ice-controls {
float: right;
}
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. ")}
From 64d4ebdaa15f2a6a4e22f5f28d0b43137883d37f Mon Sep 17 00:00:00 2001
From: Usman Khalid
Date: Sat, 28 Sep 2013 00:14:07 +0500
Subject: [PATCH 23/54] Revert last commit
---
common/static/js/pdfviewer.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/common/static/js/pdfviewer.js b/common/static/js/pdfviewer.js
index 3424a09892..a76e2f397c 100755
--- a/common/static/js/pdfviewer.js
+++ b/common/static/js/pdfviewer.js
@@ -327,11 +327,11 @@ PDFJS.disableWorker = true;
loadUrl(chapterUrl, pageVal);
}
- $(".last").on('click','#previous',function(){
+ $("#previous").click(function(event) {
prevPage();
});
- $(".next").on('click','#next',function(){
+ $("#next").click(function(event) {
nextPage();
});
From 67ccf1e3bb291be3e36772d0fa0159a71862cbdb Mon Sep 17 00:00:00 2001
From: Calen Pennington
Date: Tue, 24 Sep 2013 15:10:06 -0400
Subject: [PATCH 24/54] Move nose/django arguments for tests out of rake so
that raw manage.py works when running tests
---
cms/envs/test.py | 10 ++++++++++
lms/envs/test.py | 13 +++++++++++++
rakelib/tests.rake | 12 ++----------
3 files changed, 25 insertions(+), 10 deletions(-)
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/lms/envs/test.py b/lms/envs/test.py
index 30475e26a0..f79c8c4218 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -17,6 +17,8 @@ import os
from path import path
from warnings import filterwarnings
+os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost:8000-9000'
+
# can't test start dates with this True, but on the other hand,
# can test everything else :)
MITX_FEATURES['DISABLE_START_DATES'] = True
@@ -43,6 +45,17 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# Nose Test Runner
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
+_system = 'lms'
+
+_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',
+]
+
# Local Directories
TEST_ROOT = path("test_root")
# Want static files in the same dir for running on jenkins.
diff --git a/rakelib/tests.rake b/rakelib/tests.rake
index fb6cf575bd..ba436f3680 100644
--- a/rakelib/tests.rake
+++ b/rakelib/tests.rake
@@ -17,16 +17,8 @@ def run_under_coverage(cmd, root)
end
def run_tests(system, report_dir, test_id=nil, stop_on_failure=true)
- ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml")
- test_id_file = File.join(test_id_dir(system), "noseids")
- dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"]
- test_id = dirs.join(' ') if test_id.nil? or test_id == ''
- cmd = django_admin(
- system, :test, 'test',
- '--logging-clear-handlers',
- '--liveserver=localhost:8000-9000',
- "--id-file=#{test_id_file}",
- test_id)
+ test_id = '' if test_id.nil?
+ cmd = django_admin(system, :test, 'test', test_id)
test_sh(run_under_coverage(cmd, system))
end
From 9a6ae8f7403bfe310e9e1334850fb3d4f9042392 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 20 Sep 2013 13:53:09 -0400
Subject: [PATCH 25/54] Clean up open ended problems view and fix error.
---
lms/djangoapps/open_ended_grading/tests.py | 115 +++++++++-
lms/djangoapps/open_ended_grading/utils.py | 147 ++++++++++++
lms/djangoapps/open_ended_grading/views.py | 212 ++++++------------
.../open_ended_problems.html | 56 +++--
4 files changed, 350 insertions(+), 180 deletions(-)
diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py
index 5a0d9e69e5..5224386664 100644
--- a/lms/djangoapps/open_ended_grading/tests.py
+++ b/lms/djangoapps/open_ended_grading/tests.py
@@ -18,8 +18,9 @@ from xmodule.modulestore.django import modulestore
from xmodule.x_module import ModuleSystem
from xblock.fields import ScopeIds
-from open_ended_grading import staff_grading_service, views
+from open_ended_grading import staff_grading_service, views, utils
from courseware.access import _course_staff_group_name
+from student.models import unique_id_for_user
import logging
@@ -46,6 +47,57 @@ class EmptyStaffGradingService(object):
"""
return json.dumps({'success': True, 'error': 'No problems found.'})
+def make_instructor(course, user_email):
+ """
+ Makes a given user an instructor in a course.
+ """
+ group_name = _course_staff_group_name(course.location)
+ group = Group.objects.create(name=group_name)
+ group.user_set.add(User.objects.get(email=user_email))
+
+class StudentProblemListMockQuery(object):
+ """
+ Mock controller query service for testing student problem list functionality.
+ """
+ def get_grading_status_list(self, *args, **kwargs):
+ """
+ Get a mock grading status list with locations from the open_ended test course.
+ @returns: json formatted grading status message.
+ """
+ grading_status_list = json.dumps(
+ {
+ "version": 1,
+ "problem_list": [
+ {
+ "problem_name": "Test1",
+ "grader_type": "IN",
+ "eta_available": True,
+ "state": "Finished",
+ "eta": 259200,
+ "location": "i4x://edX/open_ended/combinedopenended/SampleQuestion1Attempt"
+ },
+ {
+ "problem_name": "Test2",
+ "grader_type": "NA",
+ "eta_available": True,
+ "state": "Waiting to be Graded",
+ "eta": 259200,
+ "location": "i4x://edX/open_ended/combinedopenended/SampleQuestion"
+ },
+ {
+ "problem_name": "Test3",
+ "grader_type": "PE",
+ "eta_available": True,
+ "state": "Waiting to be Graded",
+ "eta": 259200,
+ "location": "i4x://edX/open_ended/combinedopenended/SampleQuestion454"
+ },
+ ],
+ "success": True
+ }
+ )
+ return grading_status_list
+
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestStaffGradingService(ModuleStoreTestCase, LoginEnrollmentTestCase):
'''
@@ -67,12 +119,7 @@ class TestStaffGradingService(ModuleStoreTestCase, LoginEnrollmentTestCase):
self.course_id = "edX/toy/2012_Fall"
self.toy = modulestore().get_course(self.course_id)
- def make_instructor(course):
- group_name = _course_staff_group_name(course.location)
- group = Group.objects.create(name=group_name)
- group.user_set.add(User.objects.get(email=self.instructor))
-
- make_instructor(self.toy)
+ make_instructor(self.toy, self.instructor)
self.mock_service = staff_grading_service.staff_grading_service()
@@ -324,7 +371,7 @@ class TestPeerGradingService(ModuleStoreTestCase, LoginEnrollmentTestCase):
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
-class TestPanel(ModuleStoreTestCase, LoginEnrollmentTestCase):
+class TestPanel(ModuleStoreTestCase):
"""
Run tests on the open ended panel
"""
@@ -343,7 +390,15 @@ class TestPanel(ModuleStoreTestCase, LoginEnrollmentTestCase):
found_module, peer_grading_module = views.find_peer_grading_module(self.course)
self.assertTrue(found_module)
- @patch('open_ended_grading.views.controller_qs', controller_query_service.MockControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, views.system))
+ @patch(
+ 'open_ended_grading.utils.create_controller_query_service',
+ Mock(
+ return_value=controller_query_service.MockControllerQueryService(
+ settings.OPEN_ENDED_GRADING_INTERFACE,
+ utils.system
+ )
+ )
+ )
def test_problem_list(self):
"""
Ensure that the problem list from the grading controller server can be rendered properly locally
@@ -370,4 +425,44 @@ class TestPeerGradingFound(ModuleStoreTestCase):
"""
found, url = views.find_peer_grading_module(self.course)
- self.assertEqual(found, False)
\ No newline at end of file
+ self.assertEqual(found, False)
+
+@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
+class TestStudentProblemList(ModuleStoreTestCase):
+ """
+ Test if the student problem list correctly fetches and parses problems.
+ """
+
+ def setUp(self):
+ # Load an open ended course with several problems.
+ self.course_name = 'edX/open_ended/2012_Fall'
+ self.course = modulestore().get_course(self.course_name)
+ self.user = factories.UserFactory()
+ # Enroll our user in our course and make them an instructor.
+ make_instructor(self.course, self.user.email)
+
+ @patch(
+ 'open_ended_grading.utils.create_controller_query_service',
+ Mock(return_value=StudentProblemListMockQuery())
+ )
+ def test_get_problem_list(self):
+ """
+ Test to see if the StudentProblemList class can get and parse a problem list from ORA.
+ Mock the get_grading_status_list function using StudentProblemListMockQuery.
+ """
+ # Initialize a StudentProblemList object.
+ student_problem_list = utils.StudentProblemList(self.course.id, unique_id_for_user(self.user))
+ # Get the initial problem list from ORA.
+ success = student_problem_list.fetch_from_grading_service()
+ # Should be successful, and we should have three problems. See mock class for details.
+ self.assertTrue(success)
+ self.assertEqual(len(student_problem_list.problem_list), 3)
+
+ # See if the problem locations are valid.
+ valid_problems = student_problem_list.add_problem_data(reverse('courses'))
+ # One location is invalid, so we should now have two.
+ self.assertEqual(len(valid_problems), 2)
+ # Ensure that human names are being set properly.
+ self.assertEqual(valid_problems[0]['grader_type_display_name'], "Instructor Assessment")
+
+
diff --git a/lms/djangoapps/open_ended_grading/utils.py b/lms/djangoapps/open_ended_grading/utils.py
index 15aaf0246f..1f860b1a6d 100644
--- a/lms/djangoapps/open_ended_grading/utils.py
+++ b/lms/djangoapps/open_ended_grading/utils.py
@@ -1,10 +1,58 @@
+import json
+
from xmodule.modulestore import search
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
+from xmodule.x_module import ModuleSystem
+from xmodule.open_ended_grading_classes.controller_query_service import ControllerQueryService
+from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
+
+from django.utils.translation import ugettext as _
+from django.conf import settings
+
+from mitxmako.shortcuts import render_to_string
+
+from xblock.field_data import DictFieldData
+
import logging
log = logging.getLogger(__name__)
+GRADER_DISPLAY_NAMES = {
+ 'ML': _("AI Assessment"),
+ 'PE': _("Peer Assessment"),
+ 'NA': _("Not yet available"),
+ 'BC': _("Automatic Checker"),
+ 'IN': _("Instructor Assessment"),
+ }
+
+STUDENT_ERROR_MESSAGE = _("Error occurred while contacting the grading service. Please notify course staff.")
+STAFF_ERROR_MESSAGE = _("Error occurred while contacting the grading service. Please notify your edX point of contact.")
+
+system = ModuleSystem(
+ ajax_url=None,
+ track_function=None,
+ get_module=None,
+ render_template=render_to_string,
+ replace_urls=None,
+ xmodule_field_data=DictFieldData({}),
+ )
+
+def generate_problem_url(problem_url_parts, base_course_url):
+ """
+ From a list of problem url parts generated by search.path_to_location and a base course url, generates a url to a problem
+ @param problem_url_parts: Output of search.path_to_location
+ @param base_course_url: Base url of a given course
+ @return: A path to the problem
+ """
+ problem_url = base_course_url + "/"
+ for i, part in enumerate(problem_url_parts):
+ if part is not None:
+ if i == 1:
+ problem_url += "courseware/"
+ problem_url += part + "/"
+ return problem_url
+
def does_location_exist(course_id, location):
"""
Checks to see if a valid module exists at a given location (ie has not been deleted)
@@ -25,3 +73,102 @@ def does_location_exist(course_id, location):
log.warn(("Got an unexpected NoPathToItem error in staff grading with a non-draft location {0}. "
"Ensure that the location is valid.").format(location))
return False
+
+def create_controller_query_service():
+ """
+ Return an instance of a service that can query edX ORA.
+ """
+
+ return ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
+
+class StudentProblemList(object):
+ """
+ Get a list of problems that the student has attempted from ORA.
+ Add in metadata as needed.
+ """
+ def __init__(self, course_id, user_id):
+ """
+ @param course_id: The id of a course object. Get using course.id.
+ @param user_id: The anonymous id of the user, from the unique_id_for_user function.
+ """
+ self.course_id = course_id
+ self.user_id = user_id
+
+ # We want to append this string to all of our error messages.
+ self.course_error_ending = _("for course {0} and student {1}.").format(self.course_id, user_id)
+
+ # This is our generic error message.
+ self.error_text = STUDENT_ERROR_MESSAGE
+ self.success = False
+
+ # Create a service to query edX ORA.
+ self.controller_qs = create_controller_query_service()
+
+ def fetch_from_grading_service(self):
+ """
+ Fetch a list of problems that the student has answered from ORA.
+ Handle various error conditions.
+ @return: A boolean success indicator.
+ """
+ # In the case of multiple calls, ensure that success is false initially.
+ self.success = False
+ try:
+ #Get list of all open ended problems that the grading server knows about
+ problem_list_json = self.controller_qs.get_grading_status_list(self.course_id, self.user_id)
+ except GradingServiceError:
+ log.error("Problem contacting open ended grading service " + self.course_error_ending)
+ return self.success
+ try:
+ problem_list_dict = json.loads(problem_list_json)
+ except ValueError:
+ log.error("Problem with results from external grading service for open ended" + self.course_error_ending)
+ return self.success
+
+ success = problem_list_dict['success']
+ if 'error' in problem_list_dict:
+ self.error_text = problem_list_dict['error']
+ return success
+ if 'problem_list' not in problem_list_dict:
+ log.error("Did not receive a problem list in ORA response" + self.course_error_ending)
+ return success
+
+ self.problem_list = problem_list_dict['problem_list']
+
+ self.success = True
+ return self.success
+
+ def add_problem_data(self, base_course_url):
+ """
+ Add metadata to problems.
+ @param base_course_url: the base url for any course. Can get with reverse('course')
+ @return: A list of valid problems in the course and their appended data.
+ """
+ # Our list of valid problems.
+ valid_problems = []
+
+ if not self.success or not isinstance(self.problem_list, list):
+ log.error("Called add_problem_data without a valid problem list" + self.course_error_ending)
+ return valid_problems
+
+ # Iterate through all of our problems and add data.
+ for problem in self.problem_list:
+ try:
+ # Try to load the problem.
+ problem_url_parts = search.path_to_location(modulestore(), self.course_id, problem['location'])
+ except (ItemNotFoundError, NoPathToItem):
+ # If the problem cannot be found at the location received from the grading controller server,
+ # it has been deleted by the course author. We should not display it.
+ error_message = "Could not find module for course {0} at location {1}".format(self.course_id,
+ problem['location'])
+ log.error(error_message)
+ continue
+
+ # Get the problem url in the courseware.
+ problem_url = generate_problem_url(problem_url_parts, base_course_url)
+
+ # Map the grader name from ORA to a human readable version.
+ grader_type_display_name = GRADER_DISPLAY_NAMES.get(problem['grader_type'], "edX Assessment")
+ problem['actual_url'] = problem_url
+ problem['grader_type_display_name'] = grader_type_display_name
+ valid_problems.append(problem)
+ return valid_problems
diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py
index dcc1e11730..e8002e0883 100644
--- a/lms/djangoapps/open_ended_grading/views.py
+++ b/lms/djangoapps/open_ended_grading/views.py
@@ -1,5 +1,3 @@
-# Grading Views
-
import logging
from django.conf import settings
@@ -7,13 +5,9 @@ from django.views.decorators.cache import cache_control
from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse
-from xblock.field_data import DictFieldData
-
from student.models import unique_id_for_user
from courseware.courses import get_course_with_access
-from xmodule.x_module import ModuleSystem
-from xmodule.open_ended_grading_classes.controller_query_service import ControllerQueryService, convert_seconds_to_human_readable
from xmodule.open_ended_grading_classes.grading_service_module import GradingServiceError
import json
from student.models import unique_id_for_user
@@ -26,28 +20,21 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from django.http import HttpResponse, Http404, HttpResponseRedirect
from mitxmako.shortcuts import render_to_string
+from django.utils.translation import ugettext as _
+
+from open_ended_grading.utils import (STAFF_ERROR_MESSAGE, STUDENT_ERROR_MESSAGE,
+ StudentProblemList, generate_problem_url, create_controller_query_service)
log = logging.getLogger(__name__)
-system = ModuleSystem(
- ajax_url=None,
- track_function=None,
- get_module=None,
- render_template=render_to_string,
- replace_urls=None,
- xmodule_field_data=DictFieldData({}),
-)
-
-controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system)
-
-"""
-Reverses the URL from the name and the course id, and then adds a trailing slash if
-it does not exist yet
-
-"""
-
-
def _reverse_with_slash(url_name, course_id):
+ """
+ Reverses the URL given the name and the course id, and then adds a trailing slash if
+ it does not exist yet.
+ @param url_name: The name of the url (eg 'staff_grading').
+ @param course_id: The id of the course object (eg course.id).
+ @returns: The reversed url with a trailing slash.
+ """
ajax_url = _reverse_without_slash(url_name, course_id)
if not ajax_url.endswith('/'):
ajax_url += '/'
@@ -60,22 +47,19 @@ def _reverse_without_slash(url_name, course_id):
DESCRIPTION_DICT = {
- 'Peer Grading': "View all problems that require peer assessment in this particular course.",
- 'Staff Grading': "View ungraded submissions submitted by students for the open ended problems in the course.",
- 'Problems you have submitted': "View open ended problems that you have previously submitted for grading.",
- 'Flagged Submissions': "View submissions that have been flagged by students as inappropriate."
+ 'Peer Grading': _("View all problems that require peer assessment in this particular course."),
+ 'Staff Grading': _("View ungraded submissions submitted by students for the open ended problems in the course."),
+ 'Problems you have submitted': _("View open ended problems that you have previously submitted for grading."),
+ 'Flagged Submissions': _("View submissions that have been flagged by students as inappropriate."),
}
+
ALERT_DICT = {
- 'Peer Grading': "New submissions to grade",
- 'Staff Grading': "New submissions to grade",
- 'Problems you have submitted': "New grades have been returned",
- 'Flagged Submissions': "Submissions have been flagged for review"
+ 'Peer Grading': _("New submissions to grade"),
+ 'Staff Grading': _("New submissions to grade"),
+ 'Problems you have submitted': _("New grades have been returned"),
+ 'Flagged Submissions': _("Submissions have been flagged for review"),
}
-STUDENT_ERROR_MESSAGE = "Error occurred while contacting the grading service. Please notify course staff."
-STAFF_ERROR_MESSAGE = "Error occurred while contacting the grading service. Please notify the development team. If you do not have a point of contact, please email Vik at vik@edx.org"
-
-
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def staff_grading(request, course_id):
"""
@@ -107,8 +91,6 @@ def find_peer_grading_module(course):
# Get the course id and split it.
course_id_parts = course.id.split("/")
- log.info("COURSE ID PARTS")
- log.info(course_id_parts)
# Get the peer grading modules currently in the course. Explicitly specify the course id to avoid issues with different runs.
items = modulestore().get_items(['i4x', course_id_parts[0], course_id_parts[1], 'peergrading', None],
course_id=course.id)
@@ -123,7 +105,7 @@ def find_peer_grading_module(course):
except NoPathToItem:
# In the case of nopathtoitem, the peer grading module that was found is in an invalid state, and
# can no longer be accessed. Log an informational message, but this will not impact normal behavior.
- log.info("Invalid peer grading module location {0} in course {1}. This module may need to be removed.".format(item_location, course.id))
+ log.info(u"Invalid peer grading module location {0} in course {1}. This module may need to be removed.".format(item_location, course.id))
continue
problem_url = generate_problem_url(problem_url_parts, base_course_url)
found_module = True
@@ -143,121 +125,60 @@ def peer_grading(request, course_id):
found_module, problem_url = find_peer_grading_module(course)
if not found_module:
- #This is a student_facing_error
- error_message = """
+ error_message = _("""
Error with initializing peer grading.
There has not been a peer grading module created in the courseware that would allow you to grade others.
Please check back later for this.
- """
- #This is a dev_facing_error
- log.exception(error_message + "Current course is: {0}".format(course_id))
+ """)
+ log.exception(error_message + u"Current course is: {0}".format(course_id))
return HttpResponse(error_message)
return HttpResponseRedirect(problem_url)
-
-def generate_problem_url(problem_url_parts, base_course_url):
- """
- From a list of problem url parts generated by search.path_to_location and a base course url, generates a url to a problem
- @param problem_url_parts: Output of search.path_to_location
- @param base_course_url: Base url of a given course
- @return: A path to the problem
- """
- problem_url = base_course_url + "/"
- for z in xrange(0, len(problem_url_parts)):
- part = problem_url_parts[z]
- if part is not None:
- if z == 1:
- problem_url += "courseware/"
- problem_url += part + "/"
- return problem_url
-
-
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def student_problem_list(request, course_id):
- '''
- Show a student problem list to a student. Fetch the list from the grading controller server, get some metadata,
- and then show it to the student.
- '''
+ """
+ Show a list of problems they have attempted to a student.
+ Fetch the list from the grading controller server and append some data.
+ @param request: The request object for this view.
+ @param course_id: The id of the course to get the problem list for.
+ @return: Renders an HTML problem list table.
+ """
+
+ # Load the course. Don't catch any errors here, as we want them to be loud.
course = get_course_with_access(request.user, course_id, 'load')
+
+ # The anonymous student id is needed for communication with ORA.
student_id = unique_id_for_user(request.user)
-
- # call problem list service
- success = False
- error_text = ""
- problem_list = []
base_course_url = reverse('courses')
- list_to_remove = []
+ error_text = ""
- try:
- #Get list of all open ended problems that the grading server knows about
- problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user))
- problem_list_dict = json.loads(problem_list_json)
- success = problem_list_dict['success']
- if 'error' in problem_list_dict:
- error_text = problem_list_dict['error']
- problem_list = []
- else:
- problem_list = problem_list_dict['problem_list']
+ student_problem_list = StudentProblemList(course_id, student_id)
+ # Get the problem list from ORA.
+ success = student_problem_list.fetch_from_grading_service()
+ # If we fetched the problem list properly, add in additional problem data.
+ if success:
+ # Add in links to problems.
+ valid_problems = student_problem_list.add_problem_data(base_course_url)
+ else:
+ # Get an error message to show to the student.
+ valid_problems = []
+ error_text = student_problem_list.error_text
- #A list of problems to remove (problems that can't be found in the course)
- for i in xrange(0, len(problem_list)):
- try:
- #Try to load each problem in the courseware to get links to them
- problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location'])
- except ItemNotFoundError:
- #If the problem cannot be found at the location received from the grading controller server, it has been deleted by the course author.
- #Continue with the rest of the location to construct the list
- error_message = "Could not find module for course {0} at location {1}".format(course.id,
- problem_list[i][
- 'location'])
- log.error(error_message)
- #Mark the problem for removal from the list
- list_to_remove.append(i)
- continue
- problem_url = generate_problem_url(problem_url_parts, base_course_url)
- problem_list[i].update({'actual_url': problem_url})
- eta_available = problem_list[i]['eta_available']
- if isinstance(eta_available, basestring):
- eta_available = (eta_available.lower() == "true")
-
- eta_string = "N/A"
- if eta_available:
- try:
- eta_string = convert_seconds_to_human_readable(int(problem_list[i]['eta']))
- except:
- #This is a student_facing_error
- eta_string = "Error getting ETA."
- problem_list[i].update({'eta_string': eta_string})
-
- except GradingServiceError:
- #This is a student_facing_error
- error_text = STUDENT_ERROR_MESSAGE
- #This is a dev facing error
- log.error("Problem contacting open ended grading service.")
- success = False
- # catch error if if the json loads fails
- except ValueError:
- #This is a student facing error
- error_text = STUDENT_ERROR_MESSAGE
- #This is a dev_facing_error
- log.error("Problem with results from external grading service for open ended.")
- success = False
-
- #Remove problems that cannot be found in the courseware from the list
- problem_list = [problem_list[i] for i in xrange(0, len(problem_list)) if i not in list_to_remove]
ajax_url = _reverse_with_slash('open_ended_problems', course_id)
- return render_to_response('open_ended_problems/open_ended_problems.html', {
+ context = {
'course': course,
'course_id': course_id,
'ajax_url': ajax_url,
'success': success,
- 'problem_list': problem_list,
+ 'problem_list': valid_problems,
'error_text': error_text,
# Checked above
- 'staff_access': False, })
+ 'staff_access': False,
+ }
+ return render_to_response('open_ended_problems/open_ended_problems.html', context)
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def flagged_problem_list(request, course_id):
@@ -273,6 +194,8 @@ def flagged_problem_list(request, course_id):
problem_list = []
base_course_url = reverse('courses')
+ # Make a service that can query edX ORA.
+ controller_qs = create_controller_query_service()
try:
problem_list_json = controller_qs.get_flagged_problem_list(course_id)
problem_list_dict = json.loads(problem_list_json)
@@ -384,11 +307,12 @@ def take_action_on_flags(request, course_id):
required = ['submission_id', 'action_type', 'student_id']
for key in required:
if key not in request.POST:
- #This is a staff_facing_error
- return HttpResponse(json.dumps({'success': False,
- 'error': STAFF_ERROR_MESSAGE + 'Missing key {0} from submission. Please reload and try again.'.format(
- key)}),
- mimetype="application/json")
+ error_message = u'Missing key {0} from submission. Please reload and try again.'.format(key)
+ response = {
+ 'success': False,
+ 'error': STAFF_ERROR_MESSAGE + error_message
+ }
+ return HttpResponse(json.dumps(response), mimetype="application/json")
p = request.POST
submission_id = p['submission_id']
@@ -397,12 +321,20 @@ def take_action_on_flags(request, course_id):
student_id = student_id.strip(' \t\n\r')
submission_id = submission_id.strip(' \t\n\r')
action_type = action_type.lower().strip(' \t\n\r')
+
+ # Make a service that can query edX ORA.
+ controller_qs = create_controller_query_service()
try:
response = controller_qs.take_action_on_flags(course_id, student_id, submission_id, action_type)
return HttpResponse(response, mimetype="application/json")
except GradingServiceError:
- #This is a dev_facing_error
log.exception(
- "Error taking action on flagged peer grading submissions, submission_id: {0}, action_type: {1}, grader_id: {2}".format(
- submission_id, action_type, grader_id))
- return _err_response(STAFF_ERROR_MESSAGE)
+ u"Error taking action on flagged peer grading submissions, "
+ u"submission_id: {0}, action_type: {1}, grader_id: {2}".format(
+ submission_id, action_type, student_id)
+ )
+ response = {
+ 'success': False,
+ 'error': STAFF_ERROR_MESSAGE
+ }
+ return HttpResponse(json.dumps(response),mimetype="application/json")
diff --git a/lms/templates/open_ended_problems/open_ended_problems.html b/lms/templates/open_ended_problems/open_ended_problems.html
index aa053111d8..1c7e2657ed 100644
--- a/lms/templates/open_ended_problems/open_ended_problems.html
+++ b/lms/templates/open_ended_problems/open_ended_problems.html
@@ -19,36 +19,32 @@
${_("Instructions")}
${_("Here is a list of open ended problems for this course.")}
% if success:
- % if len(problem_list) == 0:
-
- ${_("You have not attempted any open ended problems yet.")}
-
+ %endif
%endif
From f8c727847067fe53d79c0a45c0019c1045c670e1 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Thu, 26 Sep 2013 11:25:10 -0400
Subject: [PATCH 26/54] Follow patterns Move init to top of class Change
asserts to more meaningful exception types Refactor hard-to-read fn
---
.../xmodule/xmodule/modulestore/locator.py | 98 +++++++++----------
.../modulestore/tests/test_locators.py | 6 +-
2 files changed, 52 insertions(+), 52 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py
index dfeafa5e1d..097f69c7c2 100644
--- a/common/lib/xmodule/xmodule/modulestore/locator.py
+++ b/common/lib/xmodule/xmodule/modulestore/locator.py
@@ -114,6 +114,30 @@ class CourseLocator(Locator):
course_id = None
branch = None
+ def __init__(self, url=None, version_guid=None, course_id=None, branch=None):
+ """
+ Construct a CourseLocator
+ Caller may provide url (but no other parameters).
+ Caller may provide version_guid (but no other parameters).
+ Caller may provide course_id (optionally provide branch).
+
+ Resulting CourseLocator will have either a version_guid property
+ or a course_id (with optional branch) property, or both.
+
+ version_guid must be an instance of bson.objectid.ObjectId or None
+ url, course_id, and branch must be strings or None
+
+ """
+ self._validate_args(url, version_guid, course_id)
+ if url:
+ self.init_from_url(url)
+ if version_guid:
+ self.init_from_version_guid(version_guid)
+ if course_id or branch:
+ self.init_from_course_id(course_id, branch)
+ assert self.version_guid or self.course_id, \
+ "Either version_guid or course_id should be set."
+
def __unicode__(self):
"""
Return a string representing this location.
@@ -135,18 +159,13 @@ class CourseLocator(Locator):
"""
return 'edx://' + unicode(self)
- # -- unused args which are used via inspect
- # pylint: disable= W0613
- def validate_args(self, url, version_guid, course_id, branch):
+ def _validate_args(self, url, version_guid, course_id):
"""
- Validate provided arguments.
+ Validate provided arguments. Internal use only which is why it checks for each
+ arg and doesn't use keyword
"""
- need_oneof = set(('url', 'version_guid', 'course_id'))
- args, _, _, values = inspect.getargvalues(inspect.currentframe())
- provided_args = [a for a in args if a != 'self' and values[a] is not None]
- if len(need_oneof.intersection(provided_args)) == 0:
- raise InsufficientSpecificationError("Must provide one of these args: %s " %
- list(need_oneof))
+ if not any((url, version_guid, course_id)):
+ raise InsufficientSpecificationError("Must provide one of url, version_guid, course_id")
def is_fully_specified(self):
"""
@@ -154,8 +173,8 @@ class CourseLocator(Locator):
are specified.
This should always return True, since this should be validated in the constructor.
"""
- return self.version_guid is not None \
- or (self.course_id is not None and self.branch is not None)
+ return (self.version_guid is not None or
+ (self.course_id is not None and self.branch is not None))
def set_course_id(self, new):
"""
@@ -189,30 +208,6 @@ class CourseLocator(Locator):
version_guid=self.version_guid,
branch=self.branch)
- def __init__(self, url=None, version_guid=None, course_id=None, branch=None):
- """
- Construct a CourseLocator
- Caller may provide url (but no other parameters).
- Caller may provide version_guid (but no other parameters).
- Caller may provide course_id (optionally provide branch).
-
- Resulting CourseLocator will have either a version_guid property
- or a course_id (with optional branch) property, or both.
-
- version_guid must be an instance of bson.objectid.ObjectId or None
- url, course_id, and branch must be strings or None
-
- """
- self.validate_args(url, version_guid, course_id, branch)
- if url:
- self.init_from_url(url)
- if version_guid:
- self.init_from_version_guid(version_guid)
- if course_id or branch:
- self.init_from_course_id(course_id, branch)
- assert self.version_guid or self.course_id, \
- "Either version_guid or course_id should be set."
-
@classmethod
def as_object_id(cls, value):
"""
@@ -233,9 +228,11 @@ class CourseLocator(Locator):
"""
if isinstance(url, Locator):
url = url.url()
- assert isinstance(url, basestring), '%s is not an instance of basestring' % url
+ if not isinstance(url, basestring):
+ raise TypeError('%s is not an instance of basestring' % url)
parse = parse_url(url)
- assert parse, 'Could not parse "%s" as a url' % url
+ if not parse:
+ raise ValueError('Could not parse "%s" as a url' % url)
self._set_value(
parse, 'version_guid', lambda (new_guid): self.set_version_guid(self.as_object_id(new_guid))
)
@@ -250,13 +247,13 @@ class CourseLocator(Locator):
"""
version_guid = self.as_object_id(version_guid)
- assert isinstance(version_guid, ObjectId), \
- '%s is not an instance of ObjectId' % version_guid
+ if not isinstance(version_guid, ObjectId):
+ raise TypeError('%s is not an instance of ObjectId' % version_guid)
self.set_version_guid(version_guid)
def init_from_course_id(self, course_id, explicit_branch=None):
"""
- Course_id is a string like 'mit.eecs.6002x' or 'mit.eecs.6002x/branch/published'.
+ Course_id is a CourseLocator or a string like 'mit.eecs.6002x' or 'mit.eecs.6002x/branch/published'.
Revision (optional) is a string like 'published'.
It may be provided explicitly (explicit_branch) or embedded into course_id.
@@ -270,10 +267,12 @@ class CourseLocator(Locator):
if course_id:
if isinstance(course_id, CourseLocator):
course_id = course_id.course_id
- assert course_id, "%s does not have a valid course_id"
+ if not course_id:
+ raise ValueError("%s does not have a valid course_id" % course_id)
parse = parse_course_id(course_id)
- assert parse, 'Could not parse "%s" as a course_id' % course_id
+ if not parse:
+ raise ValueError('Could not parse "%s" as a course_id' % course_id)
self.set_course_id(parse['id'])
rev = parse['branch']
if rev:
@@ -348,7 +347,7 @@ class BlockUsageLocator(CourseLocator):
url, course_id, branch, and usage_id must be strings or None
"""
- self.validate_args(url, version_guid, course_id, branch)
+ self._validate_args(url, version_guid, course_id)
if url:
self.init_block_ref_from_url(url)
if course_id:
@@ -398,7 +397,8 @@ class BlockUsageLocator(CourseLocator):
self.set_usage_id(block_ref)
else:
parse = parse_block_ref(block_ref)
- assert parse, 'Could not parse "%s" as a block_ref' % block_ref
+ if not parse:
+ raise ValueError('Could not parse "%s" as a block_ref' % block_ref)
self.set_usage_id(parse['block'])
def init_block_ref_from_url(self, url):
@@ -461,10 +461,10 @@ class VersionTree(object):
"""
:param locator: must be version specific (Course has version_guid or definition had id)
"""
- assert isinstance(locator, Locator) and not inspect.isabstract(locator), \
- "locator must be a concrete subclass of Locator"
- assert locator.version(), \
- "locator must be version specific (Course has version_guid or definition had id)"
+ if not isinstance(locator, Locator) and not inspect.isabstract(locator):
+ raise TypeError("locator {} must be a concrete subclass of Locator".format(locator))
+ if not locator.version():
+ raise ValueError("locator must be version specific (Course has version_guid or definition had id)")
self.locator = locator
if tree_dict is None:
self.children = []
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
index 05f4d25283..330a2b7320 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
@@ -91,8 +91,8 @@ class LocatorTest(TestCase):
'mit.eecs' + BRANCH_PREFIX + 'this ',
'mit.eecs' + BRANCH_PREFIX + 'th%is ',
):
- self.assertRaises(AssertionError, CourseLocator, course_id=bad_id)
- self.assertRaises(AssertionError, CourseLocator, url='edx://' + bad_id)
+ self.assertRaises(ValueError, CourseLocator, course_id=bad_id)
+ self.assertRaises(ValueError, CourseLocator, url='edx://' + bad_id)
def test_course_constructor_bad_url(self):
for bad_url in ('edx://',
@@ -100,7 +100,7 @@ class LocatorTest(TestCase):
'http://mit.eecs',
'mit.eecs',
'edx//mit.eecs'):
- self.assertRaises(AssertionError, CourseLocator, url=bad_url)
+ self.assertRaises(ValueError, CourseLocator, url=bad_url)
def test_course_constructor_redundant_001(self):
testurn = 'mit.eecs.6002x'
From d79220122c2b5c743a49e21e60d76677f2b5e605 Mon Sep 17 00:00:00 2001
From: Don Mitchell
Date: Thu, 26 Sep 2013 11:27:29 -0400
Subject: [PATCH 27/54] Change DescriptionLocator to DefinitionLocator
---
common/lib/xmodule/xmodule/modulestore/locator.py | 2 +-
.../modulestore/split_mongo/definition_lazy_loader.py | 4 ++--
.../lib/xmodule/xmodule/modulestore/split_mongo/split.py | 8 ++++----
.../xmodule/xmodule/modulestore/tests/test_locators.py | 6 +++---
.../xmodule/modulestore/tests/test_split_modulestore.py | 8 ++++----
5 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py
index 097f69c7c2..47753a4941 100644
--- a/common/lib/xmodule/xmodule/modulestore/locator.py
+++ b/common/lib/xmodule/xmodule/modulestore/locator.py
@@ -424,7 +424,7 @@ class BlockUsageLocator(CourseLocator):
return rep + BLOCK_PREFIX + unicode(self.usage_id)
-class DescriptionLocator(Locator):
+class DefinitionLocator(Locator):
"""
Container for how to locate a description (the course-independent content).
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py
index 5ccaaa7ed3..9b2a652a95 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py
@@ -1,4 +1,4 @@
-from xmodule.modulestore.locator import DescriptionLocator
+from xmodule.modulestore.locator import DefinitionLocator
class DefinitionLazyLoader(object):
@@ -15,7 +15,7 @@ class DefinitionLazyLoader(object):
:param definition_locator: the id of the record in the above to fetch
"""
self.modulestore = modulestore
- self.definition_locator = DescriptionLocator(definition_id)
+ self.definition_locator = DefinitionLocator(definition_id)
def fetch(self):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index 60bfa66b6a..cdcb55d899 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -11,7 +11,7 @@ from pytz import UTC
from xmodule.errortracker import null_error_tracker
from xmodule.x_module import XModuleDescriptor
-from xmodule.modulestore.locator import BlockUsageLocator, DescriptionLocator, CourseLocator, VersionTree, LocalId
+from xmodule.modulestore.locator import BlockUsageLocator, DefinitionLocator, CourseLocator, VersionTree, LocalId
from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError, DuplicateItemError
from xmodule.modulestore import inheritance, ModuleStoreBase, Location
@@ -563,7 +563,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
}
}
new_id = self.definitions.insert(document)
- definition_locator = DescriptionLocator(new_id)
+ definition_locator = DefinitionLocator(new_id)
document['edit_info']['original_version'] = new_id
self.definitions.update({'_id': new_id}, {'$set': {"edit_info.original_version": new_id}})
return definition_locator
@@ -597,7 +597,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
old_definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
old_definition['edit_info']['previous_version'] = definition_locator.definition_id
new_id = self.definitions.insert(old_definition)
- return DescriptionLocator(new_id), True
+ return DefinitionLocator(new_id), True
else:
return definition_locator, False
@@ -1252,7 +1252,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
elif '_id' not in definition:
return None
else:
- return DescriptionLocator(definition['_id'])
+ return DefinitionLocator(definition['_id'])
def internal_clean_children(self, course_locator):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
index 330a2b7320..6ec9acfcd5 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py
@@ -4,7 +4,7 @@ Tests for xmodule.modulestore.locator.
from unittest import TestCase
from bson.objectid import ObjectId
-from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator, DescriptionLocator
+from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator, DefinitionLocator
from xmodule.modulestore.parsers import BRANCH_PREFIX, BLOCK_PREFIX, VERSION_PREFIX, URL_VERSION_PREFIX
from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError
@@ -254,11 +254,11 @@ class LocatorTest(TestCase):
self.assertEqual('BlockUsageLocator("mit.eecs.6002x/branch/published/block/HW3")', repr(testobj))
def test_description_locator_url(self):
- definition_locator = DescriptionLocator("chapter12345_2")
+ definition_locator = DefinitionLocator("chapter12345_2")
self.assertEqual('edx://' + URL_VERSION_PREFIX + 'chapter12345_2', definition_locator.url())
def test_description_locator_version(self):
- definition_locator = DescriptionLocator("chapter12345_2")
+ definition_locator = DefinitionLocator("chapter12345_2")
self.assertEqual("chapter12345_2", definition_locator.version())
# ------------------------------------------------------------------
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
index be5c61a143..2373c409de 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py
@@ -13,7 +13,7 @@ from xblock.fields import Scope
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError, VersionConflictError, \
DuplicateItemError
-from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator, VersionTree, DescriptionLocator
+from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator, VersionTree, DefinitionLocator
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import XModuleMixin
from pytz import UTC
@@ -562,7 +562,7 @@ class TestItemCrud(SplitModuleTest):
new_module = modulestore().create_item(
locator, category, 'user123',
fields={'display_name': 'new chapter'},
- definition_locator=DescriptionLocator("chapter12345_2")
+ definition_locator=DefinitionLocator("chapter12345_2")
)
# check that course version changed and course's previous is the other one
self.assertNotEqual(new_module.location.version_guid, premod_course.location.version_guid)
@@ -588,7 +588,7 @@ class TestItemCrud(SplitModuleTest):
another_module = modulestore().create_item(
locator, category, 'anotheruser',
fields={'display_name': 'problem 2', 'data': another_payload},
- definition_locator=DescriptionLocator("problem12345_3_1"),
+ definition_locator=DefinitionLocator("problem12345_3_1"),
)
# check that course version changed and course's previous is the other one
parent = modulestore().get_item(locator)
@@ -789,7 +789,7 @@ class TestItemCrud(SplitModuleTest):
modulestore().create_item(
locator, category, 'test_update_manifold',
fields={'display_name': 'problem 2', 'data': another_payload},
- definition_locator=DescriptionLocator("problem12345_3_1"),
+ definition_locator=DefinitionLocator("problem12345_3_1"),
)
# pylint: disable=W0212
modulestore()._clear_cache()
From 19fb2017be6eddf0887b212e81b5459eee6b8a55 Mon Sep 17 00:00:00 2001
From: Vik Paruchuri
Date: Fri, 27 Sep 2013 15:05:05 -0400
Subject: [PATCH 28/54] Streamline and test uploading ... remove requirement
that uploads be images, can now upload any file.
---
.../xmodule/combined_open_ended_module.py | 2 +-
.../js/src/combinedopenended/display.coffee | 17 +-
.../open_ended_image_submission.py | 270 ------------------
.../open_ended_module.py | 5 +-
.../openendedchild.py | 228 +++++++++------
.../self_assessment_module.py | 5 +-
.../xmodule/tests/test_combined_open_ended.py | 83 +++++-
.../xmodule/tests/test_self_assessment.py | 2 +-
.../xmodule/tests/test_util_open_ended.py | 58 +++-
.../SampleQuestionImageUpload.xml | 24 ++
.../test/data/open_ended/course/2012_Fall.xml | 1 +
11 files changed, 315 insertions(+), 380 deletions(-)
delete mode 100644 common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py
create mode 100644 common/test/data/open_ended/combinedopenended/SampleQuestionImageUpload.xml
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/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
index 7103d2e841..030d93e9b5 100644
--- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
@@ -372,7 +372,8 @@ class @CombinedOpenEnded
answer_area_div = @$(@answer_area_div_sel)
answer_area_div.html(response.student_response)
else
- @can_upload_files = pre_can_upload_files
+ @submit_button.show()
+ @submit_button.attr('disabled', false)
@gentle_alert response.error
confirm_save_answer: (event) =>
@@ -385,23 +386,27 @@ class @CombinedOpenEnded
event.preventDefault()
@answer_area.attr("disabled", true)
max_filesize = 2*1000*1000 #2MB
- pre_can_upload_files = @can_upload_files
if @child_state == 'initial'
files = ""
+ valid_files_attached = false
if @can_upload_files == true
files = @$(@file_upload_box_sel)[0].files[0]
if files != undefined
+ valid_files_attached = true
if files.size > max_filesize
- @can_upload_files = false
files = ""
- else
- @can_upload_files = false
+ # Don't submit the file in the case of it being too large, deal with the error locally.
+ @submit_button.show()
+ @submit_button.attr('disabled', false)
+ @gentle_alert "You are trying to upload a file that is too large for our system. Please choose a file under 2MB or paste a link to it into the answer box."
+ return
fd = new FormData()
fd.append('student_answer', @answer_area.val())
fd.append('student_file', files)
- fd.append('can_upload_files', @can_upload_files)
+ fd.append('valid_files_attached', valid_files_attached)
+ that=this
settings =
type: "POST"
data: fd
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py
deleted file mode 100644
index ea5c3b3527..0000000000
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py
+++ /dev/null
@@ -1,270 +0,0 @@
-"""
-This contains functions and classes used to evaluate if images are acceptable (do not show improper content, etc), and
-to send them to S3.
-"""
-
-try:
- from PIL import Image
-
- ENABLE_PIL = True
-except:
- ENABLE_PIL = False
-
-from urlparse import urlparse
-import requests
-from boto.s3.connection import S3Connection
-from boto.s3.key import Key
-import logging
-
-log = logging.getLogger(__name__)
-
-#Domains where any image linked to can be trusted to have acceptable content.
-TRUSTED_IMAGE_DOMAINS = [
- 'wikipedia',
- 'edxuploads.s3.amazonaws.com',
- 'wikimedia',
-]
-
-#Suffixes that are allowed in image urls
-ALLOWABLE_IMAGE_SUFFIXES = [
- 'jpg',
- 'png',
- 'gif',
- 'jpeg'
-]
-
-#Maximum allowed dimensions (x and y) for an uploaded image
-MAX_ALLOWED_IMAGE_DIM = 2000
-
-#Dimensions to which image is resized before it is evaluated for color count, etc
-MAX_IMAGE_DIM = 150
-
-#Maximum number of colors that should be counted in ImageProperties
-MAX_COLORS_TO_COUNT = 16
-
-#Maximum number of colors allowed in an uploaded image
-MAX_COLORS = 400
-
-
-class ImageProperties(object):
- """
- Class to check properties of an image and to validate if they are allowed.
- """
-
- def __init__(self, image_data):
- """
- Initializes class variables
- @param image: Image object (from PIL)
- @return: None
- """
- self.image = Image.open(image_data)
- image_size = self.image.size
- self.image_too_large = False
- if image_size[0] > MAX_ALLOWED_IMAGE_DIM or image_size[1] > MAX_ALLOWED_IMAGE_DIM:
- self.image_too_large = True
- if image_size[0] > MAX_IMAGE_DIM or image_size[1] > MAX_IMAGE_DIM:
- self.image = self.image.resize((MAX_IMAGE_DIM, MAX_IMAGE_DIM))
- self.image_size = self.image.size
-
- def count_colors(self):
- """
- Counts the number of colors in an image, and matches them to the max allowed
- @return: boolean true if color count is acceptable, false otherwise
- """
- colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
- if colors is None:
- color_count = MAX_COLORS_TO_COUNT
- else:
- color_count = len(colors)
-
- too_many_colors = (color_count <= MAX_COLORS)
- return too_many_colors
-
- def check_if_rgb_is_skin(self, rgb):
- """
- Checks if a given input rgb tuple/list is a skin tone
- @param rgb: RGB tuple
- @return: Boolean true false
- """
- colors_okay = False
- try:
- r = rgb[0]
- g = rgb[1]
- b = rgb[2]
- check_r = (r > 60)
- check_g = (r * 0.4) < g < (r * 0.85)
- check_b = (r * 0.2) < b < (r * 0.7)
- colors_okay = check_r and check_b and check_g
- except:
- pass
-
- return colors_okay
-
- def get_skin_ratio(self):
- """
- Gets the ratio of skin tone colors in an image
- @return: True if the ratio is low enough to be acceptable, false otherwise
- """
- colors = self.image.getcolors(MAX_COLORS_TO_COUNT)
- is_okay = True
- if colors is not None:
- skin = sum([count for count, rgb in colors if self.check_if_rgb_is_skin(rgb)])
- total_colored_pixels = sum([count for count, rgb in colors])
- bad_color_val = float(skin) / total_colored_pixels
- if bad_color_val > .4:
- is_okay = False
-
- return is_okay
-
- def run_tests(self):
- """
- Does all available checks on an image to ensure that it is okay (size, skin ratio, colors)
- @return: Boolean indicating whether or not image passes all checks
- """
- image_is_okay = False
- try:
- #image_is_okay = self.count_colors() and self.get_skin_ratio() and not self.image_too_large
- image_is_okay = not self.image_too_large
- except:
- log.exception("Could not run image tests.")
-
- if not ENABLE_PIL:
- image_is_okay = True
-
- #log.debug("Image OK: {0}".format(image_is_okay))
-
- return image_is_okay
-
-
-class URLProperties(object):
- """
- Checks to see if a URL points to acceptable content. Added to check if students are submitting reasonable
- links to the peer grading image functionality of the external grading service.
- """
-
- def __init__(self, url_string):
- self.url_string = url_string
-
- def check_if_parses(self):
- """
- Check to see if a URL parses properly
- @return: success (True if parses, false if not)
- """
- success = False
- try:
- self.parsed_url = urlparse(self.url_string)
- success = True
- except:
- pass
-
- return success
-
- def check_suffix(self):
- """
- Checks the suffix of a url to make sure that it is allowed
- @return: True if suffix is okay, false if not
- """
- good_suffix = False
- for suffix in ALLOWABLE_IMAGE_SUFFIXES:
- if self.url_string.endswith(suffix):
- good_suffix = True
- break
- return good_suffix
-
- def run_tests(self):
- """
- Runs all available url tests
- @return: True if URL passes tests, false if not.
- """
- url_is_okay = self.check_suffix() and self.check_if_parses()
- return url_is_okay
-
- def check_domain(self):
- """
- Checks to see if url is from a trusted domain
- """
- success = False
- for domain in TRUSTED_IMAGE_DOMAINS:
- if domain in self.url_string:
- success = True
- return success
- return success
-
-
-def run_url_tests(url_string):
- """
- Creates a URLProperties object and runs all tests
- @param url_string: A URL in string format
- @return: Boolean indicating whether or not URL has passed all tests
- """
- url_properties = URLProperties(url_string)
- return url_properties.run_tests()
-
-
-def run_image_tests(image):
- """
- Runs all available image tests
- @param image: PIL Image object
- @return: Boolean indicating whether or not all tests have been passed
- """
- success = False
- try:
- image_properties = ImageProperties(image)
- success = image_properties.run_tests()
- except:
- log.exception("Cannot run image tests in combined open ended xmodule. May be an issue with a particular image,"
- "or an issue with the deployment configuration of PIL/Pillow")
- return success
-
-
-def upload_to_s3(file_to_upload, keyname, s3_interface):
- '''
- Upload file to S3 using provided keyname.
-
- Returns:
- public_url: URL to access uploaded file
- '''
-
- #This commented out code is kept here in case we change the uploading method and require images to be
- #converted before they are sent to S3.
- #TODO: determine if commented code is needed and remove
- #im = Image.open(file_to_upload)
- #out_im = cStringIO.StringIO()
- #im.save(out_im, 'PNG')
-
- try:
- conn = S3Connection(s3_interface['access_key'], s3_interface['secret_access_key'])
- bucketname = str(s3_interface['storage_bucket_name'])
- bucket = conn.create_bucket(bucketname.lower())
-
- k = Key(bucket)
- k.key = keyname
- k.set_metadata('filename', file_to_upload.name)
- k.set_contents_from_file(file_to_upload)
-
- #This commented out code is kept here in case we change the uploading method and require images to be
- #converted before they are sent to S3.
- #k.set_contents_from_string(out_im.getvalue())
- #k.set_metadata("Content-Type", 'images/png')
-
- k.set_acl("public-read")
- public_url = k.generate_url(60 * 60 * 24 * 365) # URL timeout in seconds.
-
- return True, public_url
- except:
- #This is a dev_facing_error
- error_message = "Could not connect to S3 to upload peer grading image. Trying to utilize bucket: {0}".format(
- bucketname.lower())
- log.error(error_message)
- return False, error_message
-
-
-def get_from_s3(s3_public_url):
- """
- Gets an image from a given S3 url
- @param s3_public_url: The URL where an image is located
- @return: The image data
- """
- r = requests.get(s3_public_url, timeout=2)
- data = r.text
- return data
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
index d7fe8c0d26..2d973aa9ed 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py
@@ -651,15 +651,12 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
return self.out_of_sync_error(data)
# 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'] = OpenEndedModule.sanitize_html(data['student_answer'])
self.new_history_entry(data['student_answer'])
self.send_to_grader(data['student_answer'], system)
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/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
index d7555ce77e..67a058f478 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py
@@ -2,8 +2,6 @@ import json
import logging
from lxml.html.clean import Cleaner, autolink_html
import re
-
-import open_ended_image_submission
from xmodule.progress import Progress
import capa.xqueue_interface as xqueue_interface
from capa.util import *
@@ -12,6 +10,9 @@ import controller_query_service
from datetime import datetime
from pytz import UTC
+import requests
+from boto.s3.connection import S3Connection
+from boto.s3.key import Key
log = logging.getLogger("mitx.courseware")
@@ -24,6 +25,50 @@ MAX_ATTEMPTS = 1
# Overriden by max_score specified in xml.
MAX_SCORE = 1
+FILE_NOT_FOUND_IN_RESPONSE_MESSAGE = "We could not find a file in your submission. Please try choosing a file or pasting a link to your file into the answer box."
+ERROR_SAVING_FILE_MESSAGE = "We are having trouble saving your file. Please try another file or paste a link to your file into the answer box."
+
+def upload_to_s3(file_to_upload, keyname, s3_interface):
+ '''
+ Upload file to S3 using provided keyname.
+
+ Returns:
+ public_url: URL to access uploaded file
+ '''
+
+ conn = S3Connection(s3_interface['access_key'], s3_interface['secret_access_key'])
+ bucketname = str(s3_interface['storage_bucket_name'])
+ bucket = conn.create_bucket(bucketname.lower())
+
+ k = Key(bucket)
+ k.key = keyname
+ k.set_metadata('filename', file_to_upload.name)
+ k.set_contents_from_file(file_to_upload)
+
+ k.set_acl("public-read")
+ public_url = k.generate_url(60 * 60 * 24 * 365) # URL timeout in seconds.
+
+ return public_url
+
+class WhiteListCleaner(Cleaner):
+ """
+ By default, lxml cleaner strips out all links that are not in a defined whitelist.
+ We want to allow all links, and rely on the peer grading flagging mechanic to catch
+ the "bad" ones. So, don't define a whitelist at all.
+ """
+ def allow_embedded_url(self, el, url):
+ """
+ Override the Cleaner allow_embedded_url method to remove the whitelist url requirement.
+ Ensure that any tags not in the whitelist are stripped beforehand.
+ """
+
+ # Tell cleaner to strip any element with a tag that isn't whitelisted.
+ if self.whitelist_tags is not None and el.tag not in self.whitelist_tags:
+ return False
+
+ # Tell cleaner to allow all urls.
+ return True
+
class OpenEndedChild(object):
"""
@@ -70,6 +115,7 @@ class OpenEndedChild(object):
except:
log.error(
"Could not load instance state for open ended. Setting it to nothing.: {0}".format(instance_state))
+ instance_state = {}
else:
instance_state = {}
@@ -176,11 +222,22 @@ class OpenEndedChild(object):
@staticmethod
def sanitize_html(answer):
+ """
+ Take a student response and sanitize the HTML to prevent malicious script injection
+ or other unwanted content.
+ answer - any string
+ return - a cleaned version of the string
+ """
try:
answer = autolink_html(answer)
- cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True,
- host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS,
- whitelist_tags=set(['embed', 'iframe', 'a', 'img', 'br']))
+ cleaner = WhiteListCleaner(
+ style=True,
+ links=True,
+ add_nofollow=False,
+ page_structure=True,
+ safe_attrs_only=True,
+ whitelist_tags=('embed', 'iframe', 'a', 'img', 'br',)
+ )
clean_html = cleaner.clean_html(answer)
clean_html = re.sub(r'$', '', re.sub(r'^
', '', 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/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 = "
+
+
+
+ Writing Applications
+
+
+
+
+ Language Conventions
+
+
+
+
+
+
+