From 3ff31892609bcb15f88f35c46a86d8872c173f6a Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 15 Jan 2013 15:39:05 -0500 Subject: [PATCH 001/214] Finished tests for access.py (as far as able) --- .../courseware/tests/test_access.py | 126 ++++++++++++++++++ .../courseware/tests/test_progress.py | 67 ++++++++++ 2 files changed, 193 insertions(+) create mode 100644 lms/djangoapps/courseware/tests/test_access.py create mode 100644 lms/djangoapps/courseware/tests/test_progress.py diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py new file mode 100644 index 0000000000..ebdad9e6a8 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -0,0 +1,126 @@ +import unittest +import logging +import time +from mock import MagicMock, patch + +from django.conf import settings +from django.test import TestCase + +from xmodule.course_module import CourseDescriptor +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore import Location +from xmodule.timeparse import parse_time +from xmodule.x_module import XModule, XModuleDescriptor +import courseware.access as access + +class Stub: + def __init__(self): + pass + +class AccessTestCase(TestCase): + def setUp(self): + pass + + def test__has_global_staff_access(self): + # Only 2 branches? + mock_user = MagicMock() + mock_user.is_staff = True + self.assertTrue(access._has_global_staff_access(mock_user)) + mock_user_2 = MagicMock() + mock_user.is_staff = False + self.assertFalse(access._has_global_staff_access(mock_user)) + + def test__has_access_to_location(self): + mock_user = MagicMock() + mock_user.is_authenticated.return_value = False + self.assertFalse(access._has_access_to_location(mock_user, "dummy", + "dummy")) + mock_user_2 = MagicMock() + mock_user_2.groups.all.return_value = ['instructor_toy'] + location = MagicMock(spec=Location) + location.course = 'toy' + self.assertTrue(access._has_access_to_location(mock_user_2, location, + "instructor")) + mock_user_3 = MagicMock() + mock_user_3.is_staff = False + self.assertFalse(access._has_access_to_location(mock_user_3, 'dummy', + 'dummy')) + + def test__dispatch(self): + self.assertRaises(ValueError, access._dispatch,{}, 'action', 'dummy', + 'dummy') + + def test__has_access_string(self): + mock_user = MagicMock() + mock_user.is_staff = True + self.assertTrue(access._has_access_string(mock_user, 'global', 'staff')) + self.assertFalse(access._has_access_string(mock_user, 'dummy', 'staff')) + + def test__has_access_descriptor(self): + mock_descriptor = MagicMock() + mock_descriptor.start = 0 + # test has dependency on time.gmtime() > 0 + self.assertTrue(access._has_access_descriptor("dummy", mock_descriptor, + 'load')) + mock_descriptor_2 = MagicMock() + mock_descriptor_2.start = None + self.assertTrue(access._has_access_descriptor("dummy", mock_descriptor_2, + 'load')) + + def test__has_access_error_desc(self): + mock_user = None + mock_descriptor = MagicMock() + mock_descriptor.location = None + # Just want to make sure function goes through path. + self.assertFalse(access._has_access_error_desc(mock_user, mock_descriptor, + 'load')) + + def test__get_access_group_name_course_desc(self): + self.assertEquals(access._get_access_group_name_course_desc('dummy', + 'notstaff'), + []) + # Problem: Can't use a Mock for location because needs to be a valid + # input to Location + # Getting "IndentationError: expected an indented block" +## tag, org, course, category, name = [MagicMock()]*5 +## #mock_course.location = ['tag', 'org', 'course', 'category', 'name'] +## L = Location([tag, org, course, category, name]) +## print L.course_id() +## assert False + #mock_course.location.course = 'toy' + #access._get_access_group_name_course_desc(mock_course, 'staff') + + def test__has_access_course_desc(self): + # This is more of a test for see_exists + mock_course = MagicMock() + mock_course.metadata.get = 'is_public' + self.assertTrue(access._has_access_course_desc('dummy', mock_course, + 'see_exists')) + mock_course_2 = MagicMock() + mock_course_2.metadata.get = 'private' + # Is there a way to see all the functions that have been called on a mock? + # Basically, I want to see if _has_staff_access_to_descriptor is called on + # the mock user and course + # This actually doesn't seem possible, according to the API + # None user can see course even if not 'is_public'? + self.assertTrue(access._has_access_course_desc(None, mock_course_2, + 'see_exists')) + def test_get_access_group_name(self): + # Need to create an instance of CourseDescriptor + # Is it necessary to test? basically "testing" python + self.assertRaises(TypeError, access.get_access_group_name, + 'notCourseDescriptor', 'dummy_action') + + def test_has_access(self): + magic = MagicMock() + error = ErrorDescriptor(magic) + mock_user = MagicMock() + self.assertFalse(access.has_access(None, error, 'load')) + self.assertFalse(access.has_access(mock_user, 'dummy', 'staff')) + self.assertRaises(TypeError, access.has_access,'dummyuser', {}, 'dummy') + +# How do decorators work? I think that is the correct +## def test_patches(self): +## user = Stub() +## @patch.object(Stub, "is_staff", True) +## self.assertTrue(access._has_global_staff_access(mock_user)) diff --git a/lms/djangoapps/courseware/tests/test_progress.py b/lms/djangoapps/courseware/tests/test_progress.py new file mode 100644 index 0000000000..480d594863 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_progress.py @@ -0,0 +1,67 @@ +from django.test import TestCase +from courseware import progress +from mock import MagicMock + + + +class ProgessTests(TestCase): + def setUp(self): + + self.d = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 4, + 'questions_incorrect': 0, + 'questions_total': 0}) + + self.c = progress.completion() + self.c2= progress.completion() + self.c2.dict = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 2, + 'questions_incorrect': 1, + 'questions_total': 0}) + + self.cplusc2 = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 2, + 'questions_incorrect': 1, + 'questions_total': 0}) + + + + self.oth = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 4, + 'questions_incorrect': 0, + 'questions_total': 7}) + + self.x = MagicMock() + self.x.dict = self.oth + + self.d_oth = {'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 4, + 'questions_incorrect': 0, + 'questions_total': 7} + def test_getitem(self): + self.assertEqual(self.c.__getitem__('duration_watched'), 0) + + def test_setitem(self): + self.c.__setitem__('questions_correct', 4) + self.assertEqual(str(self.c),str(self.d)) + + # def test_add(self): + # self.assertEqual(self.c.__add__(self.c2), self.cplusc2) + + def test_contains(self): + + return self.c.__contains__('meow') + #self.assertEqual(self.c.__contains__('done'), True) + + def test_repr(self): + self.assertEqual(self.c.__repr__(), str(progress.completion())) From 1565cd5bd364caa2d478ae3bd27095462e46a5ec Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Fri, 18 Jan 2013 14:44:17 -0500 Subject: [PATCH 002/214] Test cases for lms/djangoapps/courseware/module_render in test_module_render --- .../courseware/tests/test_module_render.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 lms/djangoapps/courseware/tests/test_module_render.py diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py new file mode 100644 index 0000000000..8340d1fda2 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -0,0 +1,164 @@ +from unittest import TestCase +import logging +from mock import MagicMock, patch +import json +import factory + +from django.http import Http404, HttpResponse, HttpRequest +from django.conf import settings +from django.contrib.auth.models import User +from django.test.client import Client +from django.conf import settings + +from courseware.models import StudentModule +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore import Location +import courseware.module_render as render + +class Stub: + def __init__(self): + pass + +class ModuleRenderTestCase(TestCase): + def setUp(self): + self.location = ['tag', 'org', 'course', 'category', 'name'] + + def test_toc_for_course(self): + mock_course = MagicMock() + mock_course.id = 'dummy' + mock_course.location = Location(self.location) + mock_course.get_children.return_value = [] + mock_user = MagicMock() + mock_user.is_authenticated.return_value = False + self.assertIsNone(render.toc_for_course(mock_user,'dummy', + mock_course, 'dummy', 'dummy')) + + def test_get_module(self): + self.assertIsNone(render.get_module('dummyuser',None,\ + 'invalid location',None,None)) + + + def test__get_module(self): + mock_user = MagicMock() + mock_user.is_authenticated.return_value = True + location = ['tag', 'org', 'course', 'category', 'name'] + #render._get_module(mock_user, + + def test_get_instance_module(self): + mock_user = MagicMock() + mock_user.is_authenticated.return_value = False + self.assertIsNone(render.get_instance_module('dummy', mock_user, 'dummy', + 'dummy')) + mock_user_2 = MagicMock() + mock_user_2.is_authenticated.return_value = True + mock_module = MagicMock() + mock_module.descriptor.stores_state = False + self.assertIsNone(render.get_instance_module('dummy', mock_user_2, + mock_module,'dummy')) + + def test_get_shared_instance_module(self): +## class MockUserFactory(factory.Factory): +## FACTORY_FOR = MagicMock +## is_authenticated.return_value = + mock_user = MagicMock(User) + mock_user.is_authenticated.return_value = False + self.assertIsNone(render.get_shared_instance_module('dummy', mock_user, 'dummy', + 'dummy')) + mock_user_2 = MagicMock(User) + mock_user_2.is_authenticated.return_value = True + mock_module = MagicMock() + mock_module.shared_state_key = 'key' + self.assertIsInstance(render.get_shared_instance_module('dummy', mock_user, + mock_module, 'dummy'), StudentModule) + + + + def test_xqueue_callback(self): + mock_request = MagicMock() + mock_request.POST.copy.return_value = {} + # 339 + self.assertRaises(Http404, render.xqueue_callback,mock_request, + 'dummy', 'dummy', 'dummy', 'dummy') + mock_request_2 = MagicMock() + xpackage = {'xqueue_header': json.dumps({}), + 'xqueue_body' : 'Message from grader'} + mock_request_2.POST.copy.return_value = xpackage + # 342 + self.assertRaises(Http404, render.xqueue_callback,mock_request_2, + 'dummy', 'dummy', 'dummy', 'dummy') + mock_request_3 = MagicMock() + xpackage_2 = {'xqueue_header': json.dumps({'lms_key':'secretkey'}), + 'xqueue_body' : 'Message from grader'} + mock_request_3.POST.copy.return_value = xpackage_2 +## self.assertRaises(Http404, render.xqueue_callback, mock_request_3, +## 'dummy', 0, 'dummy', 'dummy') + # continue later + + def test_modx_dispatch(self): + self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy', + 'invalid Location', 'dummy') + mock_request = MagicMock() + mock_request.FILES.keys.return_value = ['file_id'] + mock_request.FILES.getlist.return_value = ['file']*(settings.MAX_FILEUPLOADS_PER_INPUT + 1) + self.assertEquals(render.modx_dispatch(mock_request, 'dummy', self.location, + 'dummy').content, + json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' %\ + settings.MAX_FILEUPLOADS_PER_INPUT})) + mock_request_2 = MagicMock() + mock_request_2.FILES.keys.return_value = ['file_id'] + inputfile = Stub() + inputfile.size = 1 + settings.STUDENT_FILEUPLOAD_MAX_SIZE + inputfile.name = 'name' + filelist = [inputfile] + mock_request_2.FILES.getlist.return_value = filelist + self.assertEquals(render.modx_dispatch(mock_request_2, 'dummy', self.location, + 'dummy').content, + json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %\ + (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE/(1000**2))})) + mock_request_3 = MagicMock() + mock_request_3.POST.copy.return_value = {} + inputfile_2 = Stub() + inputfile_2.size = 1 + inputfile_2.name = 'name' + self.assertRaises(ItemNotFoundError, render.modx_dispatch, + mock_request_3, 'dummy', self.location, 'toy') + # Deadend + + def test_preview_chemcalc(self): + mock_request = MagicMock() + mock_request.method = 'notGET' + self.assertRaises(Http404, render.preview_chemcalc, mock_request) + mock_request_2 = MagicMock() + mock_request_2.method = 'GET' + mock_request_2.GET.get.return_value = None + self.assertEquals(render.preview_chemcalc(mock_request_2).content, + json.dumps({'preview':'', + 'error':'No formula specified.'})) + + mock_request_3 = MagicMock() + mock_request_3.method = 'GET' + # Test fails because chemcalc.render_to_html always parses strings? + mock_request_3.GET.get.return_value = unicode('\x12400', errors="strict") +## self.assertEquals(render.preview_chemcalc(mock_request_3).content, +## json.dumps({'preview':'', +## 'error':"Couldn't parse formula: formula"})) +## + mock_request_3 = MagicMock() + mock_request_3.method = 'GET' + mock_request_3.GET.get.return_value = Stub() + self.assertEquals(render.preview_chemcalc(mock_request_3).content, + json.dumps({'preview':'', + 'error':"Error while rendering preview"})) + + + def test_get_score_bucket(self): + self.assertEquals(render.get_score_bucket(0, 10), 'incorrect') + self.assertEquals(render.get_score_bucket(1, 10), 'partial') + self.assertEquals(render.get_score_bucket(10, 10), 'correct') + # get_score_bucket calls error cases 'incorrect' + self.assertEquals(render.get_score_bucket(11, 10), 'incorrect') + self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect') + + +class MagicMockFactory(factory.Factory): + FACTORY_FOR = MagicMock From 6e773909e48b84475d239c76ee8d9431eb80d71b Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 22 Jan 2013 10:36:05 -0500 Subject: [PATCH 003/214] added test_views --- .../courseware/tests/test_module_render.py | 4 +- lms/djangoapps/courseware/tests/test_views.py | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/test_views.py diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 8340d1fda2..d4748ebb04 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -70,8 +70,6 @@ class ModuleRenderTestCase(TestCase): mock_module.shared_state_key = 'key' self.assertIsInstance(render.get_shared_instance_module('dummy', mock_user, mock_module, 'dummy'), StudentModule) - - def test_xqueue_callback(self): mock_request = MagicMock() @@ -162,3 +160,5 @@ class ModuleRenderTestCase(TestCase): class MagicMockFactory(factory.Factory): FACTORY_FOR = MagicMock + v = factory.LazyAttribute(i for i in [True, False, False]) + diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py new file mode 100644 index 0000000000..1acf175bbd --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -0,0 +1,37 @@ +from unittest import TestCase +import logging +from mock import MagicMock, patch + +from django.conf import settings +from django.test.utils import override_settings + +import courseware.views as views + +class Stub(): + pass + +class ViewsTestCase(TestCase): + def setUp(self): + pass + + + def test_user_groups(self): + mock_user = MagicMock() + mock_user.is_authenticated.return_value = False + self.assertEquals(views.user_groups(mock_user),[]) + + @override_settings(DEBUG = True) + def test_user_groups_debug(self): + mock_user = MagicMock() + mock_user.is_authenticated.return_value = True + pass + #views.user_groups(mock_user) + #Keep going later + + def test_get_current_child(self): + self.assertIsNone(views.get_current_child(Stub())) + mock_xmodule = MagicMock() + mock_xmodule.position = -1 + mock_xmodule.get_display_items.return_value = ['one','two'] + print views.user_groups(mock_xmodule) + self.assertEquals(views.user_groups(mock_xmodule), 'one') From 35650233891b668d7c74570d59fd030dd49b0485 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 22 Jan 2013 14:01:01 -0500 Subject: [PATCH 004/214] created test_courses.py --- .../courseware/tests/test_courses.py | 21 +++++++++ lms/djangoapps/courseware/tests/test_views.py | 47 +++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/test_courses.py diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py new file mode 100644 index 0000000000..7e1456efec --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -0,0 +1,21 @@ +from mock import MagicMock, patch +import datetime + +from django.test import TestCase +from django.contrib.auth.models import User + +from student.models import CourseEnrollment +import courseware.courses as courses + +class CoursesTestCase(TestCase): + def setUp(self): + self.user = User.objects.create(username='dummy', password='123456', + email='test@mit.edu') + self.date = datetime.datetime(2013,1,22) + self.course_id = 'edx/toy/Fall_2012' + self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, + course_id = self.course_id, + created = self.date)[0] + + def test_get_course_by_id(self): + courses.get_course_by_id(self.course_id) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 1acf175bbd..ba7393f939 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1,24 +1,37 @@ -from unittest import TestCase import logging from mock import MagicMock, patch +import datetime +from django.test import TestCase +from django.http import Http404 from django.conf import settings from django.test.utils import override_settings +from django.contrib.auth.models import User +from student.models import CourseEnrollment import courseware.views as views +from xmodule.modulestore.django import modulestore + class Stub(): pass class ViewsTestCase(TestCase): def setUp(self): - pass - + self.user = User.objects.create(username='dummy', password='123456', + email='test@mit.edu') + self.date = datetime.datetime(2013,1,22) + self.course_id = 'edx/toy/Fall_2012' + self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, + course_id = self.course_id, + created = self.date)[0] def test_user_groups(self): + # depreciated function? mock_user = MagicMock() mock_user.is_authenticated.return_value = False self.assertEquals(views.user_groups(mock_user),[]) + @override_settings(DEBUG = True) def test_user_groups_debug(self): @@ -33,5 +46,29 @@ class ViewsTestCase(TestCase): mock_xmodule = MagicMock() mock_xmodule.position = -1 mock_xmodule.get_display_items.return_value = ['one','two'] - print views.user_groups(mock_xmodule) - self.assertEquals(views.user_groups(mock_xmodule), 'one') + self.assertEquals(views.get_current_child(mock_xmodule), 'one') + mock_xmodule_2 = MagicMock() + mock_xmodule_2.position = 3 + mock_xmodule_2.get_display_items.return_value = [] + self.assertIsNone(views.get_current_child(mock_xmodule_2)) + + def test_redirect_to_course_position(self): + mock_module = MagicMock() + mock_module.descriptor.id = 'Underwater Basketweaving' + mock_module.position = 3 + mock_module.get_display_items.return_value = [] + self.assertRaises(Http404, views.redirect_to_course_position, + mock_module, True) + + def test_index(self): + print modulestore() + assert False + + def test_registered_for_course(self): + self.assertFalse(views.registered_for_course('Basketweaving', None)) + mock_user = MagicMock() + mock_user.is_authenticated.return_value = False + self.assertFalse(views.registered_for_course('dummy', mock_user)) + mock_course = MagicMock() + mock_course.id = self.course_id + self.assertTrue(views.registered_for_course(mock_course, self.user)) From 7884e2c6906b99c181ca70bd8d8c8e7b9d34c146 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 22 Jan 2013 15:08:44 -0500 Subject: [PATCH 005/214] more tests --- lms/djangoapps/courseware/tests/test_courses.py | 2 ++ lms/djangoapps/courseware/tests/test_views.py | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 7e1456efec..7cb2f8b54b 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -6,6 +6,7 @@ from django.contrib.auth.models import User from student.models import CourseEnrollment import courseware.courses as courses +from xmodule.modulestore.xml import XMLModuleStore class CoursesTestCase(TestCase): def setUp(self): @@ -16,6 +17,7 @@ class CoursesTestCase(TestCase): self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, course_id = self.course_id, created = self.date)[0] + self.xml_modulestore = XMLModuleStore() def test_get_course_by_id(self): courses.get_course_by_id(self.course_id) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index ba7393f939..9bca202761 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -61,8 +61,9 @@ class ViewsTestCase(TestCase): mock_module, True) def test_index(self): - print modulestore() - assert False + pass + #print modulestore() + #assert False def test_registered_for_course(self): self.assertFalse(views.registered_for_course('Basketweaving', None)) @@ -72,3 +73,7 @@ class ViewsTestCase(TestCase): mock_course = MagicMock() mock_course.id = self.course_id self.assertTrue(views.registered_for_course(mock_course, self.user)) + + def test_jump_to(self): + self.assertRaises(Http404, views.jump_to, 'foo', 'bar', ()) + From 26ee9a24ef72d035d7554e84b5cc161da8fb3f7d Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 22 Jan 2013 13:42:01 -0500 Subject: [PATCH 006/214] Add test for TOC rendering --- .../courseware/tests/test_module_render.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 lms/djangoapps/courseware/tests/test_module_render.py diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py new file mode 100644 index 0000000000..d5164d4903 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -0,0 +1,94 @@ +import unittest +import logging + +from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory +from override_settings import override_settings + +import factory +from django.contrib.auth.models import User + +from xmodule.modulestore.django import modulestore, _MODULESTORES +from courseware import module_render + +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + +class UserFactory(factory.Factory): + first_name = 'Test' + last_name = 'Robot' + is_staff = True + is_active = True + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestTOC(TestCase): + """Check the Table of Contents for a course""" + def setUp(self): + self._MODULESTORES = {} + + # Toy courses should be loaded + self.course_name = 'edX/toy/2012_Fall' + self.toy_course = modulestore().get_course(self.course_name) + + self.portal_user = UserFactory() + + def test_toc_toy_from_chapter(self): + chapter = 'Overview' + chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter) + factory = RequestFactory() + request = factory.get(chapter_url) + + expected = ([{'active': True, 'sections': + [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, + 'format': u'Lecture Sequence', 'due': '', 'active': False}, + {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'Overview', 'display_name': u'Overview'}, + {'active': False, 'sections': + [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) + + actual = module_render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None) + self.assertEqual(expected, actual) + + def test_toc_toy_from_section(self): + chapter = 'Overview' + chapter_url = '%s/%s/%s' % ('/courses', self.course_name, chapter) + section = 'Welcome' + factory = RequestFactory() + request = factory.get(chapter_url) + + expected = ([{'active': True, 'sections': + [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, + 'format': u'Lecture Sequence', 'due': '', 'active': False}, + {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, + 'format': '', 'due': '', 'active': True}, + {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'Overview', 'display_name': u'Overview'}, + {'active': False, 'sections': + [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) + + actual = module_render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section) + self.assertEqual(expected, actual) \ No newline at end of file From 3fd69bcae4f36405e4c5069867ac5c1d62d0b4cb Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Wed, 23 Jan 2013 10:41:12 -0500 Subject: [PATCH 007/214] more tests in test_views.py --- lms/djangoapps/courseware/tests/test_views.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 9bca202761..cf4c792ab7 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -11,7 +11,8 @@ from django.contrib.auth.models import User from student.models import CourseEnrollment import courseware.views as views from xmodule.modulestore.django import modulestore - +from xmodule.modulestore.exceptions import InvalidLocationError,\ + ItemNotFoundError, NoPathToItem class Stub(): pass @@ -25,6 +26,7 @@ class ViewsTestCase(TestCase): self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, course_id = self.course_id, created = self.date)[0] + self.location = ['tag', 'org', 'course', 'category', 'name'] def test_user_groups(self): # depreciated function? @@ -75,5 +77,8 @@ class ViewsTestCase(TestCase): self.assertTrue(views.registered_for_course(mock_course, self.user)) def test_jump_to(self): - self.assertRaises(Http404, views.jump_to, 'foo', 'bar', ()) + mock_request = MagicMock() + self.assertRaises(Http404, views.jump_to, mock_request, 'bar', ()) + self.assertRaises(ItemNotFoundError, views.jump_to, mock_request, 'dummy', + self.location) From 4d469fd9f7d0148eacb70823b8b44998de173142 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 24 Jan 2013 09:55:24 -0500 Subject: [PATCH 008/214] Add test for jump_to. --- lms/djangoapps/courseware/tests/test_views.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 lms/djangoapps/courseware/tests/test_views.py diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py new file mode 100644 index 0000000000..f3206004d6 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -0,0 +1,48 @@ +import unittest +import logging + +from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory +from override_settings import override_settings + +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore, _MODULESTORES +from courseware import views + +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestJumpTo(TestCase): + """Check the jumpto link for a course""" + def setUp(self): + self._MODULESTORES = {} + + # Toy courses should be loaded + self.course_name = 'edX/toy/2012_Fall' + + def test_jumpto_invalid_location(self): + location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None) + jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location) + expected = 'courses/edX/toy/2012_Fall/courseware/Overview/' + response = self.client.get(jumpto_url) + self.assertEqual(response.status_code, 404) + + def test_jumpto_from_chapter(self): + location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') + jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location) + expected = 'courses/edX/toy/2012_Fall/courseware/Overview/' + response = self.client.get(jumpto_url) + self.assertRedirects(response, expected, status_code=302, target_status_code=302) From 61e12f57e7f64721c969ed4ad03e1ee03474be63 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Thu, 24 Jan 2013 09:59:33 -0500 Subject: [PATCH 009/214] experimenting with creating a course --- .../courseware/tests/test_course_creation.py | 61 ++++++++++ .../courseware/tests/test_module_render.py | 13 +-- lms/djangoapps/courseware/tests/test_views.py | 106 ++++++++++++++++-- 3 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/test_course_creation.py diff --git a/lms/djangoapps/courseware/tests/test_course_creation.py b/lms/djangoapps/courseware/tests/test_course_creation.py new file mode 100644 index 0000000000..af3e3ee0e1 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_course_creation.py @@ -0,0 +1,61 @@ +import logging +from mock import MagicMock, patch +import factory +import copy +from path import path + +from django.test import TestCase +from django.test.client import Client +from django.core.urlresolvers import reverse +from django.conf import settings +from override_settings import override_settings + +from xmodule.modulestore.xml_importer import import_from_xml +import xmodule.modulestore.django + +TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) +TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') + +@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +class CreateTest(TestCase): + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + xmodule.modulestore.django.modulestore().collection.drop() + import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) + + def check_edit_item(self, test_course_name): + import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) + for descriptor in modulestore().get_items(Location(None, None, None, None, None)): + print "Checking ", descriptor.location.url() + print descriptor.__class__, descriptor.location + resp = self.client.get(reverse('edit_item'), {'id': descriptor.location.url()}) + self.assertEqual(resp.status_code, 200) + + def test_edit_item_toy(self): + self.check_edit_item('toy') + +## def setUp(self): +## self.client = Client() +## self.username = 'username' +## self.email = 'test@foo.com' +## self.pw = 'password' +## +## def create_account(self, username, email, pw): +## resp = self.client.post('/create_account', { +## 'username': username, +## 'email': email, +## 'password': pw, +## 'location': 'home', +## 'language': 'Franglish', +## 'name': 'Fred Weasley', +## 'terms_of_service': 'true', +## 'honor_code': 'true', +## }) +## return resp +## +## def registration(self, email): +## '''look up registration object by email''' +## return Registration.objects.get(user__email=email) +## +## def activate_user(self, email): +## activation_key = self.registration(email).activation_key diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 24f21241f0..ae725638e4 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1,26 +1,22 @@ -from unittest import TestCase import logging from mock import MagicMock, patch import json import factory +import unittest from django.http import Http404, HttpResponse, HttpRequest from django.conf import settings from django.contrib.auth.models import User from django.test.client import Client from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory from courseware.models import StudentModule from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location import courseware.module_render as render - -import unittest - -from django.test import TestCase -from django.test.client import RequestFactory from override_settings import override_settings - from xmodule.modulestore.django import modulestore, _MODULESTORES @@ -202,7 +198,8 @@ class TestTOC(TestCase): # Toy courses should be loaded self.course_name = 'edX/toy/2012_Fall' self.toy_course = modulestore().get_course(self.course_name) - + print type(self.toy_course) + assert False self.portal_user = UserFactory() def test_toc_toy_from_chapter(self): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index cf4c792ab7..3ff14657b8 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1,32 +1,84 @@ import logging from mock import MagicMock, patch import datetime +import factory from django.test import TestCase -from django.http import Http404 +from django.http import Http404, HttpResponse from django.conf import settings from django.test.utils import override_settings from django.contrib.auth.models import User +from django.test.client import RequestFactory from student.models import CourseEnrollment -import courseware.views as views from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import InvalidLocationError,\ - ItemNotFoundError, NoPathToItem + ItemNotFoundError, NoPathToItem +import courseware.views as views +from xmodule.modulestore import Location +#import mitx.common.djangoapps.mitxmako as mako class Stub(): pass +def render_to_response(template_name, dictionary, context_instance=None, + namespace='main', **kwargs): + # The original returns HttpResponse + print dir() + print template_name + print dictionary + return HttpResponse('foo') + +class UserFactory(factory.Factory): + first_name = 'Test' + last_name = 'Robot' + is_staff = True + is_active = True + +# This part is required for modulestore() to work properly +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + +class ModulestoreTest(TestCase): + def setUp(self): + self._MODULESTORES = {} + + # Toy courses should be loaded + self.course_name = 'edX/toy/2012_Fall' + self.toy_course = modulestore().get_course('edX/toy/2012_Fall') + + def test(self): + self.assertEquals(1,2) + class ViewsTestCase(TestCase): def setUp(self): self.user = User.objects.create(username='dummy', password='123456', email='test@mit.edu') self.date = datetime.datetime(2013,1,22) - self.course_id = 'edx/toy/Fall_2012' + self.course_id = 'edx/toy/2012_Fall' self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, course_id = self.course_id, created = self.date)[0] self.location = ['tag', 'org', 'course', 'category', 'name'] + self._MODULESTORES = {} + # This is a CourseDescriptor object + self.toy_course = modulestore().get_course('edX/toy/2012_Fall') + self.request_factory = RequestFactory() + # Many functions call upon render_to_response + # Problem is that we don't know what templates there are? + views.render_to_response = render_to_response + #m = mako.MakoMiddleware() def test_user_groups(self): # depreciated function? @@ -77,8 +129,46 @@ class ViewsTestCase(TestCase): self.assertTrue(views.registered_for_course(mock_course, self.user)) def test_jump_to(self): - mock_request = MagicMock() - self.assertRaises(Http404, views.jump_to, mock_request, 'bar', ()) + chapter = 'Overview' + chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) + request = self.request_factory.get(chapter_url) + self.assertRaisesRegexp(Http404, 'Invalid location', views.jump_to, + request, 'bar', ()) + self.assertRaisesRegexp(Http404, 'No data*', views.jump_to, request, + 'dummy', self.location) + print type(self.toy_course) + print dir(self.toy_course) + print self.toy_course.location + print self.toy_course.__dict__ + valid = ['i4x', 'edX', 'toy', 'chapter', 'overview'] + L = Location('i4x', 'edX', 'toy', 'chapter', 'Overview', None) - self.assertRaises(ItemNotFoundError, views.jump_to, mock_request, 'dummy', - self.location) + views.jump_to(request, 'dummy', L) + + def test_static_tab(self): + mock_request = MagicMock() + mock_request.user = self.user + # What is tab_slug? + #views.static_tab(mock_request, self.course_id, 'dummy') + + def test_university_profile(self): + chapter = 'Overview' + chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) + request = self.request_factory.get(chapter_url) + request.user = UserFactory() + self.assertRaisesRegexp(Http404, 'University Profile*', + views.university_profile, request, 'Harvard') + # Mocked out function render_to_response + self.assertIsInstance(views.university_profile(request, 'edX'), HttpResponse) + + def test_syllabus(self): + chapter = 'Overview' + chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) + request = self.request_factory.get(chapter_url) + request.user = UserFactory() + # course not found + views.syllabus(request, self.course_id) + + def test_render_notifications(self): + request = self.request_factory.get('foo') + views.render_notifications(request, self.course_id, 'dummy') From 9663973038a13e8cce1fd9eee0bd7ff212ca37bb Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Thu, 24 Jan 2013 14:59:46 -0500 Subject: [PATCH 010/214] more tests for courseware/views --- .../courseware/tests/test_module_render.py | 5 -- lms/djangoapps/courseware/tests/test_views.py | 70 +++++++++++++------ 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index ae725638e4..925601ba37 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -62,9 +62,6 @@ class ModuleRenderTestCase(TestCase): mock_module,'dummy')) def test_get_shared_instance_module(self): -## class MockUserFactory(factory.Factory): -## FACTORY_FOR = MagicMock -## is_authenticated.return_value = mock_user = MagicMock(User) mock_user.is_authenticated.return_value = False self.assertIsNone(render.get_shared_instance_module('dummy', mock_user, 'dummy', @@ -198,8 +195,6 @@ class TestTOC(TestCase): # Toy courses should be loaded self.course_name = 'edX/toy/2012_Fall' self.toy_course = modulestore().get_course(self.course_name) - print type(self.toy_course) - assert False self.portal_user = UserFactory() def test_toc_toy_from_chapter(self): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 57ef91c79e..fda591b2e6 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -1,9 +1,9 @@ -<<<<<<< HEAD import logging from mock import MagicMock, patch import datetime import factory import unittest +import os from django.test import TestCase from django.http import Http404, HttpResponse @@ -25,13 +25,13 @@ from xmodule.modulestore import Location class Stub(): pass -def render_to_response(template_name, dictionary, context_instance=None, - namespace='main', **kwargs): - # The original returns HttpResponse - print dir() - print template_name - print dictionary - return HttpResponse('foo') +##def render_to_response(template_name, dictionary, context_instance=None, +## namespace='main', **kwargs): +## # The original returns HttpResponse +## print dir() +## print template_name +## print dictionary +## return HttpResponse('foo') class UserFactory(factory.Factory): first_name = 'Test' @@ -56,7 +56,7 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) -class ModulestoreTest(TestCase): +#class ModulestoreTest(TestCase): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestJumpTo(TestCase): @@ -68,9 +68,6 @@ class TestJumpTo(TestCase): self.course_name = 'edX/toy/2012_Fall' self.toy_course = modulestore().get_course('edX/toy/2012_Fall') - def test(self): - self.assertEquals(1,2) - def test_jumpto_invalid_location(self): location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None) jumpto_url = '%s/%s/jump_to/%s' % ('/courses', self.course_name, location) @@ -101,11 +98,11 @@ class ViewsTestCase(TestCase): self.request_factory = RequestFactory() # Many functions call upon render_to_response # Problem is that we don't know what templates there are? - views.render_to_response = render_to_response + #views.render_to_response = render_to_response #m = mako.MakoMiddleware() def test_user_groups(self): - # depreciated function? + # depreciated function mock_user = MagicMock() mock_user.is_authenticated.return_value = False self.assertEquals(views.user_groups(mock_user),[]) @@ -160,10 +157,10 @@ class ViewsTestCase(TestCase): request, 'bar', ()) self.assertRaisesRegexp(Http404, 'No data*', views.jump_to, request, 'dummy', self.location) - print type(self.toy_course) - print dir(self.toy_course) - print self.toy_course.location - print self.toy_course.__dict__ +## print type(self.toy_course) +## print dir(self.toy_course) +## print self.toy_course.location +## print self.toy_course.__dict__ valid = ['i4x', 'edX', 'toy', 'chapter', 'overview'] L = Location('i4x', 'edX', 'toy', 'chapter', 'Overview', None) @@ -172,18 +169,32 @@ class ViewsTestCase(TestCase): def test_static_tab(self): mock_request = MagicMock() mock_request.user = self.user - # What is tab_slug? + # What is tab_slug? A string? #views.static_tab(mock_request, self.course_id, 'dummy') + def test_static_university_profile(self): + # TODO + # Can't test unless have a valid template file +## request = self.client.get('university_profile/edX') +## views.static_university_profile(request, 'edX') + pass + + def test_university_profile(self): + chapter = 'Overview' chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) request = self.request_factory.get(chapter_url) request.user = UserFactory() self.assertRaisesRegexp(Http404, 'University Profile*', views.university_profile, request, 'Harvard') - # Mocked out function render_to_response - self.assertIsInstance(views.university_profile(request, 'edX'), HttpResponse) + # Supposed to return a HttpResponse object + # Templates don't exist because not in database + # TODO + # assertTemplateUsed is called on an HttpResponse, but + #request_2 = self.client.get('/university_profile/edx') + #self.assertIsInstance(views.university_profile(request, 'edX'), HttpResponse) + # Can't continue testing unless have valid template file def test_syllabus(self): chapter = 'Overview' @@ -191,8 +202,23 @@ class ViewsTestCase(TestCase): request = self.request_factory.get(chapter_url) request.user = UserFactory() # course not found + # TODO views.syllabus(request, self.course_id) def test_render_notifications(self): request = self.request_factory.get('foo') - views.render_notifications(request, self.course_id, 'dummy') + #views.render_notifications(request, self.course_id, 'dummy') + # TODO + # Needs valid template + + def test_news(self): + #print settings.TEMPLATE_DIRS + #assert False + # I want news to get all the way to render_to_response + # Bug? get_notifications is actually in lms/lib/comment_client/legacy.py + request = self.client.get('/news') + self.user.id = 'foo' + request.user = self.user + course_id = 'edX/toy/2012_Fall' + views.news(request, course_id) + # TODO From f9b0ec37cd760750bdc2522d22184d1b0cb66a39 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Thu, 24 Jan 2013 16:08:19 -0500 Subject: [PATCH 011/214] more tests in test_views, needs templates to continue --- lms/djangoapps/courseware/tests/test_views.py | 67 +++++++++++-------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index fda591b2e6..52496bcb6b 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -4,6 +4,7 @@ import datetime import factory import unittest import os +from nose.plugins.skip import SkipTest from django.test import TestCase from django.http import Http404, HttpResponse @@ -19,6 +20,12 @@ from xmodule.modulestore.exceptions import InvalidLocationError,\ import courseware.views as views from xmodule.modulestore import Location +def skipped(func): + from nose.plugins.skip import SkipTest + def _(): + raise SkipTest("Test %s is skipped" % func.__name__) + _.__name__ = func.__name__ + return _ #from override_settings import override_settings @@ -39,8 +46,14 @@ class UserFactory(factory.Factory): is_staff = True is_active = True -# This part is required for modulestore() to work properly +def skipped(func): + from nose.plugins.skip import SkipTest + def _(): + raise SkipTest("Test %s is skipped" % func.__name__) + _.__name__ = func.__name__ + return _ +# This part is required for modulestore() to work properly def xml_store_config(data_dir): return { 'default': { @@ -96,10 +109,6 @@ class ViewsTestCase(TestCase): # This is a CourseDescriptor object self.toy_course = modulestore().get_course('edX/toy/2012_Fall') self.request_factory = RequestFactory() - # Many functions call upon render_to_response - # Problem is that we don't know what templates there are? - #views.render_to_response = render_to_response - #m = mako.MakoMiddleware() def test_user_groups(self): # depreciated function @@ -161,49 +170,50 @@ class ViewsTestCase(TestCase): ## print dir(self.toy_course) ## print self.toy_course.location ## print self.toy_course.__dict__ - valid = ['i4x', 'edX', 'toy', 'chapter', 'overview'] - L = Location('i4x', 'edX', 'toy', 'chapter', 'Overview', None) - - views.jump_to(request, 'dummy', L) +## valid = ['i4x', 'edX', 'toy', 'chapter', 'overview'] +## L = Location('i4x', 'edX', 'toy', 'chapter', 'Overview', None) +## +## views.jump_to(request, 'dummy', L) def test_static_tab(self): - mock_request = MagicMock() - mock_request.user = self.user - # What is tab_slug? A string? - #views.static_tab(mock_request, self.course_id, 'dummy') - + request = self.request_factory.get('foo') + request.user = self.user + self.assertRaises(Http404, views.static_tab, request, 'edX/toy/2012_Fall', + 'dummy') + # What are valid tab_slugs? +## request_2 = self.request_factory.get('foo') +## request_2.user = UserFactory() + def test_static_university_profile(self): # TODO # Can't test unless have a valid template file -## request = self.client.get('university_profile/edX') -## views.static_university_profile(request, 'edX') - pass + raise SkipTest + request = self.client.get('university_profile/edX') + self.assertIsInstance(views.static_university_profile(request, 'edX'), HttpResponse) - def test_university_profile(self): - + raise SkipTest chapter = 'Overview' chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) request = self.request_factory.get(chapter_url) request.user = UserFactory() self.assertRaisesRegexp(Http404, 'University Profile*', views.university_profile, request, 'Harvard') - # Supposed to return a HttpResponse object - # Templates don't exist because not in database # TODO - # assertTemplateUsed is called on an HttpResponse, but #request_2 = self.client.get('/university_profile/edx') - #self.assertIsInstance(views.university_profile(request, 'edX'), HttpResponse) + self.assertIsInstance(views.university_profile(request, 'edX'), HttpResponse) # Can't continue testing unless have valid template file + def test_syllabus(self): + raise SkipTest chapter = 'Overview' chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) request = self.request_factory.get(chapter_url) request.user = UserFactory() - # course not found + # Can't find valid template # TODO - views.syllabus(request, self.course_id) + views.syllabus(request, 'edX/toy/2012_Fall') def test_render_notifications(self): request = self.request_factory.get('foo') @@ -212,13 +222,12 @@ class ViewsTestCase(TestCase): # Needs valid template def test_news(self): - #print settings.TEMPLATE_DIRS - #assert False - # I want news to get all the way to render_to_response + raise SkipTest # Bug? get_notifications is actually in lms/lib/comment_client/legacy.py request = self.client.get('/news') self.user.id = 'foo' request.user = self.user course_id = 'edX/toy/2012_Fall' - views.news(request, course_id) + self.assertIsInstance(views.news(request, course_id), HttpResponse) + # TODO From 583505b95ba8a2a803e4130829e9c19c8e586533 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Fri, 25 Jan 2013 12:19:16 -0500 Subject: [PATCH 012/214] test_courses.py --- .../courseware/tests/test_courses.py | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 7cb2f8b54b..2f4868b27b 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -3,21 +3,59 @@ import datetime from django.test import TestCase from django.contrib.auth.models import User +from django.conf import settings +from django.test.utils import override_settings from student.models import CourseEnrollment import courseware.courses as courses from xmodule.modulestore.xml import XMLModuleStore +from xmodule.modulestore.django import modulestore +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class CoursesTestCase(TestCase): def setUp(self): self.user = User.objects.create(username='dummy', password='123456', email='test@mit.edu') self.date = datetime.datetime(2013,1,22) - self.course_id = 'edx/toy/Fall_2012' + self.course_id = 'edx/toy/2012_Fall' self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, course_id = self.course_id, created = self.date)[0] - self.xml_modulestore = XMLModuleStore() + self._MODULESTORES = {} + self.toy_course = modulestore().get_course('edX/toy/2012_Fall') def test_get_course_by_id(self): - courses.get_course_by_id(self.course_id) + courses.get_course_by_id("edx/toy/2012_Fall") + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class CoursesTests(TestCase): + def setUp(self): + self._MODULESTORES = {} + self.course_name = 'edX/toy/2012_Fall' + self.toy_course = modulestore().get_course('edX/toy/2012_Fall') + self.fake_user = User.objects.create(is_superuser=True) + + ''' + no test written for get_request_for_thread + ''' + + def test_get_course_by_id(self): + self.test_course_id = "edX/toy/2012_Fall" + # print modulestore().get_instance(test_course_id, Location('i4x', 'edx', 'toy', 'course', '2012_Fall')) + self.assertEqual(courses.get_course_by_id(self.test_course_id),modulestore().get_instance(self.test_course_id, Location('i4x', 'edX', 'toy', 'course', '2012_Fall'), None)) + + From 7918c35414ca2f6d9cfe7205b684902deaf43ae9 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Mon, 28 Jan 2013 15:29:55 -0500 Subject: [PATCH 013/214] fixing up tests --- .../courseware/tests/test_courses.py | 30 ++-- .../courseware/tests/test_module_render.py | 130 ++++++++++++------ lms/djangoapps/courseware/tests/test_views.py | 44 +++--- 3 files changed, 126 insertions(+), 78 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index 2f4868b27b..91b6af4dfc 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -10,6 +10,7 @@ from student.models import CourseEnrollment import courseware.courses as courses from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location def xml_store_config(data_dir): return { @@ -28,34 +29,35 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class CoursesTestCase(TestCase): def setUp(self): - self.user = User.objects.create(username='dummy', password='123456', - email='test@mit.edu') - self.date = datetime.datetime(2013,1,22) - self.course_id = 'edx/toy/2012_Fall' - self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, - course_id = self.course_id, - created = self.date)[0] +## self.user = User.objects.create(username='dummy', password='123456', +## email='test@mit.edu') +## self.date = datetime.datetime(2013,1,22) +## self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, +## course_id = self.course_id, +## created = self.date)[0] self._MODULESTORES = {} + #self.course_id = 'edx/toy/2012_Fall' self.toy_course = modulestore().get_course('edX/toy/2012_Fall') def test_get_course_by_id(self): - courses.get_course_by_id("edx/toy/2012_Fall") + courses.get_course_by_id("edX/toy/2012_Fall") + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class CoursesTests(TestCase): + # runs def setUp(self): self._MODULESTORES = {} - self.course_name = 'edX/toy/2012_Fall' + #self.course_id = 'edX/toy/2012_Fall' self.toy_course = modulestore().get_course('edX/toy/2012_Fall') - self.fake_user = User.objects.create(is_superuser=True) +## self.fake_user = User.objects.create(is_superuser=True) ''' no test written for get_request_for_thread ''' def test_get_course_by_id(self): - self.test_course_id = "edX/toy/2012_Fall" + #self.test_course_id = "edX/toy/2012_Fall" + courses.get_course_by_id("edX/toy/2012_Fall") # print modulestore().get_instance(test_course_id, Location('i4x', 'edx', 'toy', 'course', '2012_Fall')) - self.assertEqual(courses.get_course_by_id(self.test_course_id),modulestore().get_instance(self.test_course_id, Location('i4x', 'edX', 'toy', 'course', '2012_Fall'), None)) - - + #self.assertEqual(courses.get_course_by_id(self.test_course_id),modulestore().get_instance(self.test_course_id, Location('i4x', 'edX', 'toy', 'course', '2012_Fall'), None)) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 925601ba37..3150450648 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -2,7 +2,9 @@ import logging from mock import MagicMock, patch import json import factory -import unittest +import unittest +from nose.tools import set_trace +from nose.plugins.skip import SkipTest from django.http import Http404, HttpResponse, HttpRequest from django.conf import settings @@ -11,22 +13,50 @@ from django.test.client import Client from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory +from django.core.urlresolvers import reverse -from courseware.models import StudentModule +from courseware.models import StudentModule, StudentModuleCache from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.exceptions import NotFoundError from xmodule.modulestore import Location import courseware.module_render as render from override_settings import override_settings from xmodule.modulestore.django import modulestore, _MODULESTORES - +from xmodule.seq_module import SequenceModule +from courseware.tests.tests import PageLoader +from student.models import Registration class Stub: def __init__(self): pass -class ModuleRenderTestCase(TestCase): +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +class UserFactory(factory.Factory): + first_name = 'Test' + last_name = 'Robot' + is_staff = True + is_active = True + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class ModuleRenderTestCase(PageLoader): def setUp(self): - self.location = ['tag', 'org', 'course', 'category', 'name'] + self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] + self._MODULESTORES = {} + self.course_id = 'edX/toy/2012_Fall' + self.toy_course = modulestore().get_course(self.course_id) def test_toc_for_course(self): mock_course = MagicMock() @@ -37,19 +67,26 @@ class ModuleRenderTestCase(TestCase): mock_user.is_authenticated.return_value = False self.assertIsNone(render.toc_for_course(mock_user,'dummy', mock_course, 'dummy', 'dummy')) + # rest of tests are in class TestTOC def test_get_module(self): self.assertIsNone(render.get_module('dummyuser',None,\ 'invalid location',None,None)) - + #done def test__get_module(self): mock_user = MagicMock() - mock_user.is_authenticated.return_value = True - location = ['tag', 'org', 'course', 'category', 'name'] - #render._get_module(mock_user, + mock_user.is_authenticated.return_value = False + location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') + mock_request = MagicMock() + s = render._get_module(mock_user, mock_request, location, + 'dummy', 'edX/toy/2012_Fall') + self.assertIsInstance(s, SequenceModule) + # Don't know how to generate error in line 260 to test + # Can't tell if sequence module is an error? def test_get_instance_module(self): + # done mock_user = MagicMock() mock_user.is_authenticated.return_value = False self.assertIsNone(render.get_instance_module('dummy', mock_user, 'dummy', @@ -62,16 +99,28 @@ class ModuleRenderTestCase(TestCase): mock_module,'dummy')) def test_get_shared_instance_module(self): + raise SkipTest mock_user = MagicMock(User) mock_user.is_authenticated.return_value = False self.assertIsNone(render.get_shared_instance_module('dummy', mock_user, 'dummy', 'dummy')) mock_user_2 = MagicMock(User) mock_user_2.is_authenticated.return_value = True + mock_module = MagicMock() mock_module.shared_state_key = 'key' - self.assertIsInstance(render.get_shared_instance_module('dummy', mock_user, - mock_module, 'dummy'), StudentModule) + mock_module.location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') + mock_module.get_shared_state.return_value = '{}' + mock_cache = MagicMock() + mock_cache.lookup.return_value = False + #mock_cache._state = 'dummy' + #set_trace() + print mock_module.get_shared_state() + s = render.get_shared_instance_module(self.course_id, mock_user_2, + mock_module, mock_cache) + self.assertIsInstance(s, StudentModule) + # Problem: can't get code to take branch that creates StudentModule? + # Can't finish testing modx_dispatch def test_xqueue_callback(self): mock_request = MagicMock() @@ -90,9 +139,21 @@ class ModuleRenderTestCase(TestCase): xpackage_2 = {'xqueue_header': json.dumps({'lms_key':'secretkey'}), 'xqueue_body' : 'Message from grader'} mock_request_3.POST.copy.return_value = xpackage_2 -## self.assertRaises(Http404, render.xqueue_callback, mock_request_3, -## 'dummy', 0, 'dummy', 'dummy') - # continue later + # Roadblock: how to get user registered in class? + raise SkipTest + # + # trying alternate way of creating account in hopes of getting valid id + # Problem: Can't activate user + + self.student_name = '12' + self.password = 'foo' + self.email = 'test@mit.edu' + self.create_account(self.student_name, self.email, self.password) + self.activate_user(self.email) + request = RequestFactory().get('stuff') + # This doesn't work to install user + render.xqueue_callback(mock_request_3, self.course_id, + self.student_name, self.password, 'dummy') def test_modx_dispatch(self): self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy', @@ -117,12 +178,23 @@ class ModuleRenderTestCase(TestCase): (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE/(1000**2))})) mock_request_3 = MagicMock() mock_request_3.POST.copy.return_value = {} + mock_request_3.FILES = False + mock_request_3.user = UserFactory() inputfile_2 = Stub() inputfile_2.size = 1 inputfile_2.name = 'name' self.assertRaises(ItemNotFoundError, render.modx_dispatch, mock_request_3, 'dummy', self.location, 'toy') - # Deadend + self.assertRaises(Http404,render.modx_dispatch, mock_request_3, 'dummy', + self.location, self.course_id) +## student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(self.course_id, +## mock_request_3.user, modulestore().get_instance(self.course_id, self.location)) +## get_shared_instance_module(course_id, request.user, instance, student_module_cache) + # 'goto_position' is the only dispatch that will work + mock_request_3.POST.copy.return_value = {'position':1} + self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position', + self.location, self.course_id), HttpResponse) + # keep going def test_preview_chemcalc(self): mock_request = MagicMock() @@ -159,33 +231,6 @@ class ModuleRenderTestCase(TestCase): self.assertEquals(render.get_score_bucket(11, 10), 'incorrect') self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect') - -class MagicMockFactory(factory.Factory): - FACTORY_FOR = MagicMock - v = factory.LazyAttribute(i for i in [True, False, False]) - - - -def xml_store_config(data_dir): - return { - 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - } - } -} - -TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) - -class UserFactory(factory.Factory): - first_name = 'Test' - last_name = 'Robot' - is_staff = True - is_active = True - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestTOC(TestCase): """Check the Table of Contents for a course""" @@ -245,3 +290,4 @@ class TestTOC(TestCase): actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section) self.assertEqual(expected, actual) + diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 52496bcb6b..c901f87720 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -32,14 +32,6 @@ def skipped(func): class Stub(): pass -##def render_to_response(template_name, dictionary, context_instance=None, -## namespace='main', **kwargs): -## # The original returns HttpResponse -## print dir() -## print template_name -## print dictionary -## return HttpResponse('foo') - class UserFactory(factory.Factory): first_name = 'Test' last_name = 'Robot' @@ -100,7 +92,7 @@ class ViewsTestCase(TestCase): self.user = User.objects.create(username='dummy', password='123456', email='test@mit.edu') self.date = datetime.datetime(2013,1,22) - self.course_id = 'edx/toy/2012_Fall' + self.course_id = 'edX/toy/2012_Fall' self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, course_id = self.course_id, created = self.date)[0] @@ -109,6 +101,9 @@ class ViewsTestCase(TestCase): # This is a CourseDescriptor object self.toy_course = modulestore().get_course('edX/toy/2012_Fall') self.request_factory = RequestFactory() + chapter = 'Overview' + self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) + def test_user_groups(self): # depreciated function @@ -145,9 +140,19 @@ class ViewsTestCase(TestCase): mock_module, True) def test_index(self): - pass - #print modulestore() - #assert False + assert SkipTest + request = self.request_factory.get(self.chapter_url) + request.user = UserFactory() + response = views.index(request, self.course_id) + self.assertIsInstance(response, HttpResponse) + self.assertEqual(response.status_code, 302) + # views.index does not throw 404 if chapter, section, or position are + # not valid, which doesn't match index's comments + views.index(request, self.course_id, chapter='foo', section='bar', + position='baz') + request_2 = self.request_factory.get(self.chapter_url) + request_2.user = self.user + response = views.index(request_2, self.course_id) def test_registered_for_course(self): self.assertFalse(views.registered_for_course('Basketweaving', None)) @@ -159,9 +164,7 @@ class ViewsTestCase(TestCase): self.assertTrue(views.registered_for_course(mock_course, self.user)) def test_jump_to(self): - chapter = 'Overview' - chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) - request = self.request_factory.get(chapter_url) + request = self.request_factory.get(self.chapter_url) self.assertRaisesRegexp(Http404, 'Invalid location', views.jump_to, request, 'bar', ()) self.assertRaisesRegexp(Http404, 'No data*', views.jump_to, request, @@ -186,16 +189,14 @@ class ViewsTestCase(TestCase): def test_static_university_profile(self): # TODO - # Can't test unless have a valid template file + # Can't test unless havehttp://toastdriven.com/blog/2011/apr/10/guide-to-testing-in-django/ a valid template file raise SkipTest request = self.client.get('university_profile/edX') self.assertIsInstance(views.static_university_profile(request, 'edX'), HttpResponse) def test_university_profile(self): raise SkipTest - chapter = 'Overview' - chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) - request = self.request_factory.get(chapter_url) + request = self.request_factory.get(self.chapter_url) request.user = UserFactory() self.assertRaisesRegexp(Http404, 'University Profile*', views.university_profile, request, 'Harvard') @@ -207,15 +208,14 @@ class ViewsTestCase(TestCase): def test_syllabus(self): raise SkipTest - chapter = 'Overview' - chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) - request = self.request_factory.get(chapter_url) + request = self.request_factory.get(self.chapter_url) request.user = UserFactory() # Can't find valid template # TODO views.syllabus(request, 'edX/toy/2012_Fall') def test_render_notifications(self): + raise SkipTest request = self.request_factory.get('foo') #views.render_notifications(request, self.course_id, 'dummy') # TODO From 74839663ff163576bef15ac951c6eba1a6d6dddf Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 29 Jan 2013 10:12:39 -0500 Subject: [PATCH 014/214] added cms/djangoapps/contentstore/test/tests.py --- .../contentstore/tests/test_views.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 cms/djangoapps/contentstore/tests/test_views.py diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py new file mode 100644 index 0000000000..f513eef7f9 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -0,0 +1,50 @@ +import logging +from mock import MagicMock, patch +import json +import factory +import unittest +from nose.tools import set_trace +from nose.plugins.skip import SkipTest + +from django.http import Http404, HttpResponse, HttpRequest +from django.conf import settings +from django.contrib.auth.models import User +from django.test.client import Client +from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory + +import cms.djangoapps.contentstore.views as views + +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +class UserFactory(factory.Factory): + first_name = 'Test' + last_name = 'Robot' + is_staff = True + is_active = True + is_authenticated = True + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class ViewsTestCase(TestCase): + def setUp(self): + self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] + self._MODULESTORES = {} + self.course_id = 'edX/toy/2012_Fall' + self.toy_course = modulestore().get_course(self.course_id) + + def test_has_access(self): + user = UserFactory() + views.has_access(user, self.location) From 85c412fc2eafefd0240a3c0081532d633b045a37 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 29 Jan 2013 11:06:32 -0500 Subject: [PATCH 015/214] changed cms/env/tests, added test_views.py --- cms/djangoapps/contentstore/tests/test_views.py | 10 +++++++--- cms/envs/test.py | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index f513eef7f9..ae6548bd9d 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -13,8 +13,11 @@ from django.test.client import Client from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory +from override_settings import override_settings + +from xmodule.modulestore.django import modulestore, _MODULESTORES +import contentstore.views as views -import cms.djangoapps.contentstore.views as views def xml_store_config(data_dir): return { @@ -32,7 +35,6 @@ class UserFactory(factory.Factory): last_name = 'Robot' is_staff = True is_active = True - is_authenticated = True TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) @@ -47,4 +49,6 @@ class ViewsTestCase(TestCase): def test_has_access(self): user = UserFactory() - views.has_access(user, self.location) + user.is_authenticated = True + set_trace() + self.assertTrue(views.has_access(user, self.location)) diff --git a/cms/envs/test.py b/cms/envs/test.py index d55c309827..e31037b04f 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -25,6 +25,9 @@ STATIC_ROOT = TEST_ROOT / "staticfiles" GITHUB_REPO_ROOT = TEST_ROOT / "data" COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" +# Makes the tests run much faster... +SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead + # TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing STATICFILES_DIRS = [ COMMON_ROOT / "static", From d9fdccb567924f04ec420e95bfe5c37e5bee751a Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 29 Jan 2013 14:23:55 -0500 Subject: [PATCH 016/214] testing testing tests --- .../contentstore/tests/test_views.py | 45 ++++++++++++++++--- .../courseware/tests/test_module_render.py | 9 ++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index ae6548bd9d..c1fa19624c 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -6,7 +6,7 @@ import unittest from nose.tools import set_trace from nose.plugins.skip import SkipTest -from django.http import Http404, HttpResponse, HttpRequest +from django.http import Http404, HttpResponse, HttpRequest, HttpResponseRedirect from django.conf import settings from django.contrib.auth.models import User from django.test.client import Client @@ -14,9 +14,12 @@ from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory from override_settings import override_settings +from django.core.exceptions import PermissionDenied from xmodule.modulestore.django import modulestore, _MODULESTORES import contentstore.views as views +import auth.authz as a +from contentstore.tests.factories import XModuleCourseFactory, CourseFactory def xml_store_config(data_dir): @@ -39,16 +42,46 @@ class UserFactory(factory.Factory): TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class ViewsTestCase(TestCase): def setUp(self): self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] + self.location_2 = ['i4x', 'edX', 'full', 'course', '6.002_Spring_2012'] + # empty Modulestore self._MODULESTORES = {} self.course_id = 'edX/toy/2012_Fall' + self.course_id_2 = 'edx/full/6.002_Spring_2012' self.toy_course = modulestore().get_course(self.course_id) def test_has_access(self): - user = UserFactory() - user.is_authenticated = True - set_trace() - self.assertTrue(views.has_access(user, self.location)) + user = MagicMock(is_staff = True, is_active = True, is_authenticated = True) + m = MagicMock() + m.count.return_value = 1 + user.groups.filter.return_value = m + self.assertTrue(views.has_access(user, self.location_2)) + user.is_authenticated = False + self.assertFalse(views.has_access(user, self.location_2)) + + def test_course_index(self): + # UserFactory doesn't work? + self.user = MagicMock(is_staff = False, is_active = False) + self.user.is_authenticated.return_value = False + request = MagicMock(user = self.user) + # Instead of raising exception when has_access is False, redirects + self.assertIsInstance(views.course_index(request, 'edX', + 'full', '6.002_Spring_2012'), HttpResponseRedirect) + self.user_2 = MagicMock(is_staff = True, is_active = True) + self.user_2.is_authenticated.return_value = True + request_2 = MagicMock(user = self.user_2) + # Bug? Raises error because calls modulestore().get_item(location) + #NotImplementedError: XMLModuleStores can't guarantee that definitions + #are unique. Use get_instance. + print views.course_index(request_2, 'edX', + 'full', '6.002_Spring_2012') + + def test_edit_subsection(self): + self.user = MagicMock(is_staff = False, is_active = False) + self.user.is_authenticated.return_value = False + self.request = MagicMock(user = self.user) + self.assertIsInstance(views.edit_subscription(self.request, self.location_2), + HttpResponseRedirect) + diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 3150450648..f419e6f582 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -107,8 +107,7 @@ class ModuleRenderTestCase(PageLoader): mock_user_2 = MagicMock(User) mock_user_2.is_authenticated.return_value = True - mock_module = MagicMock() - mock_module.shared_state_key = 'key' + mock_module = MagicMock(shared_state_key = 'key') mock_module.location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') mock_module.get_shared_state.return_value = '{}' mock_cache = MagicMock() @@ -197,11 +196,9 @@ class ModuleRenderTestCase(PageLoader): # keep going def test_preview_chemcalc(self): - mock_request = MagicMock() - mock_request.method = 'notGET' + mock_request = MagicMock(method = 'notGET') self.assertRaises(Http404, render.preview_chemcalc, mock_request) - mock_request_2 = MagicMock() - mock_request_2.method = 'GET' + mock_request_2 = MagicMock(method = 'GET') mock_request_2.GET.get.return_value = None self.assertEquals(render.preview_chemcalc(mock_request_2).content, json.dumps({'preview':'', From 3858362157d1638f222fc5e8da893bdbe38262d9 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Tue, 29 Jan 2013 15:53:20 -0500 Subject: [PATCH 017/214] more tests --- cms/djangoapps/contentstore/tests/test_views.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index c1fa19624c..ed18e1cc8c 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -50,8 +50,14 @@ class ViewsTestCase(TestCase): self._MODULESTORES = {} self.course_id = 'edX/toy/2012_Fall' self.course_id_2 = 'edx/full/6.002_Spring_2012' - self.toy_course = modulestore().get_course(self.course_id) + #self.toy_course = modulestore().get_course(self.course_id) + # Problem: Classes persist, need to delete stuff from modulestore + self.course = CourseFactory.create() + print dir(self.course) + def tearDown(self): + pass + def test_has_access(self): user = MagicMock(is_staff = True, is_active = True, is_authenticated = True) m = MagicMock() @@ -72,16 +78,15 @@ class ViewsTestCase(TestCase): self.user_2 = MagicMock(is_staff = True, is_active = True) self.user_2.is_authenticated.return_value = True request_2 = MagicMock(user = self.user_2) - # Bug? Raises error because calls modulestore().get_item(location) - #NotImplementedError: XMLModuleStores can't guarantee that definitions - #are unique. Use get_instance. + # Need to use XModuleStoreFactory? print views.course_index(request_2, 'edX', 'full', '6.002_Spring_2012') def test_edit_subsection(self): + # Redirects if request.user doesn't have access to location self.user = MagicMock(is_staff = False, is_active = False) self.user.is_authenticated.return_value = False self.request = MagicMock(user = self.user) - self.assertIsInstance(views.edit_subscription(self.request, self.location_2), + self.assertIsInstance(views.edit_subsection(self.request, self.location_2), HttpResponseRedirect) From cdbe9857d67b3c71255be512e47e440a5a0e10b2 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Wed, 30 Jan 2013 12:30:48 -0500 Subject: [PATCH 018/214] tests in cms.djangoapps.contentstore/tests/test_views.py --- .../contentstore/tests/test_views.py | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index ed18e1cc8c..87dd7f7ac6 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -6,7 +6,7 @@ import unittest from nose.tools import set_trace from nose.plugins.skip import SkipTest -from django.http import Http404, HttpResponse, HttpRequest, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpRequest, HttpResponseRedirect, HttpResponseBadRequest from django.conf import settings from django.contrib.auth.models import User from django.test.client import Client @@ -18,8 +18,8 @@ from django.core.exceptions import PermissionDenied from xmodule.modulestore.django import modulestore, _MODULESTORES import contentstore.views as views -import auth.authz as a -from contentstore.tests.factories import XModuleCourseFactory, CourseFactory +from contentstore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore import Location def xml_store_config(data_dir): @@ -46,17 +46,21 @@ class ViewsTestCase(TestCase): def setUp(self): self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] self.location_2 = ['i4x', 'edX', 'full', 'course', '6.002_Spring_2012'] + self.location_3 = ['i4x', 'MITx', '999', 'course', 'Robot_Super_Course'] # empty Modulestore self._MODULESTORES = {} self.course_id = 'edX/toy/2012_Fall' self.course_id_2 = 'edx/full/6.002_Spring_2012' #self.toy_course = modulestore().get_course(self.course_id) # Problem: Classes persist, need to delete stuff from modulestore + # is a CourseDescriptor object? self.course = CourseFactory.create() - print dir(self.course) + # is a sequence descriptor + self.item = ItemFactory.create(template = 'i4x://edx/templates/sequential/Empty') def tearDown(self): - pass + _MODULESTORES = {} + modulestore().collection.drop() def test_has_access(self): user = MagicMock(is_staff = True, is_active = True, is_authenticated = True) @@ -72,15 +76,15 @@ class ViewsTestCase(TestCase): self.user = MagicMock(is_staff = False, is_active = False) self.user.is_authenticated.return_value = False request = MagicMock(user = self.user) - # Instead of raising exception when has_access is False, redirects + # Redirects if request.user doesn't have access to location self.assertIsInstance(views.course_index(request, 'edX', 'full', '6.002_Spring_2012'), HttpResponseRedirect) self.user_2 = MagicMock(is_staff = True, is_active = True) self.user_2.is_authenticated.return_value = True request_2 = MagicMock(user = self.user_2) - # Need to use XModuleStoreFactory? - print views.course_index(request_2, 'edX', - 'full', '6.002_Spring_2012') + # Doesn't work unless we figure out render_to_response +## views.course_index(request_2, 'MITx', +## '999', 'Robot_Super_Course') def test_edit_subsection(self): # Redirects if request.user doesn't have access to location @@ -89,4 +93,44 @@ class ViewsTestCase(TestCase): self.request = MagicMock(user = self.user) self.assertIsInstance(views.edit_subsection(self.request, self.location_2), HttpResponseRedirect) - + # If location isn't for a "sequential", return Bad Request + self.user_2 = MagicMock(is_staff = True, is_active = True) + self.user_2.is_authenticated.return_value = True + self.request_2 = MagicMock(user = self.user_2) + self.assertIsInstance(views.edit_subsection(self.request_2, + self.location_3), HttpResponseBadRequest) + # Need render_to_response + #views.edit_subsection(self.request_2, self.item.location) + + def test_edit_unit(self): + # if user doesn't have access, should redirect + self.user = MagicMock(is_staff = False, is_active = False) + self.user.is_authenticated.return_value = False + self.request = MagicMock(user = self.user) + self.assertIsInstance(views.edit_unit(self.request, self.location_2), + HttpResponseRedirect) + + def test_assignment_type_update(self): + # If user doesn't have access, should redirect + self.user = MagicMock(is_staff = False, is_active = False) + self.user.is_authenticated.return_value = False + self.request = RequestFactory().get('foo') + self.request.user = self.user + self.assertIsInstance(views.assignment_type_update(self.request, + 'MITx', '999', 'course', 'Robot_Super_Course'), + HttpResponseRedirect) + # if user has access, then should return HttpResponse + self.user_2 = MagicMock(is_staff = True, is_active = True) + self.user_2.is_authenticated.return_value = True + self.request.user = self.user_2 + get_response = views.assignment_type_update(self.request,'MITx', '999', + 'course', 'Robot_Super_Course') + self.assertIsInstance(get_response,HttpResponse) + get_response_string = '{"id": 99, "location": ["i4x", "MITx", "999", "course", "Robot_Super_Course", null], "graderType": "Not Graded"}' + self.assertEquals(get_response.content, get_response_string) + self.request_2 = RequestFactory().post('foo') + self.request_2.user = self.user_2 + post_response = views.assignment_type_update(self.request_2,'MITx', '999', + 'course', 'Robot_Super_Course') + self.assertIsInstance(post_response,HttpResponse) + self.assertEquals(post_response.content, 'null') From 40ddaa99766244efb3f46b8b2841aa6f1a076724 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Wed, 30 Jan 2013 16:03:16 -0500 Subject: [PATCH 019/214] more tests on test_views.py --- .../contentstore/tests/test_views.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index 87dd7f7ac6..2cb00eac36 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -5,6 +5,7 @@ import factory import unittest from nose.tools import set_trace from nose.plugins.skip import SkipTest +from collections import defaultdict from django.http import Http404, HttpResponse, HttpRequest, HttpResponseRedirect, HttpResponseBadRequest from django.conf import settings @@ -20,7 +21,11 @@ from xmodule.modulestore.django import modulestore, _MODULESTORES import contentstore.views as views from contentstore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore import Location +from xmodule.x_module import ModuleSystem +from xmodule.error_module import ErrorModule +class Stub(): + pass def xml_store_config(data_dir): return { @@ -61,6 +66,7 @@ class ViewsTestCase(TestCase): def tearDown(self): _MODULESTORES = {} modulestore().collection.drop() + assert False def test_has_access(self): user = MagicMock(is_staff = True, is_active = True, is_authenticated = True) @@ -134,3 +140,78 @@ class ViewsTestCase(TestCase): 'course', 'Robot_Super_Course') self.assertIsInstance(post_response,HttpResponse) self.assertEquals(post_response.content, 'null') + + def test_load_preview_state(self): + # Tests that function creates empty defaultdict when request.session + # is empty + # location cannot be a list or other mutable type + self.request = RequestFactory().get('foo') + self.request.session = {} + instance_state, shared_state = views.load_preview_state(self.request, + 'foo', 'bar') + self.assertIsNone(instance_state) + self.assertIsNone(shared_state) + + def test_save_preview_state(self): + self.request = RequestFactory().get('foo') + self.request.session = {} + loc = Location(self.location_3) + result = {'preview_states': + {('id', loc):{'instance':None, + 'shared':None, + } + } + } + views.save_preview_state(self.request, 'id', loc, None, None) + self.assertEquals(self.request.session, result) + + def test_get_preview_module(self): + raise SkipTest + self.request = RequestFactory().get('foo') + self.request.user = UserFactory() + mock_descriptor = MagicMock() + mock_descriptor.get_sample_state.return_value = [('foo','bar')] + instance, shared = views.get_preview_module(self.request, 'id', mock_descriptor) + self.assertEquals(instance, 'foo') + + def test_preview_module_system(self): + # Returns a ModuleSystem + self.request = RequestFactory().get('foo') + self.request.user = UserFactory() + self.assertIsInstance(views.preview_module_system(self.request, + 'id', self.course), + ModuleSystem) + + def test_load_preview_module(self): + + self.request = RequestFactory().get('foo') + self.request.user = UserFactory() + self.request.session = {} + self.assertIsInstance(views.load_preview_module(self.request, 'id', + self.course, 'instance', 'shared'), + ErrorModule) + system = views.preview_module_system(self.request, 'id', self.course) + # is a functools.partial object? + # Not sure how to get a valid line 507 + print self.course.xmodule_constructor(system) + print self.course.xmodule_constructor(system).func + print self.course.xmodule_constructor(system).keywords + + def test__xmodule_recurse(self): + raise SkipTest +## mock_item = MagicMock() +## mock_item.get_children.return_value = [] + s = Stub() + s.children.append(Stub()) + views._xmodule_recurse(s, lambda x: return) + #views._xmodule_recurse(s, lambda x: x.n += 1) + self.assertEquals(s.n, 1) + self.assertEquals(s.children[0].n, 1) + +class Stub(): + def __init__(self): + self.n = 0 + self.children = [] + def get_children(self): + return self.children + From abc3e5b09d705d728522b6ffb79da85a5489fe1a Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Thu, 31 Jan 2013 14:47:59 -0500 Subject: [PATCH 020/214] more tests in test_views.py --- .../contentstore/tests/test_views.py | 119 ++++++++++++++++-- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index 2cb00eac36..9fa16ad4b2 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -23,6 +23,7 @@ from contentstore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore import Location from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorModule +from contentstore.utils import get_course_for_item class Stub(): pass @@ -49,6 +50,7 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) class ViewsTestCase(TestCase): def setUp(self): + #modulestore().collection.drop() self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] self.location_2 = ['i4x', 'edX', 'full', 'course', '6.002_Spring_2012'] self.location_3 = ['i4x', 'MITx', '999', 'course', 'Robot_Super_Course'] @@ -56,8 +58,6 @@ class ViewsTestCase(TestCase): self._MODULESTORES = {} self.course_id = 'edX/toy/2012_Fall' self.course_id_2 = 'edx/full/6.002_Spring_2012' - #self.toy_course = modulestore().get_course(self.course_id) - # Problem: Classes persist, need to delete stuff from modulestore # is a CourseDescriptor object? self.course = CourseFactory.create() # is a sequence descriptor @@ -66,7 +66,7 @@ class ViewsTestCase(TestCase): def tearDown(self): _MODULESTORES = {} modulestore().collection.drop() - assert False + #assert False def test_has_access(self): user = MagicMock(is_staff = True, is_active = True, is_authenticated = True) @@ -196,18 +196,121 @@ class ViewsTestCase(TestCase): print self.course.xmodule_constructor(system) print self.course.xmodule_constructor(system).func print self.course.xmodule_constructor(system).keywords + print dir(self.course.xmodule_constructor(system).func) def test__xmodule_recurse(self): - raise SkipTest -## mock_item = MagicMock() -## mock_item.get_children.return_value = [] + #There shouldn't be a difference, but the code works with defined + # function f but not with lambda functions + mock_item = MagicMock() + mock_item.get_children.return_value = [] s = Stub() s.children.append(Stub()) - views._xmodule_recurse(s, lambda x: return) - #views._xmodule_recurse(s, lambda x: x.n += 1) + views._xmodule_recurse(s, f) self.assertEquals(s.n, 1) self.assertEquals(s.children[0].n, 1) + + def test_get_module_previews(self): + # needs a working render_to_string + raise SkipTest + self.request = RequestFactory().get('foo') + self.request.user = UserFactory() + self.request.session = {} + print views.get_module_previews(self.request, self.course) + + def test_delete_item(self): + # If user doesn't have permission, redirect + self.no_permit_user = MagicMock(is_staff = False, is_active = False) + self.no_permit_user.is_authenticated.return_value = True + self.request = RequestFactory().post('i4x://MITx/999/course/Robot_Super_Course') + self.request.POST = self.request.POST.copy() + self.request.POST.update({'id':'i4x://MITx/999/course/Robot_Super_Course'}) + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.delete_item, self.request) + # Should return an HttpResponse + self.permit_user =MagicMock(is_staff = True, is_active = True) + self.permit_user.is_authenticated.return_value = True + self.request_2 = RequestFactory().post(self.item.location.url()) + self.request_2.POST = self.request_2.POST.copy() + self.request_2.POST.update({'id':self.item.location.url()}) + self.request_2.user = self.permit_user + response = views.delete_item(self.request_2) + self.assertIsInstance(response, HttpResponse) + self.assertEquals(modulestore().get_items(self.item.location.url()), []) + # Set delete_children to True to delete all children + # Create children + self.item_2 = ItemFactory.create() + child_item = ItemFactory.create() +## print type(self.item_2) +## print self.item_2.__dict__ + # Is there better way of adding children? What format are children in? + self.item_2.definition['children'] = [child_item.location.url()] + self.request_3 = RequestFactory().post(self.item_2.location.url()) + self.request_3.POST = self.request_3.POST.copy() + self.request_3.POST.update({'id':self.item_2.location.url(), + 'delete_children':True, + 'delete_all_versions':True}) + self.request_3.user = self.permit_user + print self.item_2.get_children() + self.assertIsInstance(views.delete_item(self.request_3), HttpResponse) + self.assertEquals(modulestore().get_items(self.item_2.location.url()), []) + # Problem: Function doesn't delete child item? + # child_item can be manually deleted, but can't delete it using function + # Not sure if problem with _xmodule_recurse and lambda functions + #store = views.get_modulestore(child_item.location.url()) + #store.delete_item(child_item.location) + self.assertEquals(modulestore().get_items(child_item.location.url()), []) + # Check delete_item on 'vertical' + self.item_3 = ItemFactory.create(template = 'i4x://edx/templates/vertical/Empty') + self.request_4 = RequestFactory().post(self.item_3.location.url()) + self.request_4.POST = self.request_4.POST.copy() + self.request_4.POST.update({'id':self.item_3.location.url(), + 'delete_children':True, + 'delete_all_versions':True}) + self.request_4.user = self.permit_user + self.assertIsInstance(views.delete_item(self.request_4), HttpResponse) + self.assertEquals(modulestore().get_items(self.item_3.location.url()), []) + + def test_save_item(self): + # Test that user with no permissions gets redirected + self.no_permit_user = MagicMock(is_staff = False, is_active = False) + self.no_permit_user.is_authenticated.return_value = True + self.request = RequestFactory().post(self.item.location.url()) + self.request.POST = self.request.POST.copy() + self.request.POST.update({'id':self.item.location.url()}) + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.save_item, self.request) + # Test user with permissions but nothing in request.POST + self.item_2 = ItemFactory.create() + self.permit_user =MagicMock(is_staff = True, is_active = True) + self.permit_user.is_authenticated.return_value = True + self.request_2 = RequestFactory().post(self.item_2.location.url()) + self.request_2.POST = self.request.POST.copy() + self.request_2.POST.update({'id':self.item_2.location.url()}) + self.request_2.user = self.permit_user + self.assertIsInstance(views.save_item(self.request_2), HttpResponse) + # Test updating data + self.request_3 = RequestFactory().post(self.item_2.location.url()) + self.request_3.POST = self.request.POST.copy() + self.request_3.POST.update({'id':self.item_2.location.url(), + 'data':{'foo':'bar'}}) + self.request_3.user = self.permit_user + self.assertIsInstance(views.save_item(self.request_3), HttpResponse) + self.assertEquals(modulestore().get_item(self.item_2.location.dict()).definition['data'], + {u'foo': u'bar'}) + # Test metadata, which is a dictionary? + self.request_4 = RequestFactory().post(self.item_2.location.url()) + self.request_4.POST = self.request.POST.copy() + self.request_4.POST.update({'id':self.item_2.location.url(), + 'metadata':{'foo':'bar'}}) + self.request_4.user = self.permit_user + self.assertIsInstance(views.save_item(self.request_4), HttpResponse) + self.assertEquals(modulestore().get_item(self.item_2.location.dict()).metadata['foo'], + 'bar') + +def f(x): + x.n += 1 + class Stub(): def __init__(self): self.n = 0 From 7908d87902d0e89faa674eb9383c6ecb00e10f6f Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Thu, 31 Jan 2013 15:58:14 -0500 Subject: [PATCH 021/214] more tests for views.py --- .../contentstore/tests/test_views.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index 9fa16ad4b2..d5a15a10e3 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -6,6 +6,7 @@ import unittest from nose.tools import set_trace from nose.plugins.skip import SkipTest from collections import defaultdict +import re from django.http import Http404, HttpResponse, HttpRequest, HttpResponseRedirect, HttpResponseBadRequest from django.conf import settings @@ -24,6 +25,7 @@ from xmodule.modulestore import Location from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorModule from contentstore.utils import get_course_for_item +from xmodule.templates import update_templates class Stub(): pass @@ -50,12 +52,14 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) class ViewsTestCase(TestCase): def setUp(self): - #modulestore().collection.drop() + self._MODULESTORES = {} + modulestore().collection.drop() + update_templates() self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] self.location_2 = ['i4x', 'edX', 'full', 'course', '6.002_Spring_2012'] self.location_3 = ['i4x', 'MITx', '999', 'course', 'Robot_Super_Course'] # empty Modulestore - self._MODULESTORES = {} + self.course_id = 'edX/toy/2012_Fall' self.course_id_2 = 'edx/full/6.002_Spring_2012' # is a CourseDescriptor object? @@ -210,6 +214,7 @@ class ViewsTestCase(TestCase): self.assertEquals(s.children[0].n, 1) def test_get_module_previews(self): + raise SkipTest # needs a working render_to_string raise SkipTest self.request = RequestFactory().get('foo') @@ -218,6 +223,7 @@ class ViewsTestCase(TestCase): print views.get_module_previews(self.request, self.course) def test_delete_item(self): + raise SkipTest # If user doesn't have permission, redirect self.no_permit_user = MagicMock(is_staff = False, is_active = False) self.no_permit_user.is_authenticated.return_value = True @@ -297,7 +303,7 @@ class ViewsTestCase(TestCase): self.assertIsInstance(views.save_item(self.request_3), HttpResponse) self.assertEquals(modulestore().get_item(self.item_2.location.dict()).definition['data'], {u'foo': u'bar'}) - # Test metadata, which is a dictionary? + # Test updating metadata self.request_4 = RequestFactory().post(self.item_2.location.url()) self.request_4.POST = self.request.POST.copy() self.request_4.POST.update({'id':self.item_2.location.url(), @@ -306,7 +312,25 @@ class ViewsTestCase(TestCase): self.assertIsInstance(views.save_item(self.request_4), HttpResponse) self.assertEquals(modulestore().get_item(self.item_2.location.dict()).metadata['foo'], 'bar') - + + def test_clone_item(self): + # Test that user with no permissions gets redirected + self.no_permit_user = MagicMock(is_staff = False, is_active = False) + self.no_permit_user.is_authenticated.return_value = True + self.request = RequestFactory().post(self.item.location.url()) + self.request.POST = self.request.POST.copy() + self.request.POST.update({'id':self.item.location.url(), + 'parent_location':self.course.location.url(), + 'template':self.location_3, + 'display_name':'bar'}) + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.clone_item, self.request) + self.permit_user = MagicMock(is_staff = True, is_active = True) + self.permit_user.is_authenticated.return_value = True + self.request.user = self.permit_user + response = views.clone_item(self.request) + self.assertIsInstance(response, HttpResponse) + self.assertRegexpMatches(response.content, '{"id": "i4x://MITx/999/course/') def f(x): x.n += 1 From 48bb447fbde50916c14bc3179e368c191b5d0f33 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 31 Jan 2013 18:42:11 -0500 Subject: [PATCH 022/214] Adding basic annotatable module and related files. --- common/lib/xmodule/setup.py | 3 +- .../lib/xmodule/xmodule/annotatable_module.py | 128 ++++++++++++++++++ .../xmodule/css/annotatable/display.scss | 63 +++++++++ .../xmodule/js/src/annotatable/display.coffee | 9 ++ lms/templates/annotatable.html | 23 ++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/annotatable_module.py create mode 100644 common/lib/xmodule/xmodule/css/annotatable/display.scss create mode 100644 common/lib/xmodule/xmodule/js/src/annotatable/display.coffee create mode 100644 lms/templates/annotatable.html diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 29227c3188..a2d9b3e4df 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -40,7 +40,8 @@ setup( "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor", - "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor" + "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", + "annotatable = xmodule.annotatable_module:AnnotatableDescriptor" ] } ) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py new file mode 100644 index 0000000000..bf76d7fc8c --- /dev/null +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -0,0 +1,128 @@ +import json +import logging +import re + +from lxml import etree +from pkg_resources import resource_string, resource_listdir + +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor +from xmodule.modulestore.mongo import MongoModuleStore +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.content import StaticContent + +import datetime +import time + +log = logging.getLogger(__name__) + +class AnnotatableModule(XModule): + # Note: js and css in common/lib/xmodule/xmodule + js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), + resource_string(__name__, 'js/src/collapsible.coffee'), + resource_string(__name__, 'js/src/html/display.coffee'), + resource_string(__name__, 'js/src/annotatable/display.coffee')], + 'js': [] + } + js_module_name = "Annotatable" + css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]} + + def _is_span(self, element): + """ Returns true if the element is a valid annotation span, false otherwise. """ + return element.tag == 'span' and element.get('class') == 'annotatable' + + def _is_span_container(self, element): + """ Returns true if the element is a valid span contanier, false otherwise. """ + return element.tag == 'p' # Assume content is in paragraph form (for now...) + + def _iterspans(self, xmltree, callbacks): + """ Iterates over span elements and invokes each callback on the span. """ + + index = 0 + for element in xmltree.iter('span'): + if self._is_span(element): + for callback in callbacks: + callback(element, index, xmltree) + index += 1 + + def _get_span_container(self, span): + """ Returns the first container element of the span. + The intent is to add the discussion widgets at the + end of the container, not interspersed with the text. """ + + container = None + for parent in span.iterancestors(): + if self._is_span_container(parent): + container = parent + break + + if container is None: + return parent + return container + + def _attach_discussion(self, span, index, xmltree): + """ Attaches a discussion thread to the annotation span. """ + + tpl = u'
' + tpl += '
' + tpl += 'Guided Discussion: ' + tpl += '{1}' + tpl += 'Show Discussion' + tpl += '
' + + span_id = 'span-{0}'.format(index) # How should we anchor spans? + span.set('data-span-id', span_id) + + discussion_id = 'discussion-{0}'.format(index) # How do we get a real discussion ID? + discussion_title = 'Thread Title {0}'.format(index) # How do we get the discussion Title? + discussion_html = tpl.format(discussion_id, discussion_title) + discussion = etree.fromstring(discussion_html) + + span_container = self._get_span_container(span) + span_container.append(discussion) + + self.discussion_for[span_id] = discussion_id + + def _add_icon(self, span, index, xmltree): + """ Adds an icon to the annotation span. """ + + span_icon = etree.Element('span', { 'class': 'annotatable-icon'} ) + span_icon.text = ''; + span_icon.tail = span.text + span.text = '' + span.insert(0, span_icon) + + def _render(self): + """ Renders annotatable content by transforming spans and adding discussions. """ + + xmltree = etree.fromstring(self.content) + self._iterspans(xmltree, [ self._add_icon, self._attach_discussion ]) + return etree.tostring(xmltree) + + def get_html(self): + """ Renders parameters to template. """ + + context = { + 'display_name': self.display_name, + 'element_id': self.element_id, + 'html_content': self._render(), + 'json_discussion_for': json.dumps(self.discussion_for) + } + + # template dir: lms/templates + return self.system.render_template('annotatable.html', context) + + def __init__(self, system, location, definition, descriptor, + instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, descriptor, + instance_state, shared_state, **kwargs) + + self.element_id = self.location.html_id(); + self.content = self.definition['data'] + self.discussion_for = {} # Maps spans to discussions by id (for JS) + + +class AnnotatableDescriptor(RawDescriptor): + module_class = AnnotatableModule + stores_state = True + template_dir_name = "annotatable" diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss new file mode 100644 index 0000000000..9b6404ceb8 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -0,0 +1,63 @@ +.annotatable-header { + border: 1px solid $border-color; + border-radius: 3px; + margin-bottom: 1em; + padding: 2px 4px; + position: relative; + + .annotatable-title { + font-size: em(18); + text-transform: uppercase; + } + .annotatable-description { + font-size: $body-font-size; + } +} + + +span.annotatable { + color: $blue; + .annotatable-icon { + margin: auto 2px auto 4px; + } +} + +.annotatable-icon { + display: inline-block; + vertical-align: middle; + width: 16px; + height: 17px; + background: url(../images/link-icon.png) no-repeat; +} + +.help-icon { + display: block; + position: absolute; + right: 0; + top: 33%; + width: 16px; + height: 17px; + margin: 0 7px 0 0; + background: url(../images/info-icon.png) no-repeat; +} + +.annotatable-discussion { + display: block; + border: 1px solid $border-color; + border-radius: 3px; + margin: 1em 0; + position: relative; + padding: 4px; + + .annotatable-discussion-label { + font-weight: bold; + } + .annotatable-icon { + margin: auto 4px auto 0px; + } + .annotatable-show-discussion { + position: absolute; + right: 8px; + margin-top: 4px; + } +} diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee new file mode 100644 index 0000000000..1db6ac2f6b --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -0,0 +1,9 @@ +class @Annotatable + constructor: (el) -> + console.log "loaded Annotatable" + $(el).find(".annotatable").on "click", (e) -> + data = $(".annotatable-wrapper", el).data("spans") + span_id = e.target.getAttribute("data-span-id") + msg = "annotatable span clicked. discuss span [" + span_id + "] in discussion [" + data[span_id] + "]" + console.log data + window.alert msg diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html new file mode 100644 index 0000000000..3df0a59921 --- /dev/null +++ b/lms/templates/annotatable.html @@ -0,0 +1,23 @@ +
+ +
+
+ % if display_name is not UNDEFINED and display_name is not None: +
${display_name}
+ % endif +
Annotated Reading + Guided Discussion
+
+ +
+ ${html_content} +
+ + + +
\ No newline at end of file From 8f005a5a1fd543fb0f8162ef572c624bf54014e5 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Fri, 1 Feb 2013 13:31:35 -0500 Subject: [PATCH 023/214] Moved discussion html into its own method, should probably be in a template. --- .../lib/xmodule/xmodule/annotatable_module.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index bf76d7fc8c..1036410320 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -59,9 +59,9 @@ class AnnotatableModule(XModule): if container is None: return parent return container - - def _attach_discussion(self, span, index, xmltree): - """ Attaches a discussion thread to the annotation span. """ + + def _get_discussion_html(self, discussion_id, discussion_title): + """ Returns html to display the discussion thread """ tpl = u'
' tpl += '
' @@ -69,17 +69,22 @@ class AnnotatableModule(XModule): tpl += '{1}' tpl += 'Show Discussion' tpl += '
' + + return tpl.format(discussion_id, discussion_title) + + def _attach_discussion(self, span, index, xmltree): + """ Attaches a discussion thread to the annotation span. """ span_id = 'span-{0}'.format(index) # How should we anchor spans? span.set('data-span-id', span_id) discussion_id = 'discussion-{0}'.format(index) # How do we get a real discussion ID? discussion_title = 'Thread Title {0}'.format(index) # How do we get the discussion Title? - discussion_html = tpl.format(discussion_id, discussion_title) - discussion = etree.fromstring(discussion_html) + discussion_html = self._get_discussion_html(discussion_id, discussion_title) + discussion_xmltree = etree.fromstring(discussion_html) span_container = self._get_span_container(span) - span_container.append(discussion) + span_container.append(discussion_xmltree) self.discussion_for[span_id] = discussion_id From c144b2519a6cad068e88df0f4c50e0937d036b59 Mon Sep 17 00:00:00 2001 From: Deena Wang Date: Fri, 1 Feb 2013 15:57:11 -0500 Subject: [PATCH 024/214] more tests in test_views.py --- .../contentstore/tests/factories.py | 53 ++- .../contentstore/tests/test_views.py | 369 +++++++++++++++++- 2 files changed, 401 insertions(+), 21 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py index cb9f451d38..ab83c52438 100644 --- a/cms/djangoapps/contentstore/tests/factories.py +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -1,10 +1,17 @@ from factory import Factory -from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore +from datetime import datetime +import uuid from time import gmtime from uuid import uuid4 -from xmodule.timeparse import stringify_time +from django.contrib.auth.models import Group + +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from xmodule.timeparse import stringify_time +from student.models import (User, UserProfile, Registration, + CourseEnrollmentAllowed) +from django.contrib.auth.models import Group def XMODULE_COURSE_CREATION(class_to_create, **kwargs): return XModuleCourseFactory._create(class_to_create, **kwargs) @@ -114,4 +121,42 @@ class ItemFactory(XModuleItemFactory): parent_location = 'i4x://MITx/999/course/Robot_Super_Course' template = 'i4x://edx/templates/chapter/Empty' - display_name = 'Section One' \ No newline at end of file + display_name = 'Section One' + +class UserProfileFactory(Factory): + FACTORY_FOR = UserProfile + + user = None + name = 'Robot Studio' + courseware = 'course.xml' + +class RegistrationFactory(Factory): + FACTORY_FOR = Registration + + user = None + activation_key = uuid.uuid4().hex + +class UserFactory(Factory): + FACTORY_FOR = User + + username = 'robot' + email = 'robot@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Tester' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime.now() + date_joined = datetime.now() + +class GroupFactory(Factory): + FACTORY_FOR = Group + + name = 'test_group' + +class CourseEnrollmentAllowedFactory(Factory): + FACTORY_FOR = CourseEnrollmentAllowed + + email = 'test@edx.org' + course_id = 'edX/test/2012_Fall' diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py index ae6548bd9d..f4f7fcd964 100644 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ b/cms/djangoapps/contentstore/tests/test_views.py @@ -5,19 +5,32 @@ import factory import unittest from nose.tools import set_trace from nose.plugins.skip import SkipTest +from collections import defaultdict +import re -from django.http import Http404, HttpResponse, HttpRequest +from django.http import (Http404, HttpResponse, HttpRequest, + HttpResponseRedirect, HttpResponseBadRequest, + HttpResponseForbidden) from django.conf import settings from django.contrib.auth.models import User -from django.test.client import Client -from django.conf import settings +from django.test.client import Client, RequestFactory from django.test import TestCase -from django.test.client import RequestFactory +from django.core.exceptions import PermissionDenied from override_settings import override_settings from xmodule.modulestore.django import modulestore, _MODULESTORES +from xmodule.modulestore import Location +from xmodule.x_module import ModuleSystem +from xmodule.error_module import ErrorModule +from xmodule.seq_module import SequenceModule +from xmodule.templates import update_templates +from contentstore.utils import get_course_for_item +from contentstore.tests.factories import UserFactory +from contentstore.tests.factories import CourseFactory, ItemFactory import contentstore.views as views +class Stub(): + pass def xml_store_config(data_dir): return { @@ -30,25 +43,347 @@ def xml_store_config(data_dir): } } -class UserFactory(factory.Factory): - first_name = 'Test' - last_name = 'Robot' - is_staff = True - is_active = True - TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class ViewsTestCase(TestCase): def setUp(self): - self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] + # empty Modulestore self._MODULESTORES = {} + modulestore().collection.drop() + update_templates() + self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] + self.location_2 = ['i4x', 'edX', 'full', 'course', '6.002_Spring_2012'] + self.location_3 = ['i4x', 'MITx', '999', 'course', 'Robot_Super_Course'] self.course_id = 'edX/toy/2012_Fall' - self.toy_course = modulestore().get_course(self.course_id) + self.course_id_2 = 'edx/full/6.002_Spring_2012' + # is a CourseDescriptor object? + self.course = CourseFactory.create() + # is a sequence descriptor + self.item = ItemFactory.create(template = 'i4x://edx/templates/sequential/Empty') + self.no_permit_user = UserFactory() + self.permit_user = UserFactory(is_staff = True, username = 'Wizardly Herbert') + def tearDown(self): + _MODULESTORES = {} + modulestore().collection.drop() + assert False + def test_has_access(self): - user = UserFactory() - user.is_authenticated = True - set_trace() - self.assertTrue(views.has_access(user, self.location)) + self.assertTrue(views.has_access(self.permit_user, self.location_2)) + self.assertFalse(views.has_access(self.no_permit_user, self.location_2)) + # done + + def test_course_index(self): + request = RequestFactory().get('foo') + request.user = self.no_permit_user + # Redirects if request.user doesn't have access to location + self.assertRaises(PermissionDenied, views.course_index, request, 'edX', + 'full', '6.002_Spring_2012') + request_2 = RequestFactory().get('foo') + request.user = self.permit_user + # Doesn't work unless we figure out render_to_response +## views.course_index(request_2, 'MITx', +## '999', 'Robot_Super_Course') + + def test_edit_subsection(self): + # Redirects if request.user doesn't have access to location + self.request = RequestFactory().get('foo') + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.edit_subsection, self.request, + self.location_2) + # If location isn't for a "sequential", return Bad Request + self.request_2 = RequestFactory().get('foo') + self.request_2.user = self.permit_user + self.assertIsInstance(views.edit_subsection(self.request_2, + self.location_3), HttpResponseBadRequest) + # Need render_to_response + #views.edit_subsection(self.request_2, self.item.location) + + def test_edit_unit(self): + raise SkipTest + # if user doesn't have access, should redirect + self.request = RequestFactory().get('foo') + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.edit_unit, self.request, + self.location_2) + self.request_2 = RequestFactory().get('foo') + self.request_2.user = self.permit_user + # Problem: no parent locations, so IndexError + #print modulestore().get_parent_locations(self.location_3, None) + views.edit_unit(self.request_2, self.location_3) + # Needs render_to_response + + def test_assignment_type_update(self): + raise SkipTest + # If user doesn't have access, should return HttpResponseForbidden() + self.request = RequestFactory().get('foo') + self.request.user = self.no_permit_user + self.assertIsInstance(views.assignment_type_update(self.request, + 'MITx', '999', 'course', 'Robot_Super_Course'), + HttpResponseForbidden) +## views.assignment_type_update(self.request, 'MITx', '999', 'course', 'Robot_Super_Course') + # if user has access, then should return HttpResponse + self.request.user = self.permit_user + get_response = views.assignment_type_update(self.request,'MITx', '999', + 'course', 'Robot_Super_Course') + self.assertIsInstance(get_response,HttpResponse) + get_response_string = '{"id": 99, "location": ["i4x", "MITx", "999", "course", "Robot_Super_Course", null], "graderType": "Not Graded"}' + self.assertEquals(get_response.content, get_response_string) + self.request_2 = RequestFactory().post('foo') + self.request_2.user = self.permit_user + post_response = views.assignment_type_update(self.request_2,'MITx', '999', + 'course', 'Robot_Super_Course') + self.assertIsInstance(post_response,HttpResponse) + self.assertEquals(post_response.content, 'null') + + def test_load_preview_state(self): + # Tests that function creates empty defaultdict when request.session + # is empty + # location cannot be a list or other mutable type + self.request = RequestFactory().get('foo') + self.request.session = {} + instance_state, shared_state = views.load_preview_state(self.request, + 'foo', 'bar') + self.assertIsNone(instance_state) + self.assertIsNone(shared_state) + # Done + + def test_save_preview_state(self): + self.request = RequestFactory().get('foo') + self.request.session = {} + loc = Location(self.location_3) + result = {'preview_states': + {('id', loc):{'instance':None, + 'shared':None, + } + } + } + views.save_preview_state(self.request, 'id', loc, None, None) + self.assertEquals(self.request.session, result) + # Done + + def test_get_preview_module(self): + self.request = RequestFactory().get('foo') + self.request.user = self.permit_user + self.request.session = {} + module = views.get_preview_module(self.request, 'id', self.course) + self.assertIsInstance(module, SequenceModule) + # Done + + def test_preview_module_system(self): + # Returns a ModuleSystem + self.request = RequestFactory().get('foo') + self.request.user = self.no_permit_user + self.assertIsInstance(views.preview_module_system(self.request, + 'id', self.course), + ModuleSystem) + # done + + def test_load_preview_module(self): + # if error in getting module, return ErrorModule + self.request = RequestFactory().get('foo') + self.request.user = self.no_permit_user + self.request.session = {} + self.assertIsInstance(views.load_preview_module(self.request, 'id', + self.course, 'instance', 'shared'), + ErrorModule) + instance_state, shared_state = self.course.get_sample_state()[0] + module = views.load_preview_module(self.request,'id', self.course, + instance_state, shared_state) + self.assertIsInstance(module, SequenceModule) + # I'd like to test module.get_html, but it relies on render_to_string + # Test static_tab + self.course_2 = CourseFactory(display_name = 'Intro_to_intros', location = Location('i4x', 'MITx', '666', 'static_tab', 'Intro_to_intros')) + module_2 = views.load_preview_module(self.request,'id', self.course_2, + instance_state, shared_state) + self.assertIsInstance(module, SequenceModule) + # needs render_to_string + + def test__xmodule_recurse(self): + #There shouldn't be a difference, but the code works with defined + # function f but not with lambda functions + mock_item = MagicMock() + mock_item.get_children.return_value = [] + s = Stub() + s.children.append(Stub()) + views._xmodule_recurse(s, f) + self.assertEquals(s.n, 1) + self.assertEquals(s.children[0].n, 1) + + def test_get_module_previews(self): + raise SkipTest + # needs a working render_to_string + self.request = RequestFactory().get('foo') + self.request.user = UserFactory() + self.request.session = {} + print views.get_module_previews(self.request, self.course) + + def test_delete_item(self): + raise SkipTest + # If user doesn't have permission, redirect + self.request = RequestFactory().post('i4x://MITx/999/course/Robot_Super_Course') + self.request.POST = self.request.POST.copy() + self.request.POST.update({'id':'i4x://MITx/999/course/Robot_Super_Course'}) + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.delete_item, self.request) + # Should return an HttpResponse + self.request_2 = RequestFactory().post(self.item.location.url()) + self.request_2.POST = self.request_2.POST.copy() + self.request_2.POST.update({'id':self.item.location.url()}) + self.request_2.user = self.permit_user + response = views.delete_item(self.request_2) + self.assertIsInstance(response, HttpResponse) + self.assertEquals(modulestore().get_items(self.item.location.url()), []) + # Set delete_children to True to delete all children + # Create children + self.item_2 = ItemFactory.create() + child_item = ItemFactory.create() +## print type(self.item_2) +## print self.item_2.__dict__ + # Is there better way of adding children? What format are children in? + self.item_2.definition['children'] = [child_item.location.url()] + self.request_3 = RequestFactory().post(self.item_2.location.url()) + self.request_3.POST = self.request_3.POST.copy() + self.request_3.POST.update({'id':self.item_2.location.url(), + 'delete_children':True, + 'delete_all_versions':True}) + self.request_3.user = self.permit_user + print self.item_2.get_children() + self.assertIsInstance(views.delete_item(self.request_3), HttpResponse) + self.assertEquals(modulestore().get_items(self.item_2.location.url()), []) + # Problem: Function doesn't delete child item? + # child_item can be manually deleted, but can't delete it using function + # Not sure if problem with _xmodule_recurse and lambda functions + #store = views.get_modulestore(child_item.location.url()) + #store.delete_item(child_item.location) + self.assertEquals(modulestore().get_items(child_item.location.url()), []) + + # Check delete_item on 'vertical' + self.item_3 = ItemFactory.create(template = 'i4x://edx/templates/vertical/Empty') + self.request_4 = RequestFactory().post(self.item_3.location.url()) + self.request_4.POST = self.request_4.POST.copy() + self.request_4.POST.update({'id':self.item_3.location.url(), + 'delete_children':True, + 'delete_all_versions':True}) + self.request_4.user = self.permit_user + self.assertIsInstance(views.delete_item(self.request_4), HttpResponse) + self.assertEquals(modulestore().get_items(self.item_3.location.url()), []) + + def test_save_item(self): + # Test that user with no permissions gets redirected + self.request = RequestFactory().post(self.item.location.url()) + self.request.POST = self.request.POST.copy() + self.request.POST.update({'id':self.item.location.url()}) + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.save_item, self.request) + # Test user with permissions but nothing in request.POST + self.item_2 = ItemFactory.create() + self.request_2 = RequestFactory().post(self.item_2.location.url()) + self.request_2.POST = self.request.POST.copy() + self.request_2.POST.update({'id':self.item_2.location.url()}) + self.request_2.user = self.permit_user + self.assertIsInstance(views.save_item(self.request_2), HttpResponse) + # Test updating data + self.request_3 = RequestFactory().post(self.item_2.location.url()) + self.request_3.POST = self.request.POST.copy() + self.request_3.POST.update({'id':self.item_2.location.url(), + 'data':{'foo':'bar'}}) + self.request_3.user = self.permit_user + self.assertIsInstance(views.save_item(self.request_3), HttpResponse) + self.assertEquals(modulestore().get_item(self.item_2.location.dict()).definition['data'], + {u'foo': u'bar'}) + # Test updating metadata + self.request_4 = RequestFactory().post(self.item_2.location.url()) + self.request_4.POST = self.request.POST.copy() + self.request_4.POST.update({'id':self.item_2.location.url(), + 'metadata':{'foo':'bar'}}) + self.request_4.user = self.permit_user + self.assertIsInstance(views.save_item(self.request_4), HttpResponse) + self.assertEquals(modulestore().get_item(self.item_2.location.dict()).metadata['foo'], + 'bar') + #done + + def test_clone_item(self): + # Test that user with no permissions gets redirected + self.request = RequestFactory().post(self.item.location.url()) + self.request.POST = self.request.POST.copy() + self.request.POST.update({'id':self.item.location.url(), + 'parent_location':self.course.location.url(), + 'template':self.location_3, + 'display_name':'bar'}) + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.clone_item, self.request) + self.request.user = self.permit_user + response = views.clone_item(self.request) + self.assertIsInstance(response, HttpResponse) + self.assertRegexpMatches(response.content, '{"id": "i4x://MITx/999/course/') + # Done + + def test_upload_asset(self): + # Test get request + self.request = RequestFactory().get('foo') + self.assertIsInstance(views.upload_asset(self.request,'org', 'course', + 'coursename'), HttpResponseBadRequest) + # Test no permissions + self.request_2 = RequestFactory().post('foo') + self.request_2.user = self.no_permit_user + self.assertIsInstance(views.upload_asset(self.request_2, 'MITx', '999', + 'Robot_Super_Course'), HttpResponseForbidden) + # Test if course exists + + self.request_3 = RequestFactory().post('foo') + self.request_3.user = self.permit_user + # Throws error because of improperly formatted log +## self.assertIsInstance(views.upload_asset(self.request_3,'org', 'course', +## 'coursename'),HttpResponseBadRequest) + # Test response with fake file attached + # Not sure how to create fake file for testing purposes because + # can't override request.FILES +## print self.request_3.FILES +## print type(self.request_3.FILES) +## f = open('file.txt') +## self.request_4 = RequestFactory().post('foo', f) +## print self.request_3.FILES +## mock_file = MagicMock(name = 'Secrets', content_type = 'foo') +## mock_file.read.return_value = 'stuff' +## file_dict = {'file':mock_file} +## self.request_3.FILES = file_dict +## print views.upload_asset(self.request_3, 'MITx', '999', +## 'Robot_Super_Course') + + def test_manage_users(self): + self.request = RequestFactory().get('foo') + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.manage_users, self.request, + self.location_3) + # Needs render_to_response + + def test_create_json_response(self): + ok_response = views.create_json_response() + self.assertIsInstance(ok_response, HttpResponse) + self.assertEquals(ok_response.content, '{"Status": "OK"}') + bad_response = views.create_json_response('Spacetime collapsing') + self.assertIsInstance(bad_response, HttpResponse) + self.assertEquals(bad_response.content, '{"Status": "Failed", "ErrMsg": "Spacetime collapsing"}') + + def test_reorder_static_tabs(self): + self.request = RequestFactory().get('foo') + self.request.POST = {'tabs':[self.location_3]} + self.request.user = self.no_permit_user + self.assertRaises(PermissionDenied, views.reorder_static_tabs, self.request) + self.request.user = self.permit_user + self.assertIsInstance(views.reorder_static_tabs(self.request), + HttpResponseBadRequest) + # to be continued ... + +def f(x): + x.n += 1 + +class Stub(): + def __init__(self): + self.n = 0 + self.children = [] + def get_children(self): + return self.children + From 0ca7131974b716eba2d6876936de5b375b4768ab Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Fri, 1 Feb 2013 16:08:23 -0500 Subject: [PATCH 025/214] Clicking on annotation span should scroll to the discussion widget. --- .../lib/xmodule/xmodule/annotatable_module.py | 1 + .../xmodule/css/annotatable/display.scss | 2 +- .../xmodule/js/src/annotatable/display.coffee | 34 +++++++++++++++---- lms/templates/annotatable.html | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 1036410320..8d7dc5f917 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -26,6 +26,7 @@ class AnnotatableModule(XModule): } js_module_name = "Annotatable" css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]} + icon_class = 'annotatable' def _is_span(self, element): """ Returns true if the element is a valid annotation span, false otherwise. """ diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 9b6404ceb8..9bfa031bba 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -14,9 +14,9 @@ } } - span.annotatable { color: $blue; + cursor: pointer; .annotatable-icon { margin: auto 2px auto 4px; } diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 1db6ac2f6b..1c750fba7d 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -1,9 +1,29 @@ class @Annotatable + @_debug: true constructor: (el) -> - console.log "loaded Annotatable" - $(el).find(".annotatable").on "click", (e) -> - data = $(".annotatable-wrapper", el).data("spans") - span_id = e.target.getAttribute("data-span-id") - msg = "annotatable span clicked. discuss span [" + span_id + "] in discussion [" + data[span_id] + "]" - console.log data - window.alert msg + console.log 'loaded Annotatable' if @_debug + @el = el + @spandata = $('.annotatable-wrapper', @el).data "spans" + @initSpans() + + initSpans: () -> + selector = 'span.annotatable[data-span-id]' + $(@el).find(selector).on 'click', (e) => + @onClickSpan.call this, e + + onClickSpan: (e) -> + span_id = e.target.getAttribute('data-span-id') + discussion_id = @spandata[span_id] + selector = '.annotatable-discussion[data-discussion-id="'+discussion_id+'"]'; + $discussion = $(selector, @el) + padding = 20 + top = $discussion.offset().top - padding + highlighted = false + complete = () -> + if !highlighted + $discussion.effect('highlight', {}, 1000) + highlighted = true + + $('html, body').animate({ + scrollTop: top, + }, 1000, 'swing', complete) diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index 3df0a59921..c88a7eebb6 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -20,4 +20,4 @@ $(function() { }); - \ No newline at end of file + From 435fd05ef46e9dffdebd55cb033a71401b94b5f7 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 4 Feb 2013 18:25:09 -0500 Subject: [PATCH 026/214] Refactored the coffeescript behavior so it is a bit more testable. --- .../xmodule/css/annotatable/display.scss | 4 + .../xmodule/js/src/annotatable/display.coffee | 85 ++++++++++++++----- lms/templates/annotatable.html | 6 +- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 9bfa031bba..3c5437dac7 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -60,4 +60,8 @@ span.annotatable { right: 8px; margin-top: 4px; } + + &.opaque { + opacity: 0.4; + } } diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 1c750fba7d..31d213a613 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -1,29 +1,72 @@ class @Annotatable @_debug: true + + wrapperSelector: '.annotatable-wrapper' + spanSelector: 'span.annotatable[data-span-id]' + discussionSelector: '.annotatable-discussion[data-discussion-id]' + constructor: (el) -> console.log 'loaded Annotatable' if @_debug @el = el - @spandata = $('.annotatable-wrapper', @el).data "spans" - @initSpans() + @init() - initSpans: () -> - selector = 'span.annotatable[data-span-id]' - $(@el).find(selector).on 'click', (e) => - @onClickSpan.call this, e + init: () -> + @loadSpanData() + @initEvents() - onClickSpan: (e) -> - span_id = e.target.getAttribute('data-span-id') - discussion_id = @spandata[span_id] - selector = '.annotatable-discussion[data-discussion-id="'+discussion_id+'"]'; - $discussion = $(selector, @el) - padding = 20 - top = $discussion.offset().top - padding - highlighted = false - complete = () -> - if !highlighted - $discussion.effect('highlight', {}, 1000) - highlighted = true + initEvents: () -> + $(@wrapperSelector, @el).delegate(@spanSelector, { + 'click': @_bind @onSpanEvent @onClickSpan + 'mouseenter': @_bind @onSpanEvent @onEnterSpan + 'mouseleave': @_bind @onSpanEvent @onLeaveSpan + }) - $('html, body').animate({ - scrollTop: top, - }, 1000, 'swing', complete) + loadSpanData: () -> + @spandata = $(@wrapperSelector, @el).data('spans') + + getDiscussionId: (span_id) -> + @spandata[span_id] + + getDiscussionEl: (discussion_id) -> + $(@discussionSelector, @el).filter('[data-discussion-id="'+discussion_id+'"]') + + onSpanEvent: (fn) -> + (e) => + span_id = e.target.getAttribute('data-span-id') + discussion_id = @getDiscussionId(span_id) + span = { + id: span_id, + el: e.target + } + discussion = { + id: discussion_id, + el: @getDiscussionEl(discussion_id) + } + fn.call this, span, discussion + + onClickSpan: (span, discussion) -> + @scrollToDiscussion(discussion.el) + + onEnterSpan: (span, discussion) -> + $(@discussionSelector, @el).not(discussion.el).toggleClass('opaque', true) + + onLeaveSpan: (span, discussion) -> + $(@discussionSelector, @el).not(discussion.el).toggleClass('opaque', false) + + scrollToDiscussion: (el) -> + complete = @makeHighlighter(el) + top = el.offset().top - 20 # with some padding + + $('html, body').animate({ scrollTop: top }, 750, 'swing', complete) + + makeHighlighter: (el) -> + return @_once -> el.effect('highlight', {}, 750) + + _once: (fn) -> + done = false + return => + fn.call this unless done + done = true + + _bind: (fn) -> + return => fn.apply(this, arguments) diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index c88a7eebb6..fb0b36d9ee 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -1,4 +1,4 @@ -
+
@@ -14,9 +14,7 @@ From 3136d2b4c1e4aa78c83f9f640a4a09192451da3b Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 4 Feb 2013 18:53:37 -0500 Subject: [PATCH 027/214] Fixed target used for span event. --- .../xmodule/js/src/annotatable/display.coffee | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 31d213a613..47ba52673f 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -32,15 +32,17 @@ class @Annotatable onSpanEvent: (fn) -> (e) => - span_id = e.target.getAttribute('data-span-id') + span_el = e.currentTarget + span_id = span_el.getAttribute('data-span-id') discussion_id = @getDiscussionId(span_id) + discussion_el = @getDiscussionEl(discussion_id) span = { - id: span_id, - el: e.target + id: span_id + el: span_el } discussion = { - id: discussion_id, - el: @getDiscussionEl(discussion_id) + id: discussion_id + el: discussion_el } fn.call this, span, discussion @@ -48,10 +50,13 @@ class @Annotatable @scrollToDiscussion(discussion.el) onEnterSpan: (span, discussion) -> - $(@discussionSelector, @el).not(discussion.el).toggleClass('opaque', true) + @focusDiscussion(discussion.el, true) onLeaveSpan: (span, discussion) -> - $(@discussionSelector, @el).not(discussion.el).toggleClass('opaque', false) + @focusDiscussion(discussion.el, false) + + focusDiscussion: (el, state) -> + $(@discussionSelector, @el).not(el).toggleClass('opaque', state) scrollToDiscussion: (el) -> complete = @makeHighlighter(el) From 6b8edde601a5b89753a11879d37c82371cfe87fb Mon Sep 17 00:00:00 2001 From: John Hess Date: Mon, 4 Feb 2013 19:13:01 -0500 Subject: [PATCH 028/214] removed un-enroll all button for fear of disaster --- lms/djangoapps/instructor/views.py | 5 ----- lms/templates/courseware/instructor_dashboard.html | 1 - 2 files changed, 6 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index ddb31bf871..8a61d78b3b 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -494,11 +494,6 @@ def instructor_dashboard(request, course_id): msg += "Error! Failed to un-enroll student with email '%s'\n" % student msg += str(err) + '\n' - elif action == 'Un-enroll ALL students': - - ret = _do_enroll_students(course, course_id, '', overload=True) - datatable = ret['datatable'] - elif action == 'Enroll multiple students': students = request.POST.get('enroll_multiple','') diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 4d46505705..0eb10c9e02 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -237,7 +237,6 @@ function goto( mode)

Student Email: -


%if settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') and instructor_access: From 45e4c0cfac600e5ad0c15f79ec8fb0e641659790 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Feb 2013 12:37:58 -0500 Subject: [PATCH 029/214] Moved discussion thread html to its own template and only scroll when not in view. --- .../lib/xmodule/xmodule/annotatable_module.py | 14 ++++------ .../xmodule/js/src/annotatable/display.coffee | 27 ++++++++++++++++--- lms/templates/annotatable_discussion.html | 6 +++++ 3 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 lms/templates/annotatable_discussion.html diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 8d7dc5f917..5b26f4d953 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -63,15 +63,11 @@ class AnnotatableModule(XModule): def _get_discussion_html(self, discussion_id, discussion_title): """ Returns html to display the discussion thread """ - - tpl = u'
' - tpl += '
' - tpl += 'Guided Discussion: ' - tpl += '{1}' - tpl += 'Show Discussion' - tpl += '
' - - return tpl.format(discussion_id, discussion_title) + context = { + 'discussion_id': discussion_id, + 'discussion_title': discussion_title + } + return self.system.render_template('annotatable_discussion.html', context) def _attach_discussion(self, span, index, xmltree): """ Attaches a discussion thread to the annotation span. """ diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 47ba52673f..52feb686ba 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -59,13 +59,34 @@ class @Annotatable $(@discussionSelector, @el).not(el).toggleClass('opaque', state) scrollToDiscussion: (el) -> + padding = 20 complete = @makeHighlighter(el) - top = el.offset().top - 20 # with some padding + animOpts = { + scrollTop : el.offset().top - padding + } + + if @canScrollToDiscussion(el) + $('html, body').animate(animOpts, 500, 'swing', complete) + else + complete() - $('html, body').animate({ scrollTop: top }, 750, 'swing', complete) + canScrollToDiscussion: (el) -> + scrollTop = el.offset().top + docHeight = $(document).height() + winHeight = $(window).height() + winScrollTop = window.scrollY + + viewStart = winScrollTop + viewEnd = winScrollTop + (.75 * winHeight) + inView = viewStart < scrollTop < viewEnd + + scrollable = !inView + atDocEnd = viewStart + winHeight >= docHeight + + return (if atDocEnd then false else scrollable) makeHighlighter: (el) -> - return @_once -> el.effect('highlight', {}, 750) + return @_once -> el.effect('highlight', {}, 500) _once: (fn) -> done = false diff --git a/lms/templates/annotatable_discussion.html b/lms/templates/annotatable_discussion.html new file mode 100644 index 0000000000..1525cc7b6b --- /dev/null +++ b/lms/templates/annotatable_discussion.html @@ -0,0 +1,6 @@ +
+
+ Guided Discussion: + ${discussion_title} + Show Discussion +
From 2097b6933210acd8d63f7f5ec2ccac6b5aa74d66 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Feb 2013 13:10:23 -0500 Subject: [PATCH 030/214] Added button to show/hide guided notes and discussions. --- .../xmodule/xmodule/css/annotatable/display.scss | 15 +++++++++++++++ .../xmodule/js/src/annotatable/display.coffee | 13 ++++++++++++- lms/templates/annotatable.html | 1 + 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 3c5437dac7..9b2cbc3763 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -14,12 +14,24 @@ } } +.annotatable-toggle { + display: block; + font-size: $body-font-size; +} + span.annotatable { color: $blue; cursor: pointer; .annotatable-icon { margin: auto 2px auto 4px; } + &.hide { + cursor: none; + color: inherit; + .annotatable-icon { + display: none; + } + } } .annotatable-icon { @@ -64,4 +76,7 @@ span.annotatable { &.opaque { opacity: 0.4; } + &.hide { + display: none; + } } diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 52feb686ba..4a5e2e42d6 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -4,6 +4,7 @@ class @Annotatable wrapperSelector: '.annotatable-wrapper' spanSelector: 'span.annotatable[data-span-id]' discussionSelector: '.annotatable-discussion[data-discussion-id]' + toggleSelector: '.annotatable-toggle' constructor: (el) -> console.log 'loaded Annotatable' if @_debug @@ -11,10 +12,14 @@ class @Annotatable @init() init: () -> + @hideAnnotations = false + @spandata = {} @loadSpanData() @initEvents() initEvents: () -> + $(@toggleSelector, @el).bind('click', @_bind @onClickToggleAnnotations) + $(@wrapperSelector, @el).delegate(@spanSelector, { 'click': @_bind @onSpanEvent @onClickSpan 'mouseenter': @_bind @onSpanEvent @onEnterSpan @@ -30,6 +35,11 @@ class @Annotatable getDiscussionEl: (discussion_id) -> $(@discussionSelector, @el).filter('[data-discussion-id="'+discussion_id+'"]') + onClickToggleAnnotations: (e) -> + @hideAnnotations = !@hideAnnotations + $(@spanSelector, @el).add(@discussionSelector, @el).toggleClass('hide', @hideAnnotations) + $(@toggleSelector, @el).text(if @hideAnnotations then 'Show Annotations' else 'Hide Annotations') + onSpanEvent: (fn) -> (e) => span_el = e.currentTarget @@ -44,7 +54,8 @@ class @Annotatable id: discussion_id el: discussion_el } - fn.call this, span, discussion + if !@hideAnnotations + fn.call this, span, discussion onClickSpan: (span, discussion) -> @scrollToDiscussion(discussion.el) diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index fb0b36d9ee..78c2d5a97a 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -6,6 +6,7 @@
${display_name}
% endif
Annotated Reading + Guided Discussion
+ Hide Annotations
From 7ea87793c29ffe0a43f3bd901c92f300e2574393 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 7 Feb 2013 19:51:22 -0500 Subject: [PATCH 031/214] Modified annotatable to retrieve and display instructor comments from span elements. Instructor commentaries are displayed in tooltips with a reply link when the highlighted area is clicked. The commentaries are connected to inline discussion forums by anchors (for now). --- .../lib/xmodule/xmodule/annotatable_module.py | 95 +++++----- .../xmodule/css/annotatable/display.scss | 66 ++++--- .../xmodule/js/src/annotatable/display.coffee | 173 ++++++++---------- lms/templates/annotatable.html | 30 ++- 4 files changed, 173 insertions(+), 191 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 5b26f4d953..5b3cc15c77 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -32,10 +32,6 @@ class AnnotatableModule(XModule): """ Returns true if the element is a valid annotation span, false otherwise. """ return element.tag == 'span' and element.get('class') == 'annotatable' - def _is_span_container(self, element): - """ Returns true if the element is a valid span contanier, false otherwise. """ - return element.tag == 'p' # Assume content is in paragraph form (for now...) - def _iterspans(self, xmltree, callbacks): """ Iterates over span elements and invokes each callback on the span. """ @@ -45,60 +41,62 @@ class AnnotatableModule(XModule): for callback in callbacks: callback(element, index, xmltree) index += 1 + + def _set_span_data(self, span, index, xmltree): + """ Sets an ID and discussion anchor for the span. """ + + if 'anchor' in span.attrib: + span.set('data-discussion-anchor', span.get('anchor')) + del span.attrib['anchor'] - def _get_span_container(self, span): - """ Returns the first container element of the span. - The intent is to add the discussion widgets at the - end of the container, not interspersed with the text. """ - - container = None - for parent in span.iterancestors(): - if self._is_span_container(parent): - container = parent - break - - if container is None: - return parent - return container - - def _get_discussion_html(self, discussion_id, discussion_title): - """ Returns html to display the discussion thread """ - context = { - 'discussion_id': discussion_id, - 'discussion_title': discussion_title - } - return self.system.render_template('annotatable_discussion.html', context) - - def _attach_discussion(self, span, index, xmltree): - """ Attaches a discussion thread to the annotation span. """ - - span_id = 'span-{0}'.format(index) # How should we anchor spans? - span.set('data-span-id', span_id) - - discussion_id = 'discussion-{0}'.format(index) # How do we get a real discussion ID? - discussion_title = 'Thread Title {0}'.format(index) # How do we get the discussion Title? - discussion_html = self._get_discussion_html(discussion_id, discussion_title) - discussion_xmltree = etree.fromstring(discussion_html) - - span_container = self._get_span_container(span) - span_container.append(discussion_xmltree) - - self.discussion_for[span_id] = discussion_id - - def _add_icon(self, span, index, xmltree): - """ Adds an icon to the annotation span. """ + def _decorate_span(self, span, index, xmltree): + """ Decorates the span with an icon and highlight. """ + cls = ['annotatable', ] + marker = self._get_marker_color(span) + if marker is None: + cls.append('highlight-yellow') + else: + cls.append('highlight-'+marker) + + span.set('class', ' '.join(cls)) span_icon = etree.Element('span', { 'class': 'annotatable-icon'} ) span_icon.text = ''; span_icon.tail = span.text span.text = '' span.insert(0, span_icon) + + def _decorate_comment(self, span, index, xmltree): + """ Sets the comment class. """ + + comment = None + for child in span.iterchildren(): + if child.get('class') == 'comment': + comment = child + break + + if comment is not None: + comment.set('class', 'annotatable-comment') + + def _get_marker_color(self, span): + valid_markers = ['yellow', 'orange', 'purple', 'blue', 'green'] + if 'marker' in span.attrib: + marker = span.attrib['marker'] + del span.attrib['marker'] + if marker in valid_markers: + return marker + return None def _render(self): """ Renders annotatable content by transforming spans and adding discussions. """ xmltree = etree.fromstring(self.content) - self._iterspans(xmltree, [ self._add_icon, self._attach_discussion ]) + self._iterspans(xmltree, [ + self._set_span_data, + self._decorate_span, + self._decorate_comment + ]) + return etree.tostring(xmltree) def get_html(self): @@ -107,8 +105,7 @@ class AnnotatableModule(XModule): context = { 'display_name': self.display_name, 'element_id': self.element_id, - 'html_content': self._render(), - 'json_discussion_for': json.dumps(self.discussion_for) + 'html_content': self._render() } # template dir: lms/templates @@ -121,7 +118,7 @@ class AnnotatableModule(XModule): self.element_id = self.location.html_id(); self.content = self.definition['data'] - self.discussion_for = {} # Maps spans to discussions by id (for JS) + self.spans = {} class AnnotatableDescriptor(RawDescriptor): diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 9b2cbc3763..d1f39332f6 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -20,18 +20,31 @@ } span.annotatable { - color: $blue; cursor: pointer; - .annotatable-icon { - margin: auto 2px auto 4px; + @each $highlight in ( + (yellow rgb(239, 255, 0)), + (orange rgb(255,113,0)), + (purple rgb(255,0,197)), + (blue rgb(0,90,255)), + (green rgb(111,255,9))) { + &.highlight-#{nth($highlight,1)} { + background-color: #{lighten(nth($highlight,2), 20%)}; + } } &.hide { cursor: none; - color: inherit; + background-color: inherit; .annotatable-icon { display: none; } } + + .annotatable-comment { + display: none; + } + .annotatable-icon { + margin: auto 2px auto 4px; + } } .annotatable-icon { @@ -42,6 +55,11 @@ span.annotatable { background: url(../images/link-icon.png) no-repeat; } +.annotatable-reply { + display: block; + margin: 1em 0 .5em 0; +} + .help-icon { display: block; position: absolute; @@ -53,30 +71,20 @@ span.annotatable { background: url(../images/info-icon.png) no-repeat; } -.annotatable-discussion { - display: block; - border: 1px solid $border-color; - border-radius: 3px; - margin: 1em 0; - position: relative; - padding: 4px; - - .annotatable-discussion-label { - font-weight: bold; +.ui-tooltip.qtip.ui-tooltip-annotatable { + $border-color: #F1D031; + .ui-tooltip-titlebar { + border-color: $border-color; } - .annotatable-icon { - margin: auto 4px auto 0px; + .ui-tooltip-content { + background: rgba(255, 255, 255, 0.9); + border: 1px solid $border-color; + color: #000; + margin-bottom: 6px; + margin-right: 0; + overflow: visible; + padding: 4px; + text-align: left; + -webkit-font-smoothing: antialiased; } - .annotatable-show-discussion { - position: absolute; - right: 8px; - margin-top: 4px; - } - - &.opaque { - opacity: 0.4; - } - &.hide { - display: none; - } -} +} \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 4a5e2e42d6..45cbb20bec 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -2,108 +2,93 @@ class @Annotatable @_debug: true wrapperSelector: '.annotatable-wrapper' - spanSelector: 'span.annotatable[data-span-id]' - discussionSelector: '.annotatable-discussion[data-discussion-id]' toggleSelector: '.annotatable-toggle' + spanSelector: 'span.annotatable' + commentSelector: '.annotatable-comment' + replySelector: 'a.annotatable-reply' constructor: (el) -> console.log 'loaded Annotatable' if @_debug - @el = el - @init() + @init(el) - init: () -> + $: (selector) -> + $(selector, @el) + + init: (el) -> + @el = el @hideAnnotations = false - @spandata = {} - @loadSpanData() @initEvents() + @initToolTips() initEvents: () -> - $(@toggleSelector, @el).bind('click', @_bind @onClickToggleAnnotations) - - $(@wrapperSelector, @el).delegate(@spanSelector, { - 'click': @_bind @onSpanEvent @onClickSpan - 'mouseenter': @_bind @onSpanEvent @onEnterSpan - 'mouseleave': @_bind @onSpanEvent @onLeaveSpan - }) - - loadSpanData: () -> - @spandata = $(@wrapperSelector, @el).data('spans') - - getDiscussionId: (span_id) -> - @spandata[span_id] - - getDiscussionEl: (discussion_id) -> - $(@discussionSelector, @el).filter('[data-discussion-id="'+discussion_id+'"]') - - onClickToggleAnnotations: (e) -> - @hideAnnotations = !@hideAnnotations - $(@spanSelector, @el).add(@discussionSelector, @el).toggleClass('hide', @hideAnnotations) - $(@toggleSelector, @el).text(if @hideAnnotations then 'Show Annotations' else 'Hide Annotations') - - onSpanEvent: (fn) -> - (e) => - span_el = e.currentTarget - span_id = span_el.getAttribute('data-span-id') - discussion_id = @getDiscussionId(span_id) - discussion_el = @getDiscussionEl(discussion_id) - span = { - id: span_id - el: span_el - } - discussion = { - id: discussion_id - el: discussion_el - } - if !@hideAnnotations - fn.call this, span, discussion - - onClickSpan: (span, discussion) -> - @scrollToDiscussion(discussion.el) - - onEnterSpan: (span, discussion) -> - @focusDiscussion(discussion.el, true) - - onLeaveSpan: (span, discussion) -> - @focusDiscussion(discussion.el, false) - - focusDiscussion: (el, state) -> - $(@discussionSelector, @el).not(el).toggleClass('opaque', state) - - scrollToDiscussion: (el) -> - padding = 20 - complete = @makeHighlighter(el) - animOpts = { - scrollTop : el.offset().top - padding - } - - if @canScrollToDiscussion(el) - $('html, body').animate(animOpts, 500, 'swing', complete) - else - complete() - - canScrollToDiscussion: (el) -> - scrollTop = el.offset().top - docHeight = $(document).height() - winHeight = $(window).height() - winScrollTop = window.scrollY - - viewStart = winScrollTop - viewEnd = winScrollTop + (.75 * winHeight) - inView = viewStart < scrollTop < viewEnd - - scrollable = !inView - atDocEnd = viewStart + winHeight >= docHeight - - return (if atDocEnd then false else scrollable) - - makeHighlighter: (el) -> - return @_once -> el.effect('highlight', {}, 500) + @$(@toggleSelector).bind 'click', @onClickToggleAnnotations + @$(@wrapperSelector).delegate @replySelector, 'click', @onClickReply - _once: (fn) -> - done = false - return => - fn.call this unless done - done = true + initToolTips: () -> + @$(@spanSelector).each (index, el) => + $(el).qtip(@getTipOptions el) - _bind: (fn) -> - return => fn.apply(this, arguments) + getTipOptions: (el) -> + content: + title: + text: @makeTipTitle(el) + button: 'Close' + text: @makeTipComment(el) + position: + my: 'bottom center' # of tooltip + at: 'top center' # of target + target: 'mouse' + container: @$(@wrapperSelector) + adjust: + mouse: false # dont follow the mouse + method: 'shift none' + show: + event: 'click' + hide: + event: 'click' + style: + classes: 'ui-tooltip-annotatable' + events: + show: @onShowTipComment + + onShowTipComment: (event, api) => + event.preventDefault() if @hideAnnotations + + onClickToggleAnnotations: (e) => + @hideAnnotations = !@hideAnnotations + hide = @hideAnnotations + + @hideAllTips() if hide + @$(@spanSelector).toggleClass('hide', hide) + @$(@toggleSelector).text((if hide then 'Show' else 'Hide') + ' Annotations') + + onClickReply: (e) => + hash = $(e.currentTarget).attr('href') + if hash?.charAt(0) == '#' + name = hash.substr(1) + anchor = $("a[name='#{name}']").first() + @scrollTo(anchor) if anchor.length == 1 + + scrollTo: (el, padding = 20) -> + scrollTop = el.offset().top - padding + $('html,body').animate(scrollTop: scrollTop, 500, 'swing') + + makeTipComment: (el) -> + return (api) => + comment = $(@commentSelector, el).first().clone() + anchor = $(el).data('discussion-anchor') + if anchor + comment.append(@createReplyLink(anchor)) + comment.contents() + + makeTipTitle: (el) -> + return (api) => + comment = $(@commentSelector, el).first() + title = comment.attr('title') + (if title then title else 'Commentary') + + createReplyLink: (anchor) -> + $("Reply to Comment") + + hideAllTips: () -> + @$(@spanSelector).each (index, el) -> $(el).qtip('api').hide() \ No newline at end of file diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index 78c2d5a97a..5f60c6cba2 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -1,22 +1,14 @@
- -
-
- % if display_name is not UNDEFINED and display_name is not None: -
${display_name}
- % endif -
Annotated Reading + Guided Discussion
- Hide Annotations -
- -
- ${html_content} -
+
+
+ % if display_name is not UNDEFINED and display_name is not None: +
${display_name}
+ % endif +
Annotated Reading + Guided Discussion
+ Hide Annotations +
- - +
+ ${html_content} +
From a9773eb888c0c52dd93f92aed5122c86b7d13548 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Fri, 8 Feb 2013 20:38:24 -0500 Subject: [PATCH 032/214] Updated markup, styling, and tooltip behavior for displaying instructor comments. Added drag/drop to the comments so the user can organize them as they wish after clicking on the span. When the user hides the annotations, their positions on the screen are remembered so they can be restored later. Also modified the markup so that block content can be displayed. --- .../lib/xmodule/xmodule/annotatable_module.py | 18 +-- .../xmodule/css/annotatable/display.scss | 23 ++-- .../xmodule/js/src/annotatable/display.coffee | 113 +++++++++++++----- lms/templates/annotatable.html | 2 +- 4 files changed, 108 insertions(+), 48 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 5b3cc15c77..c2fd973918 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -30,20 +30,20 @@ class AnnotatableModule(XModule): def _is_span(self, element): """ Returns true if the element is a valid annotation span, false otherwise. """ - return element.tag == 'span' and element.get('class') == 'annotatable' + return element.get('class') == 'annotatable' def _iterspans(self, xmltree, callbacks): - """ Iterates over span elements and invokes each callback on the span. """ + """ Iterates over elements and invokes each callback on the span. """ index = 0 - for element in xmltree.iter('span'): + for element in xmltree.iter(): if self._is_span(element): for callback in callbacks: callback(element, index, xmltree) index += 1 def _set_span_data(self, span, index, xmltree): - """ Sets an ID and discussion anchor for the span. """ + """ Sets the discussion anchor for the span. """ if 'anchor' in span.attrib: span.set('data-discussion-anchor', span.get('anchor')) @@ -52,13 +52,12 @@ class AnnotatableModule(XModule): def _decorate_span(self, span, index, xmltree): """ Decorates the span with an icon and highlight. """ - cls = ['annotatable', ] + cls = ['annotatable-span', 'highlight'] marker = self._get_marker_color(span) - if marker is None: - cls.append('highlight-yellow') - else: + if marker is not None: cls.append('highlight-'+marker) + span.tag = 'div' span.set('class', ' '.join(cls)) span_icon = etree.Element('span', { 'class': 'annotatable-icon'} ) span_icon.text = ''; @@ -76,9 +75,12 @@ class AnnotatableModule(XModule): break if comment is not None: + comment.tag = 'div' comment.set('class', 'annotatable-comment') def _get_marker_color(self, span): + """ Returns the name of the marker color for the span if it is valid, otherwise none.""" + valid_markers = ['yellow', 'orange', 'purple', 'blue', 'green'] if 'marker' in span.attrib: marker = span.attrib['marker'] diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index d1f39332f6..a8024a6d14 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -19,7 +19,8 @@ font-size: $body-font-size; } -span.annotatable { +.annotatable-span { + display: inline; cursor: pointer; @each $highlight in ( (yellow rgb(239, 255, 0)), @@ -27,9 +28,10 @@ span.annotatable { (purple rgb(255,0,197)), (blue rgb(0,90,255)), (green rgb(111,255,9))) { - &.highlight-#{nth($highlight,1)} { - background-color: #{lighten(nth($highlight,2), 20%)}; - } + $marker: nth($highlight,1); + $color: lighten(nth($highlight,2), 20%); + @if $marker == yellow { &.highlight { background-color: $color; } } + &.highlight-#{$marker} { background-color: $color; } } &.hide { cursor: none; @@ -50,7 +52,7 @@ span.annotatable { .annotatable-icon { display: inline-block; vertical-align: middle; - width: 16px; + width: 17px; height: 17px; background: url(../images/link-icon.png) no-repeat; } @@ -60,12 +62,12 @@ span.annotatable { margin: 1em 0 .5em 0; } -.help-icon { +.annotatable-help-icon { display: block; position: absolute; right: 0; top: 33%; - width: 16px; + width: 17px; height: 17px; margin: 0 7px 0 0; background: url(../images/info-icon.png) no-repeat; @@ -80,11 +82,14 @@ span.annotatable { background: rgba(255, 255, 255, 0.9); border: 1px solid $border-color; color: #000; + font-weight: normal; margin-bottom: 6px; margin-right: 0; - overflow: visible; padding: 4px; text-align: left; + max-width: 300px; + max-height: 300px; + overflow: auto; -webkit-font-smoothing: antialiased; } -} \ No newline at end of file +} diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 45cbb20bec..a021b3e9d8 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -3,64 +3,89 @@ class @Annotatable wrapperSelector: '.annotatable-wrapper' toggleSelector: '.annotatable-toggle' - spanSelector: 'span.annotatable' + spanSelector: '.annotatable-span' commentSelector: '.annotatable-comment' - replySelector: 'a.annotatable-reply' + replySelector: '.annotatable-reply' + helpSelector: '.annotatable-help-icon' constructor: (el) -> console.log 'loaded Annotatable' if @_debug - @init(el) + @el = el + @init() $: (selector) -> $(selector, @el) - init: (el) -> - @el = el - @hideAnnotations = false + init: () -> @initEvents() - @initToolTips() + @initTips() initEvents: () -> + @annotationsHidden = false @$(@toggleSelector).bind 'click', @onClickToggleAnnotations @$(@wrapperSelector).delegate @replySelector, 'click', @onClickReply - - initToolTips: () -> + + initTips: () -> + @visibleTips = [] @$(@spanSelector).each (index, el) => $(el).qtip(@getTipOptions el) + @$(@helpSelector).qtip + position: + my: 'right top' + at: 'bottom left' + content: + title: 'Annotated Reading Help' + text: "To reveal annotations in the reading, click the highlighted areas. + Discuss the annotations in the forums using the reply link at the + end of the annotation.

+ To conceal annotations, use the Hide Annotations button." + getTipOptions: (el) -> content: - title: + title: text: @makeTipTitle(el) button: 'Close' - text: @makeTipComment(el) + text: @makeTipContent(el) position: my: 'bottom center' # of tooltip at: 'top center' # of target target: 'mouse' container: @$(@wrapperSelector) - adjust: + adjust: mouse: false # dont follow the mouse - method: 'shift none' - show: + show: event: 'click' hide: event: 'click' style: classes: 'ui-tooltip-annotatable' events: - show: @onShowTipComment + render: @onRenderTip + show: @onShowTip - onShowTipComment: (event, api) => - event.preventDefault() if @hideAnnotations + onRenderTip: (event, api) => + $(api.elements.tooltip).draggable + handle: '.ui-tooltip-title' + cursor: 'move' + + onShowTip: (event, api) => + event.preventDefault() if @annotationsHidden onClickToggleAnnotations: (e) => - @hideAnnotations = !@hideAnnotations - hide = @hideAnnotations + toggle = @$(@toggleSelector) + spans = @$(@spanSelector) - @hideAllTips() if hide - @$(@spanSelector).toggleClass('hide', hide) - @$(@toggleSelector).text((if hide then 'Show' else 'Hide') + ' Annotations') + @annotationsHidden = !@annotationsHidden + if @annotationsHidden + spans.toggleClass('hide', true) + toggle.text('Show Annotations') + @visibleTips = @getVisibleTips() + @hideTips(@visibleTips) + else + spans.toggleClass('hide', false) + toggle.text('Hide Annotations') + @showTips(@visibleTips) onClickReply: (e) => hash = $(e.currentTarget).attr('href') @@ -70,11 +95,16 @@ class @Annotatable @scrollTo(anchor) if anchor.length == 1 scrollTo: (el, padding = 20) -> - scrollTop = el.offset().top - padding - $('html,body').animate(scrollTop: scrollTop, 500, 'swing') + props = + scrollTop: (el.offset().top - padding) + opts = + duration: 500 + complete: @_once -> el.effect 'highlight', {}, 2000 - makeTipComment: (el) -> - return (api) => + $('html,body').animate(props, opts) + + makeTipContent: (el) -> + (api) => comment = $(@commentSelector, el).first().clone() anchor = $(el).data('discussion-anchor') if anchor @@ -82,13 +112,36 @@ class @Annotatable comment.contents() makeTipTitle: (el) -> - return (api) => + (api) => comment = $(@commentSelector, el).first() title = comment.attr('title') (if title then title else 'Commentary') createReplyLink: (anchor) -> - $("Reply to Comment") + $("Reply to this comment") + + getVisibleTips: () -> + visible = [] + @$(@spanSelector).each (index, el) -> + api = $(el).qtip('api') + tip = $(api?.elements.tooltip) + if tip.is(':visible') + visible.push [el, tip.offset()] + visible - hideAllTips: () -> - @$(@spanSelector).each (index, el) -> $(el).qtip('api').hide() \ No newline at end of file + hideTips: (items) -> + elements = (pair[0] for pair in items) + $(elements).qtip('hide') + + showTips: (items) -> + $.each items, (index, item) -> + [el, offset] = item + api = $(el).qtip('api') + api?.show() + $(api?.elements.tooltip).offset(offset) + + _once: (fn) -> + done = false + return => + fn.call this unless done + done = true diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index 5f60c6cba2..1cb40a0068 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -1,6 +1,6 @@
-
+
% if display_name is not UNDEFINED and display_name is not None:
${display_name}
% endif From 5fc7f1a89284a124f95b8546844977d42b59c6b6 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Fri, 8 Feb 2013 21:27:35 -0500 Subject: [PATCH 033/214] Constrain comments to viewport. --- common/lib/xmodule/xmodule/js/src/annotatable/display.coffee | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index a021b3e9d8..6bb286bfd4 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -52,7 +52,9 @@ class @Annotatable at: 'top center' # of target target: 'mouse' container: @$(@wrapperSelector) + viewport: true, adjust: + method: 'none shift' mouse: false # dont follow the mouse show: event: 'click' From b7158e9f8aa61a676649930e50d9b0991fe1ccc9 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Sat, 9 Feb 2013 14:38:04 -0500 Subject: [PATCH 034/214] Switched to $.scrollTo instead of custom animation and refactored toggle method. --- .../xmodule/js/src/annotatable/display.coffee | 90 ++++++++++--------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 6bb286bfd4..49dc6aa8ec 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -2,11 +2,11 @@ class @Annotatable @_debug: true wrapperSelector: '.annotatable-wrapper' - toggleSelector: '.annotatable-toggle' - spanSelector: '.annotatable-span' + toggleSelector: '.annotatable-toggle' + spanSelector: '.annotatable-span' commentSelector: '.annotatable-comment' - replySelector: '.annotatable-reply' - helpSelector: '.annotatable-help-icon' + replySelector: '.annotatable-reply' + helpSelector: '.annotatable-help-icon' constructor: (el) -> console.log 'loaded Annotatable' if @_debug @@ -26,10 +26,8 @@ class @Annotatable @$(@wrapperSelector).delegate @replySelector, 'click', @onClickReply initTips: () -> - @visibleTips = [] - @$(@spanSelector).each (index, el) => - $(el).qtip(@getTipOptions el) - + @savedTips = [] + @$(@spanSelector).each (index, el) => $(el).qtip(@getTipOptions el) @$(@helpSelector).qtip position: my: 'right top' @@ -75,42 +73,42 @@ class @Annotatable event.preventDefault() if @annotationsHidden onClickToggleAnnotations: (e) => - toggle = @$(@toggleSelector) - spans = @$(@spanSelector) - - @annotationsHidden = !@annotationsHidden - if @annotationsHidden - spans.toggleClass('hide', true) - toggle.text('Show Annotations') - @visibleTips = @getVisibleTips() - @hideTips(@visibleTips) - else - spans.toggleClass('hide', false) - toggle.text('Hide Annotations') - @showTips(@visibleTips) + @annotationsHidden = not @annotationsHidden + @toggleButtonText @annotationsHidden + @toggleSpans @annotationsHidden + @toggleTips @annotationsHidden onClickReply: (e) => hash = $(e.currentTarget).attr('href') if hash?.charAt(0) == '#' name = hash.substr(1) anchor = $("a[name='#{name}']").first() - @scrollTo(anchor) if anchor.length == 1 + @scrollTo(anchor) - scrollTo: (el, padding = 20) -> - props = - scrollTop: (el.offset().top - padding) - opts = + toggleTips: (hide) -> + if hide + @closeAndSaveTips() + else + @openSavedTips() + + toggleButtonText: (hide) -> + buttonText = (if hide then 'Show' else 'Hide')+' Annotations' + @$(@toggleSelector).text(buttonText) + + toggleSpans: (hide) -> + @$(@spanSelector).toggleClass 'hide', hide + + scrollTo: (el) -> + options = duration: 500 - complete: @_once -> el.effect 'highlight', {}, 2000 - - $('html,body').animate(props, opts) + onAfter: @_once -> el.effect 'highlight', {}, 2000 + $('html,body').scrollTo(el, options) makeTipContent: (el) -> (api) => - comment = $(@commentSelector, el).first().clone() anchor = $(el).data('discussion-anchor') - if anchor - comment.append(@createReplyLink(anchor)) + comment = $(@commentSelector, el).first().clone() + comment.append(@createReplyLink(anchor)) if anchor comment.contents() makeTipTitle: (el) -> @@ -120,9 +118,19 @@ class @Annotatable (if title then title else 'Commentary') createReplyLink: (anchor) -> - $("Reply to this comment") + cls = 'annotatable-reply' + href = '#' + anchor + text = 'Reply to this comment' + $("#{text}") - getVisibleTips: () -> + openSavedTips: () -> + @showTips @savedTips + + closeAndSaveTips: () -> + @savedTips = @findVisibleTips() + @hideTips @savedTips + + findVisibleTips: () -> visible = [] @$(@spanSelector).each (index, el) -> api = $(el).qtip('api') @@ -130,16 +138,16 @@ class @Annotatable if tip.is(':visible') visible.push [el, tip.offset()] visible - - hideTips: (items) -> - elements = (pair[0] for pair in items) + + hideTips: (pairs) -> + elements = (pair[0] for pair in pairs) $(elements).qtip('hide') - showTips: (items) -> - $.each items, (index, item) -> - [el, offset] = item + showTips: (pairs) -> + $.each pairs, (index, pair) -> + [el, offset] = pair + $(el).qtip('show') api = $(el).qtip('api') - api?.show() $(api?.elements.tooltip).offset(offset) _once: (fn) -> From 3a81ed0651e01a99961ed7225176c5da152aa6f0 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Sun, 10 Feb 2013 19:40:18 -0500 Subject: [PATCH 035/214] Updated styling and markup. --- .../lib/xmodule/xmodule/annotatable_module.py | 7 +- .../xmodule/css/annotatable/display.scss | 96 ++++++++++++++----- .../xmodule/js/src/annotatable/display.coffee | 4 +- 3 files changed, 74 insertions(+), 33 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index c2fd973918..a96dd22f3e 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -50,7 +50,7 @@ class AnnotatableModule(XModule): del span.attrib['anchor'] def _decorate_span(self, span, index, xmltree): - """ Decorates the span with an icon and highlight. """ + """ Decorates the span highlight. """ cls = ['annotatable-span', 'highlight'] marker = self._get_marker_color(span) @@ -59,11 +59,6 @@ class AnnotatableModule(XModule): span.tag = 'div' span.set('class', ' '.join(cls)) - span_icon = etree.Element('span', { 'class': 'annotatable-icon'} ) - span_icon.text = ''; - span_icon.tail = span.text - span.text = '' - span.insert(0, span_icon) def _decorate_comment(self, span, index, xmltree): """ Sets the comment class. """ diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index a8024a6d14..bfd5f8567c 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -23,13 +23,14 @@ display: inline; cursor: pointer; @each $highlight in ( - (yellow rgb(239, 255, 0)), - (orange rgb(255,113,0)), - (purple rgb(255,0,197)), - (blue rgb(0,90,255)), - (green rgb(111,255,9))) { + (red rgba(178,19,16,0.3)), + (orange rgba(255,165,0,0.3)), + (yellow rgba(255,255,10,0.3)), + (green rgba(25,255,132,0.3)), + (blue rgba(35,163,255,0.3)), + (purple rgba(115,9,178,0.3))) { $marker: nth($highlight,1); - $color: lighten(nth($highlight,2), 20%); + $color: nth($highlight,2); @if $marker == yellow { &.highlight { background-color: $color; } } &.highlight-#{$marker} { background-color: $color; } } @@ -49,17 +50,9 @@ } } -.annotatable-icon { - display: inline-block; - vertical-align: middle; - width: 17px; - height: 17px; - background: url(../images/link-icon.png) no-repeat; -} - .annotatable-reply { display: block; - margin: 1em 0 .5em 0; + margin-bottom: 10px; } .annotatable-help-icon { @@ -74,22 +67,75 @@ } .ui-tooltip.qtip.ui-tooltip-annotatable { - $border-color: #F1D031; + $color: #fff; + $background: rgba(0,0,0,.85); + $border-radius: 1em; + + -webkit-font-smoothing: antialiased; + .ui-tooltip-titlebar { - border-color: $border-color; + color: $color; + background: $background; + border-left: 1px solid #333; + border-right: 1px solid #333; + border-top: 1px solid #333; + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + padding: 5px 10px; + + .ui-tooltip-title { + margin-right: 25px; + padding: 5px 0px; + border-bottom: 2px solid #333; + font-weight: bold; + &:before { + font-weight: normal; + content: "Guided Discussion: " + } + } + .ui-tooltip-icon { + right: 10px; + background: #333; + } + .ui-state-hover { + color: inherit; + border: 1px solid #ccc; + } } .ui-tooltip-content { - background: rgba(255, 255, 255, 0.9); - border: 1px solid $border-color; - color: #000; - font-weight: normal; - margin-bottom: 6px; - margin-right: 0; - padding: 4px; + color: $color; + background: $background; text-align: left; + font-weight: 400; + font-size: 11px; + padding: 0 10px; max-width: 300px; max-height: 300px; + border: none; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + border-left: 1px solid #333; + border-right: 1px solid #333; + border-bottom: 1px solid #333; overflow: auto; - -webkit-font-smoothing: antialiased; + + .annotatable-comment { + display: block; + margin: 0px 0px 10px 0; + } + } + p { color: $color } + + &:after { + content: ' '; + display: block; + position: absolute; + bottom: -14px; + left: 50%; + height: 0; + width: 0; + margin-left: -7px; + border: 10px solid transparent; + border-top-color: rgba(0, 0, 0, .85); } } diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 49dc6aa8ec..60d8e9e9e4 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -108,8 +108,8 @@ class @Annotatable (api) => anchor = $(el).data('discussion-anchor') comment = $(@commentSelector, el).first().clone() - comment.append(@createReplyLink(anchor)) if anchor - comment.contents() + comment = comment.after(@createReplyLink(anchor)) if anchor + comment makeTipTitle: (el) -> (api) => From a14489615451a2bd32a218a461b8d5da2b7866c6 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 11 Feb 2013 16:17:34 -0500 Subject: [PATCH 036/214] Style and behavior changes to the header and tooltips. --- .../lib/xmodule/xmodule/annotatable_module.py | 9 +- .../xmodule/css/annotatable/display.scss | 117 ++++++++---------- .../xmodule/js/src/annotatable/display.coffee | 72 ++++++----- lms/templates/annotatable.html | 9 +- lms/templates/annotatable_discussion.html | 6 - 5 files changed, 104 insertions(+), 109 deletions(-) delete mode 100644 lms/templates/annotatable_discussion.html diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index a96dd22f3e..92ba987256 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -56,9 +56,14 @@ class AnnotatableModule(XModule): marker = self._get_marker_color(span) if marker is not None: cls.append('highlight-'+marker) - - span.tag = 'div' + + icon = etree.Element('span', { 'class': 'annotatable-icon ss-icon ss-textchat' }) + icon.append(etree.Entity('#xE396')) + icon.tail = span.text + span.text = '' + span.insert(0, icon) span.set('class', ' '.join(cls)) + span.tag = 'div' def _decorate_comment(self, span, index, xmltree): """ Sets the comment class. """ diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index bfd5f8567c..796c204c0b 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -1,39 +1,38 @@ .annotatable-header { - border: 1px solid $border-color; - border-radius: 3px; margin-bottom: 1em; - padding: 2px 4px; - position: relative; - .annotatable-title { - font-size: em(18); + font-size: em(20); text-transform: uppercase; + padding: 2px 4px; } .annotatable-description { - font-size: $body-font-size; + position: relative; + font-size: em(14); + padding: 2px 4px; + border: 1px solid $border-color; + border-radius: 3px; + .annotatable-toggle { } } } -.annotatable-toggle { - display: block; - font-size: $body-font-size; -} - .annotatable-span { display: inline; cursor: pointer; + @each $highlight in ( - (red rgba(178,19,16,0.3)), - (orange rgba(255,165,0,0.3)), - (yellow rgba(255,255,10,0.3)), - (green rgba(25,255,132,0.3)), - (blue rgba(35,163,255,0.3)), - (purple rgba(115,9,178,0.3))) { + (red rgba(178,19,16,0.3)), (orange rgba(255,165,0,0.3)), + (yellow rgba(255,255,10,0.3)), (green rgba(25,255,132,0.3)), + (blue rgba(35,163,255,0.3)), (purple rgba(115,9,178,0.3))) { + $marker: nth($highlight,1); $color: nth($highlight,2); - @if $marker == yellow { &.highlight { background-color: $color; } } + + @if $marker == yellow { + &.highlight { background-color: $color; } + } &.highlight-#{$marker} { background-color: $color; } } + &.hide { cursor: none; background-color: inherit; @@ -45,14 +44,6 @@ .annotatable-comment { display: none; } - .annotatable-icon { - margin: auto 2px auto 4px; - } -} - -.annotatable-reply { - display: block; - margin-bottom: 10px; } .annotatable-help-icon { @@ -67,31 +58,21 @@ } .ui-tooltip.qtip.ui-tooltip-annotatable { - $color: #fff; - $background: rgba(0,0,0,.85); - $border-radius: 1em; - + border: 1px solid #333; + border-radius: 1em; + background-color: rgba(0,0,0,.85); + color: #fff; -webkit-font-smoothing: antialiased; .ui-tooltip-titlebar { - color: $color; - background: $background; - border-left: 1px solid #333; - border-right: 1px solid #333; - border-top: 1px solid #333; - border-top-left-radius: $border-radius; - border-top-right-radius: $border-radius; + color: inherit; + background-color: transparent; padding: 5px 10px; - + border: none; .ui-tooltip-title { - margin-right: 25px; padding: 5px 0px; border-bottom: 2px solid #333; font-weight: bold; - &:before { - font-weight: normal; - content: "Guided Discussion: " - } } .ui-tooltip-icon { right: 10px; @@ -103,39 +84,47 @@ } } .ui-tooltip-content { - color: $color; - background: $background; + color: inherit; + background-color: transparent; text-align: left; font-weight: 400; font-size: 11px; padding: 0 10px; - max-width: 300px; - max-height: 300px; - border: none; - border-bottom-left-radius: $border-radius; - border-bottom-right-radius: $border-radius; - border-left: 1px solid #333; - border-right: 1px solid #333; - border-bottom: 1px solid #333; - overflow: auto; - + } + p { color: inherit; } +} + +.ui-tooltip.qtip.ui-tooltip-annotatable-comment { + max-width: 350px; + .ui-tooltip-title:before { + font-weight: normal; + content: "Guided Discussion: "; + } + .ui-tooltip-content { .annotatable-comment { display: block; margin: 0px 0px 10px 0; + max-height: 200px; + overflow: hidden; } - } - p { color: $color } - + .annotatable-reply { + display: block; + border-top: 2px solid #333; + padding: 5px 0; + margin: 0; + text-align: center; + } + } &:after { - content: ' '; - display: block; + content: ''; + display: inline-block; position: absolute; - bottom: -14px; + bottom: -20px; left: 50%; height: 0; width: 0; - margin-left: -7px; + margin-left: -5px; border: 10px solid transparent; border-top-color: rgba(0, 0, 0, .85); } -} +} \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 60d8e9e9e4..dc580125d8 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -32,64 +32,65 @@ class @Annotatable position: my: 'right top' at: 'bottom left' + container: @$(@wrapperSelector) content: title: 'Annotated Reading Help' - text: "To reveal annotations in the reading, click the highlighted areas. - Discuss the annotations in the forums using the reply link at the - end of the annotation.

- To conceal annotations, use the Hide Annotations button." + text: "Move your cursor over the highlighted areas to display annotations. + Discuss the annotations in the forums using the link at the + bottom of the annotation. You may hide annotations at any time by + using the button at the top of the section." + style: + classes: 'ui-tooltip-annotatable' getTipOptions: (el) -> content: title: text: @makeTipTitle(el) - button: 'Close' text: @makeTipContent(el) position: my: 'bottom center' # of tooltip at: 'top center' # of target target: 'mouse' container: @$(@wrapperSelector) - viewport: true, adjust: - method: 'none shift' mouse: false # dont follow the mouse + y: -10 show: - event: 'click' + event: 'mouseenter' + solo: true hide: - event: 'click' + event: 'unfocus' style: - classes: 'ui-tooltip-annotatable' + classes: 'ui-tooltip-annotatable ui-tooltip-annotatable-comment' events: - render: @onRenderTip show: @onShowTip - onRenderTip: (event, api) => - $(api.elements.tooltip).draggable - handle: '.ui-tooltip-title' - cursor: 'move' - onShowTip: (event, api) => event.preventDefault() if @annotationsHidden onClickToggleAnnotations: (e) => + @toggleAnnotations() + + onClickReply: (e) => + e.preventDefault() + anchorEl = @getAnchorByName e.currentTarget + @scrollTo anchorEl if anchorEl + + getAnchorByName: (el) -> + hash = $(el).attr('href') + if hash?.charAt(0) == '#' + name = hash.substr(1) + anchor = $("a[name='#{name}']").first() + anchor + + toggleAnnotations: () -> @annotationsHidden = not @annotationsHidden @toggleButtonText @annotationsHidden @toggleSpans @annotationsHidden @toggleTips @annotationsHidden - onClickReply: (e) => - hash = $(e.currentTarget).attr('href') - if hash?.charAt(0) == '#' - name = hash.substr(1) - anchor = $("a[name='#{name}']").first() - @scrollTo(anchor) - toggleTips: (hide) -> - if hide - @closeAndSaveTips() - else - @openSavedTips() + if hide then @closeAndSaveTips() else @openSavedTips() toggleButtonText: (hide) -> buttonText = (if hide then 'Show' else 'Hide')+' Annotations' @@ -99,16 +100,19 @@ class @Annotatable @$(@spanSelector).toggleClass 'hide', hide scrollTo: (el) -> - options = - duration: 500 - onAfter: @_once -> el.effect 'highlight', {}, 2000 - $('html,body').scrollTo(el, options) + $('html,body').scrollTo(el, { + duration: 500, + onAfter: @makeAfterScroll(el) + }) + + makeAfterScroll: (el, duration = 2000) -> + @_once -> el.effect 'highlight', {}, duration makeTipContent: (el) -> (api) => anchor = $(el).data('discussion-anchor') comment = $(@commentSelector, el).first().clone() - comment = comment.after(@createReplyLink(anchor)) if anchor + comment = comment.after(@createReplyLink anchor) if anchor comment makeTipTitle: (el) -> @@ -116,11 +120,11 @@ class @Annotatable comment = $(@commentSelector, el).first() title = comment.attr('title') (if title then title else 'Commentary') - + createReplyLink: (anchor) -> cls = 'annotatable-reply' href = '#' + anchor - text = 'Reply to this comment' + text = 'See Full Discussion' $("#{text}") openSavedTips: () -> diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index 1cb40a0068..8f379c7007 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -1,11 +1,14 @@
-
% if display_name is not UNDEFINED and display_name is not None:
${display_name}
% endif -
Annotated Reading + Guided Discussion
- Hide Annotations +
+
+ Annotated Reading + Guided Discussion
+ Hide Annotations + +
diff --git a/lms/templates/annotatable_discussion.html b/lms/templates/annotatable_discussion.html deleted file mode 100644 index 1525cc7b6b..0000000000 --- a/lms/templates/annotatable_discussion.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
- Guided Discussion: - ${discussion_title} - Show Discussion -
From cfa16feab58d137abf0240293ed402715a1de485 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 12 Feb 2013 12:00:54 -0500 Subject: [PATCH 037/214] Trying to setup jasmine specs... --- .../xmodule/js/fixtures/annotatable.html | 39 +++++++++++++++++++ .../js/spec/annotatable/display_spec.coffee | 8 ++++ 2 files changed, 47 insertions(+) create mode 100644 common/lib/xmodule/xmodule/js/fixtures/annotatable.html create mode 100644 common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee diff --git a/common/lib/xmodule/xmodule/js/fixtures/annotatable.html b/common/lib/xmodule/xmodule/js/fixtures/annotatable.html new file mode 100644 index 0000000000..3c862861ff --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/annotatable.html @@ -0,0 +1,39 @@ +
+
+
+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam malesuada pellentesque posuere. +

+ Ut urna magna, fringilla porta ultricies a, molestie sollicitudin tellus. +
Curabitur tellus lorem tempus et dolor.
+
+ Duis condimentum, sapien porttitor commodo elementum, ligula dui tempus mauris, sed ultricies + lectus elit ut nunc. Duis dictum tempus dui tristique pharetra. Vivamus sit amet odio + ac tellus blandit viverra. +

+ +

+

+
Curabitur elementum pretium egestas.
+ Praesent nec eros sem, id fermentum ipsum. Pellentesque egestas cursus lacus non commodo. +
+ Phasellus elementum, diam volutpat auctor posuere, tellus urna blandit orci, ac lacinia justo nisi + ac diam. Pellentesque rutrum leo id nulla eleifend porttitor. Pellentesque habitant + morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aliquam + tristique, ante vitae porttitor hendrerit, tellus quam condimentum magna, nec semper + arcu orci nec erat. +
+ Sed dictum bibendum nibh, nec feugiat metus porttitor sed. +
Test.
+
+ Aliquam dictum suscipit arcu mollis hendrerit. +

+
+
+
+
+ First Discussion
+ Second Discussion
+ Third Discussion
+
diff --git a/common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee new file mode 100644 index 0000000000..983cc495e0 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.coffee @@ -0,0 +1,8 @@ +describe 'Annotatable', -> + beforeEach -> + loadFixtures 'annotatable.html' + describe 'constructor', -> + beforeEach -> + @annotatable = new Annotatable $('.xmodule_display') + it 'initializes tooltips', -> + expect(1).toBe 2 From 7e7911a08d2110926dff995a3bc15ef0720433b1 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 12 Feb 2013 13:24:50 -0500 Subject: [PATCH 038/214] Changed the markup and js for associating spans with discussion threads. Uses the discussion id now. --- .../lib/xmodule/xmodule/annotatable_module.py | 8 ++--- .../xmodule/js/src/annotatable/display.coffee | 31 +++++++++---------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 92ba987256..f4c1d80408 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -43,11 +43,11 @@ class AnnotatableModule(XModule): index += 1 def _set_span_data(self, span, index, xmltree): - """ Sets the discussion anchor for the span. """ + """ Sets the associated discussion id for the span. """ - if 'anchor' in span.attrib: - span.set('data-discussion-anchor', span.get('anchor')) - del span.attrib['anchor'] + if 'discussion' in span.attrib: + span.set('data-discussion-id', span.get('discussion')) + del span.attrib['discussion'] def _decorate_span(self, span, index, xmltree): """ Decorates the span highlight. """ diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index dc580125d8..493d8c7110 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -7,6 +7,7 @@ class @Annotatable commentSelector: '.annotatable-comment' replySelector: '.annotatable-reply' helpSelector: '.annotatable-help-icon' + inlineDiscussionSelector: '.xmodule_DiscussionModule .discussion-module' constructor: (el) -> console.log 'loaded Annotatable' if @_debug @@ -73,15 +74,14 @@ class @Annotatable onClickReply: (e) => e.preventDefault() - anchorEl = @getAnchorByName e.currentTarget - @scrollTo anchorEl if anchorEl + @scrollTo(@getInlineDiscussion e.currentTarget) - getAnchorByName: (el) -> - hash = $(el).attr('href') - if hash?.charAt(0) == '#' - name = hash.substr(1) - anchor = $("a[name='#{name}']").first() - anchor + getInlineDiscussion: (el) -> + discussion_id = @getDiscussionId(el) + $(@inlineDiscussionSelector).filter("[data-discussion-id='#{discussion_id}']") + + getDiscussionId: (el) -> + $(el).data('discussion-id') toggleAnnotations: () -> @annotationsHidden = not @annotationsHidden @@ -105,14 +105,14 @@ class @Annotatable onAfter: @makeAfterScroll(el) }) - makeAfterScroll: (el, duration = 2000) -> - @_once -> el.effect 'highlight', {}, duration + makeAfterScroll: (el, duration = 500) -> + @_once -> el.effect 'shake', {}, duration makeTipContent: (el) -> (api) => - anchor = $(el).data('discussion-anchor') + discussion_id = @getDiscussionId(el) comment = $(@commentSelector, el).first().clone() - comment = comment.after(@createReplyLink anchor) if anchor + comment = comment.after(@createReplyLink discussion_id) if discussion_id comment makeTipTitle: (el) -> @@ -121,11 +121,8 @@ class @Annotatable title = comment.attr('title') (if title then title else 'Commentary') - createReplyLink: (anchor) -> - cls = 'annotatable-reply' - href = '#' + anchor - text = 'See Full Discussion' - $("#{text}") + createReplyLink: (discussion_id) -> + $("See Full Discussion") openSavedTips: () -> @showTips @savedTips From 0c70d201f8217e053886af8e2a55108c84bca7da Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 12 Feb 2013 13:57:28 -0500 Subject: [PATCH 039/214] Auto-expand the discussion thread after it is scrolled to by simulating a click on the show/hide button. Note: the jQuery highlight effect doesnt work here due to a CSS !important setting on .discussion-module background color. --- .../lib/xmodule/xmodule/js/src/annotatable/display.coffee | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 493d8c7110..deaabaf738 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -105,8 +105,12 @@ class @Annotatable onAfter: @makeAfterScroll(el) }) - makeAfterScroll: (el, duration = 500) -> - @_once -> el.effect 'shake', {}, duration + makeAfterScroll: (el, duration = 1500) -> + @_once -> + btn = $('.discussion-show', el) + if !btn.hasClass('shown') + btn.click() + #el.effect 'highlight', {}, duration makeTipContent: (el) -> (api) => From 6b5bf319c3c52871dbdf81deddbf0be6d8b09027 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 12 Feb 2013 19:08:23 -0500 Subject: [PATCH 040/214] Style and behavior changes. Added return links to discussions. --- .../lib/xmodule/xmodule/annotatable_module.py | 14 ++--- .../xmodule/css/annotatable/display.scss | 26 +++++---- .../xmodule/js/src/annotatable/display.coffee | 54 ++++++++++++------- lms/templates/annotatable.html | 3 +- 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index f4c1d80408..2818efd7ce 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -50,18 +50,13 @@ class AnnotatableModule(XModule): del span.attrib['discussion'] def _decorate_span(self, span, index, xmltree): - """ Decorates the span highlight. """ + """ Adds a highlight class to the span. """ cls = ['annotatable-span', 'highlight'] marker = self._get_marker_color(span) if marker is not None: cls.append('highlight-'+marker) - icon = etree.Element('span', { 'class': 'annotatable-icon ss-icon ss-textchat' }) - icon.append(etree.Entity('#xE396')) - icon.tail = span.text - span.text = '' - span.insert(0, icon) span.set('class', ' '.join(cls)) span.tag = 'div' @@ -79,7 +74,7 @@ class AnnotatableModule(XModule): comment.set('class', 'annotatable-comment') def _get_marker_color(self, span): - """ Returns the name of the marker color for the span if it is valid, otherwise none.""" + """ Returns the name of the marker/highlight color for the span if it is valid, otherwise none.""" valid_markers = ['yellow', 'orange', 'purple', 'blue', 'green'] if 'marker' in span.attrib: @@ -88,7 +83,7 @@ class AnnotatableModule(XModule): if marker in valid_markers: return marker return None - + def _render(self): """ Renders annotatable content by transforming spans and adding discussions. """ @@ -98,6 +93,7 @@ class AnnotatableModule(XModule): self._decorate_span, self._decorate_comment ]) + xmltree.tag = 'div' return etree.tostring(xmltree) @@ -120,8 +116,6 @@ class AnnotatableModule(XModule): self.element_id = self.location.html_id(); self.content = self.definition['data'] - self.spans = {} - class AnnotatableDescriptor(RawDescriptor): module_class = AnnotatableModule diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 796c204c0b..a8ad5a71ce 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -1,13 +1,13 @@ .annotatable-header { margin-bottom: 1em; .annotatable-title { - font-size: em(20); + font-size: em(22); text-transform: uppercase; padding: 2px 4px; } .annotatable-description { position: relative; - font-size: em(14); + font-size: $body-font-size; padding: 2px 4px; border: 1px solid $border-color; border-radius: 3px; @@ -57,7 +57,8 @@ background: url(../images/info-icon.png) no-repeat; } -.ui-tooltip.qtip.ui-tooltip-annotatable { +.ui-tooltip.qtip.ui-tooltip { + font-size: $body-font-size; border: 1px solid #333; border-radius: 1em; background-color: rgba(0,0,0,.85); @@ -65,6 +66,7 @@ -webkit-font-smoothing: antialiased; .ui-tooltip-titlebar { + font-size: em(16); color: inherit; background-color: transparent; padding: 5px 10px; @@ -85,26 +87,30 @@ } .ui-tooltip-content { color: inherit; - background-color: transparent; + font-size: em(14); text-align: left; font-weight: 400; - font-size: 11px; - padding: 0 10px; + padding: 0 10px 10px 10px; + background-color: transparent; } - p { color: inherit; } + p { + color: inherit; + line-height: normal; + } } -.ui-tooltip.qtip.ui-tooltip-annotatable-comment { +.ui-tooltip.qtip.ui-tooltip-annotatable { max-width: 350px; .ui-tooltip-title:before { font-weight: normal; content: "Guided Discussion: "; } .ui-tooltip-content { + padding: 0 10px; .annotatable-comment { display: block; margin: 0px 0px 10px 0; - max-height: 200px; + max-height: 175px; overflow: hidden; } .annotatable-reply { @@ -127,4 +133,4 @@ border: 10px solid transparent; border-top-color: rgba(0, 0, 0, .85); } -} \ No newline at end of file +} diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index deaabaf738..205ee5c830 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -6,8 +6,10 @@ class @Annotatable spanSelector: '.annotatable-span' commentSelector: '.annotatable-comment' replySelector: '.annotatable-reply' + returnSelector: '.annotatable-return' helpSelector: '.annotatable-help-icon' - inlineDiscussionSelector: '.xmodule_DiscussionModule .discussion-module' + discussionXModuleSelector: '.xmodule_DiscussionModule' + discussionSelector: '.discussion-module' constructor: (el) -> console.log 'loaded Annotatable' if @_debug @@ -20,11 +22,13 @@ class @Annotatable init: () -> @initEvents() @initTips() + @initDiscussionReturnLinks() initEvents: () -> @annotationsHidden = false @$(@toggleSelector).bind 'click', @onClickToggleAnnotations @$(@wrapperSelector).delegate @replySelector, 'click', @onClickReply + $(@discussionXModuleSelector).delegate @returnSelector, 'click', @onClickReturn initTips: () -> @savedTips = [] @@ -35,13 +39,12 @@ class @Annotatable at: 'bottom left' container: @$(@wrapperSelector) content: - title: 'Annotated Reading Help' - text: "Move your cursor over the highlighted areas to display annotations. - Discuss the annotations in the forums using the link at the - bottom of the annotation. You may hide annotations at any time by - using the button at the top of the section." - style: - classes: 'ui-tooltip-annotatable' + title: 'Annotated Reading' + text: true # use title attribute of this element + + initDiscussionReturnLinks: () -> + $(@discussionXModuleSelector).find(@discussionSelector).each (index, el) => + $(el).after @createReturnLink(@getDiscussionId el) getTipOptions: (el) -> content: @@ -62,7 +65,7 @@ class @Annotatable hide: event: 'unfocus' style: - classes: 'ui-tooltip-annotatable ui-tooltip-annotatable-comment' + classes: 'ui-tooltip-annotatable' events: show: @onShowTip @@ -74,11 +77,20 @@ class @Annotatable onClickReply: (e) => e.preventDefault() - @scrollTo(@getInlineDiscussion e.currentTarget) + discussion_el = @getInlineDiscussion e.currentTarget + @scrollTo(discussion_el, @afterScrollToDiscussion) + + onClickReturn: (e) => + e.preventDefault() + @scrollTo(@getSpan e.currentTarget, @afterScrollToSpan) + + getSpan: (el) -> + discussion_id = @getDiscussionId(el) + @$(@spanSelector).filter("[data-discussion-id='#{discussion_id}']") getInlineDiscussion: (el) -> discussion_id = @getDiscussionId(el) - $(@inlineDiscussionSelector).filter("[data-discussion-id='#{discussion_id}']") + $(@discussionXModuleSelector).find(@discussionSelector).filter("[data-discussion-id='#{discussion_id}']") getDiscussionId: (el) -> $(el).data('discussion-id') @@ -99,18 +111,19 @@ class @Annotatable toggleSpans: (hide) -> @$(@spanSelector).toggleClass 'hide', hide - scrollTo: (el) -> + scrollTo: (el, after) -> $('html,body').scrollTo(el, { - duration: 500, - onAfter: @makeAfterScroll(el) + duration: 500 + #onAfter: @_once => after.call this, el }) - makeAfterScroll: (el, duration = 1500) -> - @_once -> + afterScrollToDiscussion: () -> + (el) -> btn = $('.discussion-show', el) - if !btn.hasClass('shown') - btn.click() - #el.effect 'highlight', {}, duration + btn.click() if !btn.hasClass('shown') + + afterScrollToSpan: (el) -> + (el) -> el.effect('highlight', {}, 500) makeTipContent: (el) -> (api) => @@ -128,6 +141,9 @@ class @Annotatable createReplyLink: (discussion_id) -> $("See Full Discussion") + createReturnLink: (discussion_id) -> + $("Return to annotation") + openSavedTips: () -> @showTips @savedTips diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index 8f379c7007..41df903265 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -4,10 +4,9 @@
${display_name}
% endif
-
+
Annotated Reading + Guided Discussion
Hide Annotations -
From 949603e3c13fd25937a1d62294b4b72bbe87eb26 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 13 Feb 2013 01:16:20 -0500 Subject: [PATCH 041/214] Fixed issue with return link and scroll after effects. --- .../xmodule/js/src/annotatable/display.coffee | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 205ee5c830..ef3e4f05ed 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -6,10 +6,11 @@ class @Annotatable spanSelector: '.annotatable-span' commentSelector: '.annotatable-comment' replySelector: '.annotatable-reply' - returnSelector: '.annotatable-return' helpSelector: '.annotatable-help-icon' + returnSelector: '.annotatable-return' + discussionXModuleSelector: '.xmodule_DiscussionModule' - discussionSelector: '.discussion-module' + discussionSelector: '.discussion-module' constructor: (el) -> console.log 'loaded Annotatable' if @_debug @@ -82,7 +83,8 @@ class @Annotatable onClickReturn: (e) => e.preventDefault() - @scrollTo(@getSpan e.currentTarget, @afterScrollToSpan) + span_el = @getSpan e.currentTarget + @scrollTo(span_el, @afterScrollToSpan) getSpan: (el) -> discussion_id = @getDiscussionId(el) @@ -96,34 +98,38 @@ class @Annotatable $(el).data('discussion-id') toggleAnnotations: () -> - @annotationsHidden = not @annotationsHidden - @toggleButtonText @annotationsHidden - @toggleSpans @annotationsHidden - @toggleTips @annotationsHidden + hide = (@annotationsHidden = not @annotationsHidden) + @toggleButtonText hide + @toggleSpans hide + @toggleReturnLinks hide + @toggleTips hide toggleTips: (hide) -> if hide then @closeAndSaveTips() else @openSavedTips() + toggleReturnLinks: (hide) -> + $(@returnSelector)[if hide then 'hide' else 'show']() + toggleButtonText: (hide) -> buttonText = (if hide then 'Show' else 'Hide')+' Annotations' @$(@toggleSelector).text(buttonText) toggleSpans: (hide) -> - @$(@spanSelector).toggleClass 'hide', hide + @$(@spanSelector).toggleClass 'hide', hide, 250 - scrollTo: (el, after) -> + scrollTo: (el, after = -> true) -> $('html,body').scrollTo(el, { duration: 500 - #onAfter: @_once => after.call this, el + onAfter: @_once => after.call this, el + offset: -20 }) - afterScrollToDiscussion: () -> - (el) -> - btn = $('.discussion-show', el) - btn.click() if !btn.hasClass('shown') + afterScrollToDiscussion: (el) -> + btn = $('.discussion-show', el) + btn.click() if !btn.hasClass('shown') afterScrollToSpan: (el) -> - (el) -> el.effect('highlight', {}, 500) + el.effect 'highlight', {color: 'rgba(0,0,0,0.5)' }, 1000 makeTipContent: (el) -> (api) => @@ -142,7 +148,7 @@ class @Annotatable $("See Full Discussion") createReturnLink: (discussion_id) -> - $("Return to annotation") + $("Return to annotation") openSavedTips: () -> @showTips @savedTips From 2848df828260241b3da0cdb4ef27be82a63b93f5 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 14 Feb 2013 12:43:11 -0500 Subject: [PATCH 042/214] Style changes to comments: increase font size, width. Improved scrolling between spans and discussions. --- .../xmodule/css/annotatable/display.scss | 6 ++--- .../xmodule/js/src/annotatable/display.coffee | 24 ++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index a8ad5a71ce..5973d17222 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -100,7 +100,7 @@ } .ui-tooltip.qtip.ui-tooltip-annotatable { - max-width: 350px; + max-width: 375px; .ui-tooltip-title:before { font-weight: normal; content: "Guided Discussion: "; @@ -110,8 +110,8 @@ .annotatable-comment { display: block; margin: 0px 0px 10px 0; - max-height: 175px; - overflow: hidden; + max-height: 225px; // truncate the text via JS so we can get an ellipsis + overflow: auto; } .annotatable-reply { display: block; diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index ef3e4f05ed..75720c1cb8 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -11,7 +11,9 @@ class @Annotatable discussionXModuleSelector: '.xmodule_DiscussionModule' discussionSelector: '.discussion-module' - + + commentMaxLength: 750 # Max length characters to show in the comment hover state + constructor: (el) -> console.log 'loaded Annotatable' if @_debug @el = el @@ -84,7 +86,8 @@ class @Annotatable onClickReturn: (e) => e.preventDefault() span_el = @getSpan e.currentTarget - @scrollTo(span_el, @afterScrollToSpan) + offset = -200 + @scrollTo(span_el, @afterScrollToSpan, offset) getSpan: (el) -> discussion_id = @getDiscussionId(el) @@ -117,11 +120,11 @@ class @Annotatable toggleSpans: (hide) -> @$(@spanSelector).toggleClass 'hide', hide, 250 - scrollTo: (el, after = -> true) -> + scrollTo: (el, after, offset = -20) -> $('html,body').scrollTo(el, { duration: 500 - onAfter: @_once => after.call this, el - offset: -20 + onAfter: @_once => after?.call this, el + offset: offset }) afterScrollToDiscussion: (el) -> @@ -135,7 +138,10 @@ class @Annotatable (api) => discussion_id = @getDiscussionId(el) comment = $(@commentSelector, el).first().clone() - comment = comment.after(@createReplyLink discussion_id) if discussion_id + text = @_truncate comment.text().trim(), @commentMaxLength + comment.text(text) + if discussion_id + comment = comment.after(@createReplyLink discussion_id) comment makeTipTitle: (el) -> @@ -182,3 +188,9 @@ class @Annotatable return => fn.call this unless done done = true + + _truncate: (text = '', limit) -> + if text.length > limit + text.substring(0, limit - 1).split(' ').slice(0, -1).join(' ') + '...' # truncate on word boundary + else + text \ No newline at end of file From 8f17e6ae9ed76fa75b3caf867b65ccb632cb6870 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 15 Feb 2013 01:30:14 -0500 Subject: [PATCH 043/214] First pass at implementing problem history. --- .../0006_create_student_module_history.py | 109 ++++++++++++++++++ .../0007_allow_null_version_in_history.py | 100 ++++++++++++++++ lms/djangoapps/courseware/models.py | 33 +++++- lms/djangoapps/courseware/views.py | 49 +++++++- lms/envs/common.py | 4 + lms/static/sass/shared/_modal.scss | 2 + .../courseware/submission_history.html | 13 +++ lms/templates/courseware/xqa_interface.html | 21 ++++ lms/templates/staff_problem_info.html | 25 +++- lms/urls.py | 9 +- 10 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 lms/djangoapps/courseware/migrations/0006_create_student_module_history.py create mode 100644 lms/djangoapps/courseware/migrations/0007_allow_null_version_in_history.py create mode 100644 lms/templates/courseware/submission_history.html diff --git a/lms/djangoapps/courseware/migrations/0006_create_student_module_history.py b/lms/djangoapps/courseware/migrations/0006_create_student_module_history.py new file mode 100644 index 0000000000..8bf40cfb20 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0006_create_student_module_history.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'StudentModuleHistory' + db.create_table('courseware_studentmodulehistory', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('student_module', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['courseware.StudentModule'])), + ('version', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('state', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('grade', self.gf('django.db.models.fields.FloatField')(null=True, blank=True)), + ('max_grade', self.gf('django.db.models.fields.FloatField')(null=True, blank=True)), + )) + db.send_create_signal('courseware', ['StudentModuleHistory']) + + + def backwards(self, orm): + # Deleting model 'StudentModuleHistory' + db.delete_table('courseware_studentmodulehistory') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + } + } + + complete_apps = ['courseware'] \ No newline at end of file diff --git a/lms/djangoapps/courseware/migrations/0007_allow_null_version_in_history.py b/lms/djangoapps/courseware/migrations/0007_allow_null_version_in_history.py new file mode 100644 index 0000000000..f6204294c4 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0007_allow_null_version_in_history.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Changing field 'StudentModuleHistory.version' + db.alter_column('courseware_studentmodulehistory', 'version', self.gf('django.db.models.fields.CharField')(max_length=255, null=True)) + + def backwards(self, orm): + + # User chose to not deal with backwards NULL issues for 'StudentModuleHistory.version' + raise RuntimeError("Cannot reverse this migration. 'StudentModuleHistory.version' and its values cannot be restored.") + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'db_index': 'True'}) + } + } + + complete_apps = ['courseware'] \ No newline at end of file diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index ac9bde77cd..58ede38d58 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -12,8 +12,10 @@ file and check it in at the same time as your model changes. To do that, ASSUMPTIONS: modules have unique IDs, even across different module_types """ -from django.db import models from django.contrib.auth.models import User +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver class StudentModule(models.Model): """ @@ -60,6 +62,35 @@ class StudentModule(models.Model): self.student.username, self.module_state_key, str(self.state)[:20]]) +class StudentModuleHistory(models.Model): + """Keeps a complete history of state changes for a given XModule for a given + Student. Right now, we restrict this to problems so that the table doesn't + explode in size.""" + + class Meta: + get_latest_by = "created" + + student_module = models.ForeignKey(StudentModule, db_index=True) + version = models.CharField(max_length=255, null=True, blank=True, db_index=True) + + # This should be populated from the modified field in StudentModule + created = models.DateTimeField(db_index=True) + state = models.TextField(null=True, blank=True) + grade = models.FloatField(null=True, blank=True) + max_grade = models.FloatField(null=True, blank=True) + + @receiver(post_save, sender=StudentModule) + def save_history(sender, instance, **kwargs): + if instance.module_type == 'problem': + history_entry = StudentModuleHistory(student_module=instance, + version=None, + created=instance.modified, + state=instance.state, + grade=instance.grade, + max_grade=instance.max_grade) + history_entry.save() + + # TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index fb351e1c01..b6fb31fc25 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -5,10 +5,11 @@ from functools import partial from django.conf import settings from django.core.context_processors import csrf +from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required -from django.http import Http404, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect from mitxmako.shortcuts import render_to_response, render_to_string #from django.views.decorators.csrf import ensure_csrf_cookie @@ -20,7 +21,7 @@ from courseware.access import has_access from courseware.courses import (get_courses, get_course_with_access, get_courses_by_university, sort_by_announcement) import courseware.tabs as tabs -from courseware.models import StudentModule, StudentModuleCache +from courseware.models import StudentModule, StudentModuleCache, StudentModuleHistory from module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor from django_comment_client.utils import get_discussion_title @@ -606,3 +607,47 @@ def progress(request, course_id, student_id=None): context.update() return render_to_response('courseware/progress.html', context) + + +@login_required +def submission_history(request, course_id, student_username, location): + """Render an HTML fragment (meant for inclusion elsewhere) that renders a + history of all state changes made by this user for this problem location. + Right now this only works for problems because that's all + StudentModuleHistory records. + """ + # Make sure our has_access check uses the course_id, eh? or is ourself + course = get_course_with_access(request.user, course_id, 'load') + staff_access = has_access(request.user, course, 'staff') + + if (student_username != request.user.username) and (not staff_access): + raise PermissionDenied + + try: + student = User.objects.get(username=student_username) + student_module = StudentModule.objects.get(course_id=course_id, + module_state_key=location, + student_id=student.id) + except User.DoesNotExist: + return HttpResponse("User {0} does not exist.".format(student_username)) + except StudentModule.DoesNotExist: + return HttpResponse("{0} has never accessed problem {1}" + .format(student_username, location)) + + history_entries = StudentModuleHistory.objects \ + .filter(student_module=student_module).order_by('-created') + + # If no history records exist, let's force a save to get history started. + if not history_entries: + student_module.save() + history_entries = StudentModuleHistory.objects \ + .filter(student_module=student_module).order_by('-created') + + context = { + 'history_entries': history_entries, + 'username': student.username, + 'location': location, + 'course_id': course_id + } + + return render_to_response('courseware/submission_history.html', context) diff --git a/lms/envs/common.py b/lms/envs/common.py index eb8c9989f0..371b7d9dcd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -84,6 +84,10 @@ MITX_FEATURES = { # Flip to True when the YouTube iframe API breaks (again) 'USE_YOUTUBE_OBJECT_API': False, + + # Give a UI to show a student's submission history in a problem by the + # Staff Debug tool. + 'ENABLE_STUDENT_HISTORY_VIEW': True } # Used for A/B testing diff --git a/lms/static/sass/shared/_modal.scss b/lms/static/sass/shared/_modal.scss index 1685487b89..bfa803fee2 100644 --- a/lms/static/sass/shared/_modal.scss +++ b/lms/static/sass/shared/_modal.scss @@ -57,6 +57,8 @@ border: 1px solid rgba(0, 0, 0, 0.9); @include box-shadow(inset 0 1px 0 0 rgba(255, 255, 255, 0.7)); overflow: hidden; + padding-left: 10px; + padding-right: 10px; padding-bottom: 30px; position: relative; z-index: 2; diff --git a/lms/templates/courseware/submission_history.html b/lms/templates/courseware/submission_history.html new file mode 100644 index 0000000000..cd14e9b2ab --- /dev/null +++ b/lms/templates/courseware/submission_history.html @@ -0,0 +1,13 @@ +<% import json %> +

${username} > ${course_id} > ${location}

+ +% for i, entry in enumerate(history_entries): +
+
+#${len(history_entries) - i}: ${entry.created} EST
+Score: ${entry.grade} / ${entry.max_grade} +
+${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h}
+
+
+% endfor diff --git a/lms/templates/courseware/xqa_interface.html b/lms/templates/courseware/xqa_interface.html index c314cc7fb0..89ec77b31f 100644 --- a/lms/templates/courseware/xqa_interface.html +++ b/lms/templates/courseware/xqa_interface.html @@ -5,6 +5,27 @@ function setup_debug(element_id, edit_link, staff_context){ $('#' + element_id + '_trig').leanModal(); $('#' + element_id + '_xqa_log').leanModal(); $('#' + element_id + '_xqa_form').submit(function () {sendlog(element_id, edit_link, staff_context);}); + + $("#" + element_id + "_history_trig").leanModal(); + + $('#' + element_id + '_history_form').submit( + function () { + var username = $("#" + element_id + "_history_student_username").val(); + var location = $("#" + element_id + "_history_location").val(); + + // This is a ridiculous way to get the course_id, but I'm not sure + // how to do it sensibly from within the staff debug code. + // staff_problem_info.html is rendered through a wrapper to get_html + // that's injected by the code that adds the histogram -- it's all + // kinda bizarre, and it remains awkward to simply ask "what course + // is this problem being shown in the context of." + var path_parts = window.location.pathname.split('/'); + var course_id = path_parts[2] + "/" + path_parts[3] + "/" + path_parts[4]; + $("#" + element_id + "_history_text").load('/courses/' + course_id + + "/submission_history/" + username + "/" + location); + return false; + } + ); } function sendlog(element_id, edit_link, staff_context){ diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 9324445dd1..f427709bce 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,3 +1,4 @@ +## The JS for this is defined in xqa_interface.html ${module_content} %if location.category in ['problem','video','html']: % if edit_link: @@ -13,6 +14,10 @@ ${module_content} % endif +% if settings.MITX_FEATURES.get('ENABLE_STUDENT_HISTORY_VIEW'): + +% endif + -
+ + +
+ + + \ No newline at end of file diff --git a/common/static/js/capa/genex/3BFB2B59BF73690E64CA963B37E3E6C2.cache.html b/common/static/js/capa/genex/3BFB2B59BF73690E64CA963B37E3E6C2.cache.html new file mode 100644 index 0000000000..f47030bf01 --- /dev/null +++ b/common/static/js/capa/genex/3BFB2B59BF73690E64CA963B37E3E6C2.cache.html @@ -0,0 +1,631 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/4EC2E5D94B410DDAB081BBAC4222386F.cache.html b/common/static/js/capa/genex/4EC2E5D94B410DDAB081BBAC4222386F.cache.html new file mode 100644 index 0000000000..090d22b68c --- /dev/null +++ b/common/static/js/capa/genex/4EC2E5D94B410DDAB081BBAC4222386F.cache.html @@ -0,0 +1,618 @@ + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/7504BC625F3CBFF0967F88C441871055.cache.html b/common/static/js/capa/genex/7504BC625F3CBFF0967F88C441871055.cache.html new file mode 100644 index 0000000000..143af1d438 --- /dev/null +++ b/common/static/js/capa/genex/7504BC625F3CBFF0967F88C441871055.cache.html @@ -0,0 +1,631 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/88AB039AB796F1D6C3B133DAD892A057.cache.html b/common/static/js/capa/genex/88AB039AB796F1D6C3B133DAD892A057.cache.html new file mode 100644 index 0000000000..a75fd5115e --- /dev/null +++ b/common/static/js/capa/genex/88AB039AB796F1D6C3B133DAD892A057.cache.html @@ -0,0 +1,607 @@ + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/C66BAF3695DBE904ECE0FB5DC56AED92.cache.html b/common/static/js/capa/genex/C66BAF3695DBE904ECE0FB5DC56AED92.cache.html new file mode 100644 index 0000000000..545dcff856 --- /dev/null +++ b/common/static/js/capa/genex/C66BAF3695DBE904ECE0FB5DC56AED92.cache.html @@ -0,0 +1,621 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/clear.cache.gif b/common/static/js/capa/genex/clear.cache.gif new file mode 100644 index 0000000000000000000000000000000000000000..e565824aafafe632011b281cba976baf8b3ba89a GIT binary patch literal 43 qcmZ?wbhEHbWMp7uXkcLY4+e@qSs1y10y+#p0Fq%~V)9{Rum%7ZWeN!Z literal 0 HcmV?d00001 diff --git a/common/static/js/capa/genex/genex.css b/common/static/js/capa/genex/genex.css new file mode 100644 index 0000000000..459c854f92 --- /dev/null +++ b/common/static/js/capa/genex/genex.css @@ -0,0 +1,122 @@ +.genex-button { + margin-right: -8px; + height: 40px !important; +} + +.genex-label { + /*font: normal normal normal 10pt/normal 'Open Sans', Verdana, Geneva, sans-serif !important;*/ + /*padding: 4px 0px 0px 10px !important;*/ + font-family: sans-serif !important; + font-size: 13px !important; + font-style: normal !important; + font-variant: normal !important; + font-weight: bold !important; + padding-top: 6px !important; + margin-left: 18px; +} + +.gwt-HTML { + cursor: default; + overflow-x: auto !important; + overflow-y: auto !important; + background-color: rgb(248, 248, 248) !important; +} + +.genex-scrollpanel { + word-wrap: normal !important; + white-space: pre !important; +} + +pre, #dna-strand { + font-family: 'courier new', courier !important; + font-size: 13px !important; + font-style: normal !important; + font-variant: normal !important; + font-weight: normal !important; + border-style: none !important; + background-color: rgb(248, 248, 248) !important; + word-wrap: normal !important; + white-space: pre !important; + overflow-x: visible !important; + overflow-y: visible !important; +} + +.gwt-DialogBox .Caption { + background: #F1F1F1; + padding: 4px 8px 4px 4px; + cursor: default; + font-family: Arial Unicode MS, Arial, sans-serif; + font-weight: bold; + border-bottom: 1px solid #bbbbbb; + border-top: 1px solid #D2D2D2; +} +.gwt-DialogBox .dialogContent { +} +.gwt-DialogBox .dialogMiddleCenter { + padding: 3px; + background: white; +} +.gwt-DialogBox .dialogBottomCenter { + background: url(images/hborder.png) repeat-x 0px -2945px; + -background: url(images/hborder_ie6.png) repeat-x 0px -2144px; +} +.gwt-DialogBox .dialogMiddleLeft { + background: url(images/vborder.png) repeat-y -31px 0px; +} +.gwt-DialogBox .dialogMiddleRight { + background: url(images/vborder.png) repeat-y -32px 0px; + -background: url(images/vborder_ie6.png) repeat-y -32px 0px; +} +.gwt-DialogBox .dialogTopLeftInner { + width: 10px; + height: 8px; + zoom: 1; +} +.gwt-DialogBox .dialogTopRightInner { + width: 12px; + zoom: 1; +} +.gwt-DialogBox .dialogBottomLeftInner { + width: 10px; + height: 12px; + zoom: 1; +} +.gwt-DialogBox .dialogBottomRightInner { + width: 12px; + height: 12px; + zoom: 1; +} +.gwt-DialogBox .dialogTopLeft { + background: url(images/circles.png) no-repeat -20px 0px; + -background: url(images/circles_ie6.png) no-repeat -20px 0px; +} +.gwt-DialogBox .dialogTopRight { + background: url(images/circles.png) no-repeat -28px 0px; + -background: url(images/circles_ie6.png) no-repeat -28px 0px; +} +.gwt-DialogBox .dialogBottomLeft { + background: url(images/circles.png) no-repeat 0px -36px; + -background: url(images/circles_ie6.png) no-repeat 0px -36px; +} +.gwt-DialogBox .dialogBottomRight { + background: url(images/circles.png) no-repeat -8px -36px; + -background: url(images/circles_ie6.png) no-repeat -8px -36px; +} +* html .gwt-DialogBox .dialogTopLeftInner { + width: 10px; + overflow: hidden; +} +* html .gwt-DialogBox .dialogTopRightInner { + width: 12px; + overflow: hidden; +} +* html .gwt-DialogBox .dialogBottomLeftInner { + width: 10px; + height: 12px; + overflow: hidden; +} +* html .gwt-DialogBox .dialogBottomRightInner { + width: 12px; + height: 12px; + overflow: hidden; +} \ No newline at end of file diff --git a/common/static/js/capa/genex/genex.nocache.js b/common/static/js/capa/genex/genex.nocache.js new file mode 100644 index 0000000000..b130ea3689 --- /dev/null +++ b/common/static/js/capa/genex/genex.nocache.js @@ -0,0 +1,18 @@ +function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='1B3B0A256735176413A40727372820E6',Rb='3BFB2B59BF73690E64CA963B37E3E6C2',Sb='4EC2E5D94B410DDAB081BBAC4222386F',Tb='7504BC625F3CBFF0967F88C441871055',Ub='88AB039AB796F1D6C3B133DAD892A057',Wb=':',pb='::',dc=' + +This html file is for Development Mode support. + diff --git a/common/static/js/capa/genex/images/circles.png b/common/static/js/capa/genex/images/circles.png new file mode 100644 index 0000000000000000000000000000000000000000..2a84b9c32066c484aaa8ad28c0e6f3ff77cd072c GIT binary patch literal 1492 zcma)+`#%$U7{|X88wz10cUnRdAspqj?QpPamZRm8$fegvvmrtvCMTTIQPK>fv#`~a z+%L0Qa%)2)YKaO{3bRCXIXnNsc|EVs^Lf3W-<}^npG$b0r<$t1DgZ#u+soZo?$&Ze zC`09Y@oJ@~+#vD3o*2Lp8orl<3fU_l9st$3{{+d*Qwx+IVF})cJYZslEm}6tRNbY| z04SaGc1Qc2efjC;K&-YWyt~t(Y&j*|AiQ7@s%Yk)ONHN!dpr3%;88J>d$!|7(tbwcu$Ml~RGr9qU&HYxzf{UR7_H4OyAz_x5^IO*J!(CRFPK?;a;Q zm8|H6BT597wY3`8o~fm{^ZQ{MA(;LpwZ@qy{OSM)hsM{Wpy3<4#4GuCf~m`(>foUzdLS{cj1aCbZ?Ot zf;>BDhR0sw%rybTZ}%9WEOzm=j3*f5ExQRQbc|EUry~TPD*`)=*9Km56iw84`ikr2_@#$gu%9f}2T5Fu z=fx9uq~)8#f=J(d!}HVVTnIu5K*4leOQg6lajHkt@Yi#C0M8!rZwNHnd87Ys*!{7b z;JU$fiwbBoZKnR6%gde~Whe|*!Ls%{;!%O;)%;O4nOt^~HscuP6fOQT%i{t2n@@LW zJa~azTVFpR{zCl7_>@>;FN(lFQ3G7Bml56`yD#Q?fjm5ABZ)s;7{tXWkV zK_Zb3it6`Xa?eB*_U_p&J`-FS^|G|3rN!0kANY+saL_N2HK~}nAHL@EmcwEJ)Y8{) zwr^^*VAWXVUxa#M$Dt!|C29fbvYx?gk#%;v5xeq{teqxFHkhGdlvlYL1G4A}(Fr7@ zw5F!!6rj$vo(j$0Ob*@x9uE%>4**w6rmMELw#dQ3Vbn!ZIZjH_ac>)kN{&4h3Xhi; z7h6KeZxxpzj>7W&KfAxLucRjgH_arMEvAJubAsAZSLT`V65)4axN+D4oY(A8rF$kQ zb(D4U1T+*@AWZx+h-I2Xp-`@BUma5KA_ciPnQHbpRoA-I1r$LX+jew@;#@EME$tdu zObiboP}qTonZ7UT2obcC35=2Gp<_!A#2utZ<=BU@pp rt#jL}6|Xb2{4%k46>IeWgi&$&IV5S|BV8g>00001b5ch_0Itp) z=>Px#Cs0gOMF0Q*j*gDKy}iW5#LUdh*4EbE-rnZs=IiV0@9*#N@$vWf_y7O@Ab>o> z00001bW%=J06^y0W&i*H32;bRa{vGeoB#j{oB{OG-x2@-00(qQO+^RW0vQf28HNsl z-v9ss0!c(cR7l6|)3FM|Fc1LHh_oU&m|t*kad9z!;9k^Fa1zDcMT*^BoW!LFu2RV_ z=-^t&N7*Ye^}Gx^2oAmJc!axLdSx}1Ek)r(2GR6RLUMM zb!zmSYuU{zlvs2`(Frq6s7$qELaTALTkDb;CGale%mjMWJJg$K3VdPKpqO<*+~ynQ!je*ZURuC1=L(n>4+ akv;$m!0-oAPJsac00001^@s6g3A^000006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliru*aQy_83lf>^63Bo1OZ7z zK~z}7?U-LkTUQ*%KQ{@%rp3AFVzOB>N!AdZOd49-N|hEfRv*mEUYzVP%%{EXvA8#1 z_f+QNuzMH`?qDr)9mNb?rzm2G(#`BUI`U>KmY#qqk2 zfZP^MuTMqu10xv?P2UMnfOf#6>*Enn9}w0*%pxMmhv;=17}{K%Af1KuQ81%#faqcE zzz?!4NA;ixr4<2qzBIw}r3rwK3qbU+i-2F2<)Pdtbe93j_cWK$=Zt_M%ks7bWAaq! zdCwxUtCXfODT_$qQj)6y8V5#-ct(WBlx%<|9=2L+!B!(80AB28+tiHa6xS?jMsvQ{ z&$i`vcy<1!;fSQvp*G%l)-tHSVYf=njc28ssYC7k`Z{SS7gaiIFrsIB+0ptsX|Rx? zzD^o^wwE0RE^oVUkj@wzMax@fq);i7@Oy z8Be!-D4lC;cIA>o&1i|eRO*l2wOOBd3oigq07KE)Vo8<$tDpRwn7RM%?&G*0&?m58 z92bCHU|$@KW!=5y1NyJs8cWRFj~4K)iens@HC5~GEg#T+?beuhsJ<p-viElyZ|Kd7absSQs#3kn)kZ_F;jAlH3b5eanOe>JWUEa?CpL7api_`(**4}C zsiUKVcs#Du7Szzo#$)gvRp{D*0Q%b;Bxa5)bZtOEbxvDf1c$>>)VN~5bX-)u@U5YU z`uh5cnkFYFt21=v$h$M8JNpk8)rwyey4U(?3Hra)x@xVHlAv%nEQ#HXTxOj}dM}(5 zXtm~`NF;)yC^vvBK&!aC4m=Ep!=HUKwS(!Rswm1B5CB5H4+0LC%MHNR)@Ez+&qvLQ zqQp?=9UVj7oI;`nV@b8U$7lcC@X*DQ z>emGz`M{v?8lI!pGP5ZJw0_y!U>Fpvz#lZbj>?tcb@UGgBT0GyAom*p0000EMUMH{Tt)^YzrjulMdhy7%DGg(u%Wef{#r~0e*ORdfA{X)+txWagA6SR@(X5gcy=QV#7XjYcVXyYmGuB}I14-?iy0WWg+Z8+ zVb&Z8pdfpRr>`sfO-2DeWd?=She578b~+rKoHwFvIpv^3&S znZ$cbsf~tw4yym-^Sy57)jqp;M6*THt?XH_{{o%buOAdeR%h&a+GARJeNx~5sz*!B z*IZS&m*YLZq-ye8N6o%VGd7f4+A4bP0l+XkK Dn$5EQ literal 0 HcmV?d00001 diff --git a/common/static/js/capa/genex/images/hborder.png b/common/static/js/capa/genex/images/hborder.png new file mode 100644 index 0000000000000000000000000000000000000000..ec58ae6126a0f189b14bcaaf06a8cfebaaacffe3 GIT binary patch literal 1995 zcmeAS@N?(olHy`uVBq!ia0vp^3JeU~eH?5+R#0}_1R%v&9OUlAuG#z(zf~Y@3)nH$zj{tZ?T}AQI}UD_Z!QlhHIBqU%X_w^@j6H36riJ=Veu(aH`0@ z{vD#sKtGHKFy+^W4+fP??N0^R)?^+~3$BFxN$}SZ#?unXDvYLo{sbL9U(d|>8Gn}6?2TL8@bvWd_ZOwE zl%^oHsy!aynkVH^c-Kl zYk4Uo^1h92qL4+~OO{XZC!=<-)P6eubx?+0;ek&-eppOu*p)k5ZiaNg_uBLGnu1PU zH+c{&QeZc~Js9X{fxE2BFKh1OwsRNOSZcFec!xjJKjxB|9djP!R^J49%G1@)Wt~$( F697DM5(oeQ literal 0 HcmV?d00001 diff --git a/common/static/js/capa/genex/images/hborder_ie6.png b/common/static/js/capa/genex/images/hborder_ie6.png new file mode 100644 index 0000000000000000000000000000000000000000..2268f88a76174ada5393bf042902f7743a9e8e76 GIT binary patch literal 706 zcmeAS@N?(olHy`uVBq!ia0vp^3JeS!xg5+u){)-3!a$0#ILO_JVcj{Imq1QkfKQ04 zu2-3XZ?#EiyG6_-o1}Tx+3Os$*VyK5axL2GT(aAv@_=Xc(MbnC?A^0>>fw(wkA6II zP$Gv;^uHOH4=FyJ}PrluJ{Qcgu@6Vn+ zd-CeX^S3|WzkmPs!}pILKYsf1^V|1tKYsrC_51Jt|NsBmSR4hqgtNdSvY3H^TL^?1 zFWs&C0~BO0@$_|Nzs)4cXKd=dt5E^y`cs}Rjv*Dd-rjZ3yJNuM5V-97Ba6<*Cfj%1 z{ofS%gh9T8jV1lhzhAlO=GyPOtdKtl@HvnJcz#XAlqc0%j4oJ4}!Na7l@et z5HPKf@7*p)v^g+^HX;S`ZyD~m^5!j;d|)+;aor--1Flyd*f0SVG#qC9DOW1V$j?2; zm7U3NvFZU&lTGdyD`a}rFIGtPKEGHY*&BYbLbliZBF}{8ISfT=;S4+F;#|BN(gU0C r9Qo&B?7;WQBI=PAAM=yXNyb0851P%0GLA5i0$J|q>gTe~DWM4fBnA0p literal 0 HcmV?d00001 diff --git a/common/static/js/capa/genex/images/thumb_horz.png b/common/static/js/capa/genex/images/thumb_horz.png new file mode 100644 index 0000000000000000000000000000000000000000..b43e683e1fc8ff563a0e90c465ed6bf0c12c3924 GIT binary patch literal 222 zcmeAS@N?(olHy`uVBq!ia0vp^>_E)I!VDxgys+>CQq09po*^6@9Je3(KLBzi0(?ST zjg5`}|Nozxn;Q@i(ACxD=;&BeQxhK_e#- zgOR?Wfxe;P-5zNcpaKz37sn8enaK&J;l&98A|fIQ4VxK+%@|MTrwIH2$}@Pn`njxg HN@xNALh?UZ literal 0 HcmV?d00001 diff --git a/common/static/js/capa/genex/images/thumb_vertical.png b/common/static/js/capa/genex/images/thumb_vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..bd57f594ac98980f6eda313abba3f4ed1bed2ae6 GIT binary patch literal 231 zcmeAS@N?(olHy`uVBq!ia0vp^EI`c8!VDzudVhQjq?n7HJVQ7*IBq}me*ol41o(uw z8XFt`|NlQXH#Z<4psTCP(b2J{rY1f<{zP2lbf6l}0*}aI1_o{+5N5n|x9$&6P^QE+ zq9iy!t)x7$D3!r6B|j-u!7Z~WwLHHlyI8?F*tBr#V>6&SQII<4qSVBa%=|oskj&gv z1|xk#1ARloyFJn%*U5UiIEHY{OcoGG5D-lj5K9vfPZtnM5)e)n5M*TF)?*Z3_i%|1 PP$7e-tDnm{r-UW|-D^M6 literal 0 HcmV?d00001 diff --git a/common/static/js/capa/genex/images/vborder.png b/common/static/js/capa/genex/images/vborder.png new file mode 100644 index 0000000000000000000000000000000000000000..6840d11a1227e163a012e526e821babed6736814 GIT binary patch literal 298 zcmeAS@N?(olHy`uVBq!ia0vp^x~AHRJ0^8f$;XVMF7fGRl)JR*x37`TN%nDNrx zx<5cc_7YEDSN7XXl6<<>70#1?0EJXNT^vI+&L5zMQp3{0%&`0Ne@8vu$Q+;s22WQ%mvv4FO#qf+N16Zt literal 0 HcmV?d00001 From da2d0ed6ec25732dc180f31220ca8af39252c7cf Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 25 Feb 2013 11:16:37 -0500 Subject: [PATCH 064/214] Foldit with puzzle leaderboard --- common/lib/xmodule/xmodule/foldit_module.py | 2 +- lms/djangoapps/foldit/models.py | 41 ++++++------- lms/djangoapps/foldit/tests.py | 64 +++++++++++++++------ lms/djangoapps/foldit/views.py | 28 ++++++--- 4 files changed, 86 insertions(+), 49 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index bfe921a068..b80f54a41c 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -73,7 +73,7 @@ class FolditModule(XModule): """ from foldit.models import Score - return [(e['username'], e['total_score']) for e in Score.get_tops_n(10)] + return [(e['username'], e['score']) for e in Score.get_tops_n(10)] def get_html(self): """ diff --git a/lms/djangoapps/foldit/models.py b/lms/djangoapps/foldit/models.py index 3202402e52..df1be3e87c 100644 --- a/lms/djangoapps/foldit/models.py +++ b/lms/djangoapps/foldit/models.py @@ -39,23 +39,23 @@ class Score(models.Model): return (-score) * 10 + 8000 * sum_of # TODO: delete this, incorporate it in get_tops_n - @staticmethod - def get_top_n(puzzle_id, n): - """ - Arguments: - puzzle_id (int): id of the puzzle for which to look - n (int): number of top scores to return. + #@staticmethod + #def get_top_n(puzzle_id, n): + #""" + #Arguments: + #puzzle_id (int): id of the puzzle for which to look + #n (int): number of top scores to return. - Returns: - The top (lowest energy, highest display score) n scores for the puzzle. If - there are fewer than n, returns all. Output is a list of dictionaries, sorted - by display_score: - [ {username: 'a_user', - score: 8500}, ...] - """ - scores = Score.objects.filter(puzzle_id=puzzle_id).order_by('-best_score')[:n] - return [{'username': s.user.username, 'score': Score.display_score(s.best_score)} - for s in scores] + #Returns: + #The top (lowest energy, highest display score) n scores for the puzzle. If + #there are fewer than n, returns all. Output is a list of dictionaries, sorted + #by display_score: + #[ {username: 'a_user', + #score: 8500}, ...] + #""" + #scores = Score.objects.filter(puzzle_id=puzzle_id).order_by('-best_score')[:n] + #return [{'username': s.user.username, 'score': Score.display_score(s.best_score)} + #for s in scores] @staticmethod def get_tops_n(n, puzzles=['994559']): @@ -72,13 +72,14 @@ class Score(models.Model): [ {username: 'a_user', score: 12000} ...] """ - scores = Score.objects.filter(puzzle_id__in=puzzles).annotate( - total_score=models.Sum('best_score')).order_by( - '-total_score')[:n] + scores = Score.objects \ + .filter(puzzle_id__in=puzzles) \ + .annotate(total_score=models.Sum('best_score')) \ + .order_by('-total_score')[:n] num = len(puzzles) return [{'username': s.user.username, - 'total_score': Score.display_score(s.total_score, num)} + 'score': Score.display_score(s.total_score, num)} for s in scores] diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index d560416f4b..b81119d614 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -25,19 +25,22 @@ class FolditTestCase(TestCase): pwd = 'abc' self.user = User.objects.create_user('testuser', 'test@test.com', pwd) + self.user2 = User.objects.create_user('testuser2', 'test2@test.com', pwd) self.unique_user_id = unique_id_for_user(self.user) + self.unique_user_id2 = unique_id_for_user(self.user2) now = datetime.now() self.tomorrow = now + timedelta(days=1) self.yesterday = now - timedelta(days=1) UserProfile.objects.create(user=self.user) + UserProfile.objects.create(user=self.user2) - def make_request(self, post_data): + def make_request(self, post_data, user=self.user): request = self.factory.post(self.url, post_data) - request.user = self.user + request.user = user return request - def make_puzzle_score_request(self, puzzle_ids, best_scores): + def make_puzzle_score_request(self, puzzle_ids, best_scores, user=self.user): """ Given lists of puzzle_ids and best_scores (must have same length), make a SetPlayerPuzzleScores request and return the response. @@ -52,8 +55,8 @@ class FolditTestCase(TestCase): scores = [score_dict(pid, bs) for pid, bs in zip(puzzle_ids, best_scores)] scores_str = json.dumps(scores) - verify = {"Verify": verify_code(self.user.email, scores_str), - "VerifyMethod":"FoldItVerify"} + verify = {"Verify": verify_code(user.email, scores_str), + "VerifyMethod": "FoldItVerify"} data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify), 'SetPlayerPuzzleScores': scores_str} @@ -65,7 +68,7 @@ class FolditTestCase(TestCase): def test_SetPlayerPuzzleScores(self): - puzzle_id = 994391 + puzzle_id = [994391] best_score = 0.078034 response = self.make_puzzle_score_request([puzzle_id], [best_score]) @@ -76,14 +79,12 @@ class FolditTestCase(TestCase): "Status": "Success"}]}])) # There should now be a score in the db. - top_10 = Score.get_top_n(puzzle_id, 10) + top_10 = Score.get_tops_n(puzzle_id, 10) self.assertEqual(len(top_10), 1) self.assertEqual(top_10[0]['score'], Score.display_score(best_score)) - def test_SetPlayerPuzzleScores_many(self): - response = self.make_puzzle_score_request([1, 2], [0.078034, 0.080000]) self.assertEqual(response.content, json.dumps( @@ -96,19 +97,17 @@ class FolditTestCase(TestCase): "Status": "Success"}]}])) - - def test_SetPlayerPuzzleScores_multiple(self): """ Check that multiple posts with the same id are handled properly (keep latest for each user, have multiple users work properly) """ orig_score = 0.07 - puzzle_id = 1 + puzzle_id = ['1'] response = self.make_puzzle_score_request([puzzle_id], [orig_score]) # There should now be a score in the db. - top_10 = Score.get_top_n(puzzle_id, 10) + top_10 = Score.get_tops_n(puzzle_id, 10) self.assertEqual(len(top_10), 1) self.assertEqual(top_10[0]['score'], Score.display_score(best_score)) @@ -116,7 +115,7 @@ class FolditTestCase(TestCase): better_score = 0.06 response = self.make_puzzle_score_request([1], [better_score]) - top_10 = Score.get_top_n(puzzle_id, 10) + top_10 = Score.get_tops_n(puzzle_id, 10) self.assertEqual(len(top_10), 1) self.assertEqual(top_10[0]['score'], Score.display_score(better_score)) @@ -124,24 +123,51 @@ class FolditTestCase(TestCase): worse_score = 0.065 response = self.make_puzzle_score_request([1], [worse_score]) - top_10 = Score.get_top_n(puzzle_id, 10) + top_10 = Score.get_tops_n(puzzle_id, 10) self.assertEqual(len(top_10), 1) # should still be the better score self.assertEqual(top_10[0]['score'], Score.display_score(better_score)) + def test_SetPlayerPyzzleScores_manyplayers(self): + """ + Check that when we send scores from multiple users, the correct order + of scores is displayed. + """ + puzzle_id = ['1'] + player1_score = 0.07 + player2_score = 0.08 + response1 = self.make_puzzle_score_request([puzzle_id], [player1_score], + self.user) + # There should now be a score in the db. + top_10 = Score.get_tops_n(puzzle_id, 10) + self.assertEqual(len(top_10), 1) + self.assertEqual(top_10[0]['score'], Score.display_score(player1_score)) + + response2 = self.make_puzzle_score_request([puzzle_id], [player2_score], + self.user2) + + # There should now be two scores in the db + self.assertEqual(len(top_10), 2) + + # Top score should be player2_score. Second should be player1_score + self.assertEqual(top_10[0]['score'], Score.display_score(player2_score)) + self.assertEqual(top_10[1]['score'], Score.display_score(player1_score)) + + # Top score user should be self.user2.username + self.assertEqual(top_10[0]['username'], self.user2.username) def test_SetPlayerPuzzleScores_error(self): - scores = [ {"PuzzleID": 994391, + scores = [{"PuzzleID": 994391, "ScoreType": "score", "BestScore": 0.078034, - "CurrentScore":0.080035, - "ScoreVersion":23}] + "CurrentScore": 0.080035, + "ScoreVersion": 23}] validation_str = json.dumps(scores) verify = {"Verify": verify_code(self.user.email, validation_str), - "VerifyMethod":"FoldItVerify"} + "VerifyMethod": "FoldItVerify"} # change the real string -- should get an error scores[0]['ScoreVersion'] = 22 diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 8b284704d6..9939d1aa63 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -111,16 +111,26 @@ def save_scores(user, puzzle_scores): current_score = score['CurrentScore'] score_version = score['ScoreVersion'] - # TODO: save the score - # SetPlayerPuzzleScoreResponse object - Score.objects.get_or_create( - user=user, - unique_user_id=unique_id_for_user(user), - puzzle_id=puzzle_id, - best_score=best_score, - current_score=current_score, - score_version=score_version) + # Score entries are unique on user/unique_user_id/puzzle_id/score_version + try: + obj = Score.objects.get( + user=user, + unique_user_id=unique_id_for_user(user), + puzzle_id=puzzle_id, + score_version=score_version) + obj.current_score = current_score + obj.best_score = best_score + + except Score.DoesNotExist: + obj = Score( + user=user, + unique_user_id=unique_id_for_user(user), + puzzle_id=puzzle_id, + current_score=current_score, + best_score=best_score, + score_version=score_version) + obj.save() # TODO: get info from db instead? score_responses.append({'PuzzleID': puzzle_id, From e736ed34f51100281f4e6104d574b2512ea3d4d0 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 25 Feb 2013 11:29:59 -0500 Subject: [PATCH 065/214] added ability to hide/show instructions, if present --- .../lib/xmodule/xmodule/annotatable_module.py | 17 +++- .../xmodule/css/annotatable/display.scss | 81 +++-------------- .../xmodule/js/src/annotatable/display.coffee | 89 +++++++++++-------- common/static/js/capa/annotationinput.js | 12 +-- lms/templates/annotatable.html | 19 ++-- 5 files changed, 101 insertions(+), 117 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 295790e46a..d35127f7af 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -93,6 +93,7 @@ class AnnotatableModule(XModule): 'display_name': self.display_name, 'element_id': self.element_id, 'discussion_id': self.discussion_id, + 'instructions_html': self.instructions_html, 'content_html': self._render_content() } @@ -103,11 +104,25 @@ class AnnotatableModule(XModule): XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + self.element_id = self.location.html_id() + xmltree = etree.fromstring(self.definition['data']) + + # extract discussion id self.discussion_id = xmltree.get('discussion', '') del xmltree.attrib['discussion'] + + # extract instructions text (if any) + instructions = xmltree.find('instructions') + instructions_html = None + if instructions is not None: + instructions.tag = 'div' + instructions_html = etree.tostring(instructions, encoding='unicode') + xmltree.remove(instructions) + self.instructions_html = instructions_html + + # everything else is annotatable content self.content = etree.tostring(xmltree, encoding='unicode') - self.element_id = self.location.html_id() class AnnotatableDescriptor(RawDescriptor): module_class = AnnotatableModule diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 8ce71cd6a7..fa9e153849 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -1,21 +1,22 @@ .annotatable-header { - margin-bottom: 1em; + margin-bottom: .5em; .annotatable-title { font-size: em(22); text-transform: uppercase; padding: 2px 4px; } - .annotatable-description { - position: relative; - font-size: $body-font-size; - padding: 2px 4px; - border: 1px solid $border-color; - border-radius: 3px; - .annotatable-toggle { - position: absolute; - right: 0; - margin: 2px 7px 2px 0; - } +} + +.annotatable-description { + position: relative; + padding: 2px 4px; + border: 1px solid $border-color; + border-radius: 3px; + margin-bottom: .5em; + .annotatable-toggle { + position: absolute; + right: 0; + margin: 2px 7px 2px 0; } } @@ -50,62 +51,6 @@ } } -.annotatable-problems { - margin: 25px 0 0 0; - .annotatable-discussion { - display: none; - } - .annotatable-problem { - border: 1px solid #ccc; - border-radius: 1em; - margin: 0 0 1em 0; - } - .annotatable-problem-header { - font-weight: bold; - border-bottom: 1px solid #ccc; - .annotatable-problem-index { font-weight: normal; } - } - .annotatable-problem-body { - position: relative; - textarea { - display: inline-block; - width: 55%; - } - .annotatable-problem-prompt { - font-style: italic; - } - ul.annotatable-problem-tags { - display: block; - list-style-type: none; - margin: 1em 0; - padding: 0; - li { - cursor: pointer; - display: inline; - padding: .5em; - margin: 0 .5em 0 0; - background-color: #ccc; - border: 1px solid #000; - &.selected { - background-color: rgba(255,255,10,0.3); - } - } - } - .annotatable-problem-controls { - display: inline-block; - margin: 0 4px 0 8px; - } - } - .annotatable-problem-footer {} - - .annotatable-problem-header, - .annotatable-problem-body, - .annotatable-problem-footer { - padding: .5em 1em; - } -} - - .ui-tooltip.qtip.ui-tooltip { font-size: $body-font-size; border: 1px solid #333; diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 7d0a70810f..717ef35446 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -1,15 +1,17 @@ class @Annotatable _debug: false - wrapperSelector: '.annotatable-wrapper' - toggleSelector: '.annotatable-toggle' - spanSelector: '.annotatable-span' - replySelector: '.annotatable-reply' + wrapperSelector: '.annotatable-wrapper' + toggleAnnotationsSelector: '.annotatable-toggle-annotations' + toggleInstructionsSelector: '.annotatable-toggle-instructions' + instructionsSelector: '.annotatable-instructions' + spanSelector: '.annotatable-span' + replySelector: '.annotatable-reply' - problemXModuleSelector: '.xmodule_CapaModule' - problemSelector: 'section.problem' - problemInputSelector: '.annotation-input' - problemReturnSelector: 'section.problem .annotation-return' + problemXModuleSelector: '.xmodule_CapaModule' + problemSelector: 'section.problem' + problemInputSelector: 'section.problem .annotation-input' + problemReturnSelector: 'section.problem .annotation-return' constructor: (el) -> console.log 'loaded Annotatable' if @_debug @@ -24,9 +26,12 @@ class @Annotatable @initTips() initEvents: () -> - # For handling hide/show of annotations + # For handling hide/show of annotations and instructions @annotationsHidden = false - @$(@toggleSelector).bind 'click', @onClickToggleAnnotations + @$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations + + @instructionsHidden = false + @$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions # For handling 'reply to annotation' events that scroll to the associated capa problem. # These are contained in the tooltips, which should be rendered somewhere in the wrapper @@ -68,29 +73,15 @@ class @Annotatable events: show: @onShowTip - onShowTip: (event, api) => - event.preventDefault() if @annotationsHidden + onShowTip: (event, api) => event.preventDefault() if @annotationsHidden - onClickToggleAnnotations: (e) => - @toggleAnnotations() + onClickToggleAnnotations: (e) => @toggleAnnotations() - onClickReply: (e) => - e.preventDefault() - offset = -20 - el = @getProblem e.currentTarget - if el.length > 0 - @scrollTo(el, @afterScrollToProblem, offset) - else - console.log('problem not found. event: ', e) if @_debug + onClickToggleInstructions: (e) => @toggleInstructions() - onClickReturn: (e) => - e.preventDefault() - offset = -200 - el = @getSpanForProblemReturn e.currentTarget - if el.length > 0 - @scrollTo(el, @afterScrollToSpan, offset) - else - console.log('span not found. event:', e) if @_debug + onClickReply: (e) => @replyTo(e.currentTarget) + + onClickReturn: (e) => @returnFrom(e.currentTarget) getSpanForProblemReturn: (el) -> problem_id = $(@problemReturnSelector).index(el) @@ -105,24 +96,48 @@ class @Annotatable toggleAnnotations: () -> hide = (@annotationsHidden = not @annotationsHidden) - @toggleButtonText hide + @toggleAnnotationButtonText hide @toggleSpans hide - @toggleReturnLinks hide @toggleTips hide toggleTips: (hide) -> if hide then @closeAndSaveTips() else @openSavedTips() - toggleReturnLinks: (hide) -> - $(@returnSelector)[if hide then 'hide' else 'show']() - - toggleButtonText: (hide) -> + toggleAnnotationButtonText: (hide) -> buttonText = (if hide then 'Show' else 'Hide')+' Annotations' - @$(@toggleSelector).text(buttonText) + @$(@toggleAnnotationsSelector).text(buttonText) + + toggleInstructions: () -> + hide = (@instructionsHidden = not @instructionsHidden) + @toggleInstructionsButtonText hide + @toggleInstructionsText hide + + toggleInstructionsButtonText: (hide) -> + buttonText = (if hide then 'Show' else 'Hide')+' Instructions' + @$(@toggleInstructionsSelector).text(buttonText) + + toggleInstructionsText: (hide) -> + @$(@instructionsSelector)[if hide then 'slideUp' else 'slideDown']() toggleSpans: (hide) -> @$(@spanSelector).toggleClass 'hide', hide, 250 + replyTo: (buttonEl) -> + offset = -20 + el = @getProblem buttonEl + if el.length > 0 + @scrollTo(el, @afterScrollToProblem, offset) + else + console.log('problem not found. event: ', e) if @_debug + + returnFrom: (buttonEl) -> + offset = -200 + el = @getSpanForProblemReturn buttonEl + if el.length > 0 + @scrollTo(el, @afterScrollToSpan, offset) + else + console.log('span not found. event:', e) if @_debug + scrollTo: (el, after, offset = -20) -> $('html,body').scrollTo(el, { duration: 500 diff --git a/common/static/js/capa/annotationinput.js b/common/static/js/capa/annotationinput.js index 77fc6fa2ea..47b8ad342f 100644 --- a/common/static/js/capa/annotationinput.js +++ b/common/static/js/capa/annotationinput.js @@ -23,18 +23,18 @@ }, onChangeComment: function(e) { var value_el = this.findValueEl(e.target); - var current_value = this.currentValue(value_el); + var current_value = this.loadValue(value_el); var target_value = $(e.target).val(); current_value.comment = target_value; - this.setValue(value_el, current_value); + this.storeValue(value_el, current_value); }, onClickTag: function(e) { var target_el = e.target, target_value, target_index; var value_el, current_value; value_el = this.findValueEl(e.target); - current_value = this.currentValue(value_el); + current_value = this.loadValue(value_el); target_value = $(e.target).data('id'); if(!$(target_el).hasClass('selected')) { @@ -46,14 +46,14 @@ } } - this.setValue(value_el, current_value); + this.storeValue(value_el, current_value); $(target_el).toggleClass('selected'); }, findValueEl: function(target_el) { var input_el = $(target_el).closest(this.inputSelector); return $(this.valueSelector, input_el); }, - currentValue: function(value_el) { + loadValue: function(value_el) { var json = $(value_el).val(); var result = JSON.parse(json); @@ -69,7 +69,7 @@ return result; }, - setValue: function(value_el, new_value) { + storeValue: function(value_el, new_value) { var json = JSON.stringify(new_value); $(value_el).val(json); } diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index a5cf5efb82..ca7413adec 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -3,10 +3,19 @@ % if display_name is not UNDEFINED and display_name is not None:
${display_name}
% endif -
- Guided Discussion - Hide Annotations -
-
+
+ + % if instructions_html is not UNDEFINED and instructions_html is not None: +
+ Instructions + Hide Instructions +
+
${instructions_html}
+ % endif + +
+ Guided Discussion + Hide Annotations +
${content_html}
From 1721793e99d04579301b84e5dbbd0c1f2e97ab27 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 25 Feb 2013 12:33:43 -0500 Subject: [PATCH 066/214] moved annotation return styling effect to css --- .../xmodule/css/annotatable/display.scss | 22 ++++++++++++++----- .../xmodule/js/src/annotatable/display.coffee | 3 ++- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index fa9e153849..2870ba990f 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -25,17 +25,27 @@ cursor: pointer; @each $highlight in ( - (red rgba(178,19,16,0.3)), (orange rgba(255,165,0,0.3)), - (yellow rgba(255,255,10,0.3)), (green rgba(25,255,132,0.3)), - (blue rgba(35,163,255,0.3)), (purple rgba(115,9,178,0.3))) { + (red rgba(178,19,16,0.3) rgba(178,19,16,0.9)), + (orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)), + (yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)), + (green rgba(25,255,132,0.3) rgba(25,255,132,0.9)), + (blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)), + (purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) { $marker: nth($highlight,1); $color: nth($highlight,2); + $selected_color: nth($highlight,3); - @if $marker == yellow { - &.highlight { background-color: $color; } + @if $marker == yellow { + &.highlight { + background-color: $color; + &.selected { background-color: $selected_color; } + } + } + &.highlight-#{$marker} { + background-color: $color; + &.selected { background-color: $selected_color; } } - &.highlight-#{$marker} { background-color: $color; } } &.hide { diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 717ef35446..43d0536d32 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -149,7 +149,8 @@ class @Annotatable problem_el.effect 'highlight', {}, 500 afterScrollToSpan: (span_el) -> - span_el.effect 'highlight', {color: 'rgba(0,0,0,0.5)' }, 1000 + span_el.addClass 'selected', 400, 'swing', -> + span_el.removeClass 'selected', 400, 'swing' makeTipContent: (el) -> (api) => From 7b195eb0ee2547518c44a64b641e931ef07c3148 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 25 Feb 2013 13:24:05 -0500 Subject: [PATCH 067/214] fixed error if highlight not defined --- common/lib/xmodule/xmodule/annotatable_module.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index d35127f7af..7ed0906c00 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -28,20 +28,19 @@ class AnnotatableModule(XModule): and an XML key to delete from the element. """ + attr = {} cls = ['annotatable-span', 'highlight'] - - highlight_key = 'highlight' - color = el.get(highlight_key) valid_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] + highlight_key = 'highlight' + + color = el.get(highlight_key) if color is not None and color in valid_colors: cls.append('highlight-'+color) + attr['_delete'] = highlight_key - cls_str = ' '.join(cls) + attr['value'] = ' '.join(cls) - return { 'class': { - 'value': cls_str, - '_delete': highlight_key } - } + return { 'class' : attr } def _get_annotation_data_attr(self, index, el): """ Returns a dict in which the keys are the HTML data attributes From 1f160d6ef4659bc1a6b0ac0f19424c53aae55616 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 25 Feb 2013 16:29:19 -0500 Subject: [PATCH 068/214] added a box around the instructions text --- .../lib/xmodule/xmodule/annotatable_module.py | 35 ++++++++----------- .../xmodule/css/annotatable/display.scss | 24 +++++++++---- .../xmodule/js/src/annotatable/display.coffee | 10 +++--- .../xmodule/tests/test_annotatable_module.py | 7 ++++ lms/templates/annotatable.html | 17 +++++---- 5 files changed, 56 insertions(+), 37 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_annotatable_module.py diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 7ed0906c00..7a0adc0bf2 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -12,7 +12,6 @@ from xmodule.contentstore.content import StaticContent log = logging.getLogger(__name__) class AnnotatableModule(XModule): - # Note: js and css in common/lib/xmodule/xmodule js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/html/display.coffee'), @@ -77,22 +76,32 @@ class AnnotatableModule(XModule): attr = {} attr.update(self._get_annotation_class_attr(index, el)) attr.update(self._get_annotation_data_attr(index, el)) + for key in attr.keys(): el.set(key, attr[key]['value']) - if '_delete' in attr[key]: + if '_delete' in attr[key] and attr[key]['_delete'] is not None: delete_key = attr[key]['_delete'] del el.attrib[delete_key] + index += 1 return etree.tostring(xmltree, encoding='unicode') + def _extract_instructions(self, xmltree): + """ Removes from the xmltree and returns them as a string, otherwise None. """ + instructions = xmltree.find('instructions') + if instructions is not None: + instructions.tag = 'div' + xmltree.remove(instructions) + return etree.tostring(instructions, encoding='unicode') + return None + def get_html(self): """ Renders parameters to template. """ context = { 'display_name': self.display_name, 'element_id': self.element_id, - 'discussion_id': self.discussion_id, - 'instructions_html': self.instructions_html, + 'instructions_html': self.instructions, 'content_html': self._render_content() } @@ -103,25 +112,11 @@ class AnnotatableModule(XModule): XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - self.element_id = self.location.html_id() - xmltree = etree.fromstring(self.definition['data']) - # extract discussion id - self.discussion_id = xmltree.get('discussion', '') - del xmltree.attrib['discussion'] - - # extract instructions text (if any) - instructions = xmltree.find('instructions') - instructions_html = None - if instructions is not None: - instructions.tag = 'div' - instructions_html = etree.tostring(instructions, encoding='unicode') - xmltree.remove(instructions) - self.instructions_html = instructions_html - - # everything else is annotatable content + self.instructions = self._extract_instructions(xmltree) self.content = etree.tostring(xmltree, encoding='unicode') + self.element_id = self.location.html_id() class AnnotatableDescriptor(RawDescriptor): module_class = AnnotatableModule diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 2870ba990f..eef4ab28b7 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -7,19 +7,29 @@ } } -.annotatable-description { +.annotatable-section { position: relative; - padding: 2px 4px; + padding: .5em 1em; border: 1px solid $border-color; - border-radius: 3px; + border-radius: .5em; margin-bottom: .5em; - .annotatable-toggle { - position: absolute; - right: 0; - margin: 2px 7px 2px 0; + + .annotatable-section-title {} + .annotatable-section-body { + border-top: 1px solid $border-color; + margin-top: .5em; + padding-top: .5em; } } +.annotatable-toggle { + position: absolute; + right: 0; + margin: 2px 1em 2px 0; + &.expanded:after { content: " \2191" } + &.collapsed:after { content: " \2193" } +} + .annotatable-span { display: inline; cursor: pointer; diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 43d0536d32..bb4ddf5404 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -5,6 +5,7 @@ class @Annotatable toggleAnnotationsSelector: '.annotatable-toggle-annotations' toggleInstructionsSelector: '.annotatable-toggle-instructions' instructionsSelector: '.annotatable-instructions' + sectionSelector: '.annotatable-section' spanSelector: '.annotatable-span' replySelector: '.annotatable-reply' @@ -109,12 +110,13 @@ class @Annotatable toggleInstructions: () -> hide = (@instructionsHidden = not @instructionsHidden) - @toggleInstructionsButtonText hide + @toggleInstructionsButton hide @toggleInstructionsText hide - toggleInstructionsButtonText: (hide) -> - buttonText = (if hide then 'Show' else 'Hide')+' Instructions' - @$(@toggleInstructionsSelector).text(buttonText) + toggleInstructionsButton: (hide) -> + txt = (if hide then 'Expand' else 'Collapse')+' Instructions' + cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded']) + @$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1]) toggleInstructionsText: (hide) -> @$(@instructionsSelector)[if hide then 'slideUp' else 'slideDown']() diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py new file mode 100644 index 0000000000..5d270d2350 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -0,0 +1,7 @@ +"""Module annotatable tests""" + +import unittest +from xmodule import annotatable + +class AnnotatableModuleTestCase(unittest.TestCase): + diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index ca7413adec..abefe77f1b 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -1,4 +1,4 @@ -
+
% if display_name is not UNDEFINED and display_name is not None:
${display_name}
@@ -6,16 +6,21 @@
% if instructions_html is not UNDEFINED and instructions_html is not None: -
- Instructions - Hide Instructions +
+
+ Instructions + Collapse Instructions +
+
+ ${instructions_html} +
-
${instructions_html}
% endif -
+
Guided Discussion Hide Annotations
+
${content_html}
From 26eaf8985c6c9bc5d00a011dfa8050f47f831154 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 25 Feb 2013 17:28:22 -0500 Subject: [PATCH 069/214] Allow progress without leaderboard and vice-versa --- common/lib/xmodule/xmodule/foldit_module.py | 58 ++++++++++++++++++--- lms/templates/foldit.html | 47 +++-------------- 2 files changed, 58 insertions(+), 47 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index b80f54a41c..6cbf22980b 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor log = logging.getLogger(__name__) class FolditModule(XModule): + + css = {'scss': [resource_string(__name__, 'css/foldit/leadeboard.scss')]} + def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - # ooh look--I'm lazy, so hardcoding the 7.00x required level. - # If we need it generalized, can pull from the xml later - self.required_level = 4 - self.required_sublevel = 5 + """ + + Example: + + """ + req_level = self.metadata.get("required_level") + req_sublevel = self.metadata.get("required_sublevel") + + # default to what Spring_7012x uses + self.required_level = req_level if req_level else 4 + self.required_sublevel = req_sublevel if req_sublevel else 5 def parse_due_date(): """ @@ -83,16 +96,47 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) + showbasic = (self.metadata.get("show_basic_score") == "true") + showleader = (self.metadata.get("show_leaderboard") == "true") context = { 'due': self.due_str, 'success': self.is_complete(), 'goal_level': goal_level, 'completed': self.completed_puzzles(), 'top_scores': self.puzzle_leaders(), + 'show_basic': showbasic, + 'show_leader': showleader, + 'folditbasic': self.get_basicpuzzles_html(), + 'folditchallenge': self.get_challenge_html() } return self.system.render_template('foldit.html', context) + def get_basicpuzzles_html(self): + """ + Render html for the basic puzzle section. + """ + goal_level = '{0}-{1}'.format( + self.required_level, + self.required_sublevel) + + context = { + 'due': self.due_str, + 'success': self.is_complete(), + 'goal_level': goal_level, + 'completed': self.completed_puzzles(), + } + return self.system.render_template('folditbasic.html', context) + + def get_challenge_html(self): + """ + Render html for challenge (i.e., the leaderboard) + """ + + context = { + 'top_scores': self.puzzle_leaders()} + + return self.system.render_template('folditchallenge.html', context) def get_score(self): """ @@ -109,7 +153,7 @@ class FolditModule(XModule): class FolditDescriptor(XmlDescriptor, EditingDescriptor): """ - Module for adding open ended response questions to courses + Module for adding Foldit problems to courses """ mako_template = "widgets/html-edit.html" module_class = FolditModule @@ -129,6 +173,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor): @classmethod def definition_from_xml(cls, xml_object, system): """ - For now, don't need anything from the xml + Get the xml_object's attributes. """ - return {} + return {'metadata': xml_object.attrib} diff --git a/lms/templates/foldit.html b/lms/templates/foldit.html index 2460e25f8e..2a8271cc62 100644 --- a/lms/templates/foldit.html +++ b/lms/templates/foldit.html @@ -1,45 +1,12 @@
-

Due: ${due} - -

-Status: -% if success: -You have successfully gotten to level ${goal_level}. -% else: -You have not yet gotten to level ${goal_level}. -% endif -

- -

Completed puzzles

- - - - - - - % for puzzle in completed: - - - - - % endfor -
LevelSubmitted
${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}${puzzle['created'].strftime('%Y-%m-%d %H:%M')}
- -
-

Puzzle Leaderboard

+ % if show_basic: + ${folditbasic} + % endif - - - - - - % for pair in top_scores: - - - - - % endfor -
UserScore
${pair[0]}${pair[1]}
+ + % if show_leader: + ${folditchallenge} + % endif
From edba0978cfa302833ae75786dcb53453e3aee5ee Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 25 Feb 2013 18:18:46 -0500 Subject: [PATCH 070/214] Included styling and template --- .../xmodule/css/foldit/leaderboard.scss | 20 +++++++++++++ common/lib/xmodule/xmodule/foldit_module.py | 2 +- lms/templates/folditbasic.html | 29 +++++++++++++++++++ lms/templates/folditchallenge.html | 16 ++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/css/foldit/leaderboard.scss create mode 100644 lms/templates/folditbasic.html create mode 100644 lms/templates/folditchallenge.html diff --git a/common/lib/xmodule/xmodule/css/foldit/leaderboard.scss b/common/lib/xmodule/xmodule/css/foldit/leaderboard.scss new file mode 100644 index 0000000000..5342c985c2 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/foldit/leaderboard.scss @@ -0,0 +1,20 @@ +$leaderboard: #F4F4F4; + +section.foldit { + div.folditchallenge { + table { + border: 1px solid lighten($leaderboard, 10%); + border-collapse: collapse; + margin-top: 20px; + } + th { + background: $leaderboard; + color: darken($leaderboard, 25%); + } + td { + background: lighten($leaderboard, 3%); + border-bottom: 1px solid #fff; + padding: 8px; + } + } +} diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 6cbf22980b..7f46a34b0f 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) class FolditModule(XModule): - css = {'scss': [resource_string(__name__, 'css/foldit/leadeboard.scss')]} + css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]} def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): diff --git a/lms/templates/folditbasic.html b/lms/templates/folditbasic.html new file mode 100644 index 0000000000..0c79a53703 --- /dev/null +++ b/lms/templates/folditbasic.html @@ -0,0 +1,29 @@ +
+

Due: ${due} + +

+ Status: + % if success: + You have successfully gotten to level ${goal_level}. + % else: + You have not yet gotten to level ${goal_level}. + % endif +

+ +

Completed puzzles

+ + + + + + + % for puzzle in completed: + + + + + % endfor +
LevelSubmitted
${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}${puzzle['created'].strftime('%Y-%m-%d %H:%M')}
+ +
+
diff --git a/lms/templates/folditchallenge.html b/lms/templates/folditchallenge.html new file mode 100644 index 0000000000..677bc286c8 --- /dev/null +++ b/lms/templates/folditchallenge.html @@ -0,0 +1,16 @@ +
+

Puzzle Leaderboard

+ + + + + + + % for pair in top_scores: + + + + + % endfor +
UserScore
${pair[0]}${pair[1]}
+
From 3473a5d3b0e15e4cd48974b76bc1bc6bc53e9c63 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 25 Feb 2013 18:39:59 -0500 Subject: [PATCH 071/214] Fixed broken reference --- lms/djangoapps/foldit/tests.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index b81119d614..3a0fe54503 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -35,16 +35,18 @@ class FolditTestCase(TestCase): UserProfile.objects.create(user=self.user) UserProfile.objects.create(user=self.user2) - def make_request(self, post_data, user=self.user): + def make_request(self, post_data, user=None): request = self.factory.post(self.url, post_data) - request.user = user + request.user = self.user if not user else user return request - def make_puzzle_score_request(self, puzzle_ids, best_scores, user=self.user): + def make_puzzle_score_request(self, puzzle_ids, best_scores, user=None): """ Given lists of puzzle_ids and best_scores (must have same length), make a SetPlayerPuzzleScores request and return the response. """ + user = self.user if not user else user + def score_dict(puzzle_id, best_score): return {"PuzzleID": puzzle_id, "ScoreType": "score", @@ -109,7 +111,7 @@ class FolditTestCase(TestCase): # There should now be a score in the db. top_10 = Score.get_tops_n(puzzle_id, 10) self.assertEqual(len(top_10), 1) - self.assertEqual(top_10[0]['score'], Score.display_score(best_score)) + self.assertEqual(top_10[0]['score'], Score.display_score(orig_score)) # Reporting a better score should overwrite better_score = 0.06 From b4387b287d4d8758f9eb98d2dcee25b30e8e9d2e Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 25 Feb 2013 18:56:58 -0500 Subject: [PATCH 072/214] Adding migrations --- .../foldit/migrations/0001_initial.py | 111 ++++++++++++++++++ lms/djangoapps/foldit/migrations/__init__.py | 0 2 files changed, 111 insertions(+) create mode 100644 lms/djangoapps/foldit/migrations/0001_initial.py create mode 100644 lms/djangoapps/foldit/migrations/__init__.py diff --git a/lms/djangoapps/foldit/migrations/0001_initial.py b/lms/djangoapps/foldit/migrations/0001_initial.py new file mode 100644 index 0000000000..6c9edfeaa4 --- /dev/null +++ b/lms/djangoapps/foldit/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Score' + db.create_table('foldit_score', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='foldit_scores', to=orm['auth.User'])), + ('unique_user_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('puzzle_id', self.gf('django.db.models.fields.IntegerField')()), + ('best_score', self.gf('django.db.models.fields.FloatField')(db_index=True)), + ('current_score', self.gf('django.db.models.fields.FloatField')(db_index=True)), + ('score_version', self.gf('django.db.models.fields.IntegerField')()), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('foldit', ['Score']) + + # Adding model 'PuzzleComplete' + db.create_table('foldit_puzzlecomplete', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='foldit_puzzles_complete', to=orm['auth.User'])), + ('unique_user_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('puzzle_id', self.gf('django.db.models.fields.IntegerField')()), + ('puzzle_set', self.gf('django.db.models.fields.IntegerField')(db_index=True)), + ('puzzle_subset', self.gf('django.db.models.fields.IntegerField')(db_index=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('foldit', ['PuzzleComplete']) + + # Adding unique constraint on 'PuzzleComplete', fields ['user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'] + db.create_unique('foldit_puzzlecomplete', ['user_id', 'puzzle_id', 'puzzle_set', 'puzzle_subset']) + + + def backwards(self, orm): + # Removing unique constraint on 'PuzzleComplete', fields ['user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'] + db.delete_unique('foldit_puzzlecomplete', ['user_id', 'puzzle_id', 'puzzle_set', 'puzzle_subset']) + + # Deleting model 'Score' + db.delete_table('foldit_score') + + # Deleting model 'PuzzleComplete' + db.delete_table('foldit_puzzlecomplete') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'foldit.puzzlecomplete': { + 'Meta': {'ordering': "['puzzle_id']", 'unique_together': "(('user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'),)", 'object_name': 'PuzzleComplete'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'puzzle_id': ('django.db.models.fields.IntegerField', [], {}), + 'puzzle_set': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'puzzle_subset': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'unique_user_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'foldit_puzzles_complete'", 'to': "orm['auth.User']"}) + }, + 'foldit.score': { + 'Meta': {'object_name': 'Score'}, + 'best_score': ('django.db.models.fields.FloatField', [], {'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'current_score': ('django.db.models.fields.FloatField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'puzzle_id': ('django.db.models.fields.IntegerField', [], {}), + 'score_version': ('django.db.models.fields.IntegerField', [], {}), + 'unique_user_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'foldit_scores'", 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['foldit'] \ No newline at end of file diff --git a/lms/djangoapps/foldit/migrations/__init__.py b/lms/djangoapps/foldit/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 414c62115ce3564028bda381e0d42c2f3f6f4889 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 25 Feb 2013 20:12:33 -0500 Subject: [PATCH 073/214] Fixed tests and list check --- lms/djangoapps/foldit/models.py | 2 ++ lms/djangoapps/foldit/tests.py | 39 +++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/foldit/models.py b/lms/djangoapps/foldit/models.py index df1be3e87c..703962f422 100644 --- a/lms/djangoapps/foldit/models.py +++ b/lms/djangoapps/foldit/models.py @@ -72,6 +72,8 @@ class Score(models.Model): [ {username: 'a_user', score: 12000} ...] """ + if not(type(puzzles) == list): + puzzles = [puzzles] scores = Score.objects \ .filter(puzzle_id__in=puzzles) \ .annotate(total_score=models.Sum('best_score')) \ diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 3a0fe54503..7127651601 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -45,6 +45,10 @@ class FolditTestCase(TestCase): Given lists of puzzle_ids and best_scores (must have same length), make a SetPlayerPuzzleScores request and return the response. """ + if not(type(best_scores) == list): + best_scores = [best_scores] + if not(type(puzzle_ids) == list): + puzzle_ids = [puzzle_ids] user = self.user if not user else user def score_dict(puzzle_id, best_score): @@ -62,7 +66,7 @@ class FolditTestCase(TestCase): data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify), 'SetPlayerPuzzleScores': scores_str} - request = self.make_request(data) + request = self.make_request(data, user) response = foldit_ops(request) self.assertEqual(response.status_code, 200) @@ -70,9 +74,9 @@ class FolditTestCase(TestCase): def test_SetPlayerPuzzleScores(self): - puzzle_id = [994391] + puzzle_id = 994391 best_score = 0.078034 - response = self.make_puzzle_score_request([puzzle_id], [best_score]) + response = self.make_puzzle_score_request(puzzle_id, [best_score]) self.assertEqual(response.content, json.dumps( [{"OperationID": "SetPlayerPuzzleScores", @@ -81,7 +85,7 @@ class FolditTestCase(TestCase): "Status": "Success"}]}])) # There should now be a score in the db. - top_10 = Score.get_tops_n(puzzle_id, 10) + top_10 = Score.get_tops_n(10, puzzle_id) self.assertEqual(len(top_10), 1) self.assertEqual(top_10[0]['score'], Score.display_score(best_score)) @@ -105,11 +109,11 @@ class FolditTestCase(TestCase): (keep latest for each user, have multiple users work properly) """ orig_score = 0.07 - puzzle_id = ['1'] + puzzle_id = '1' response = self.make_puzzle_score_request([puzzle_id], [orig_score]) # There should now be a score in the db. - top_10 = Score.get_tops_n(puzzle_id, 10) + top_10 = Score.get_tops_n(10, puzzle_id) self.assertEqual(len(top_10), 1) self.assertEqual(top_10[0]['score'], Score.display_score(orig_score)) @@ -117,20 +121,26 @@ class FolditTestCase(TestCase): better_score = 0.06 response = self.make_puzzle_score_request([1], [better_score]) - top_10 = Score.get_tops_n(puzzle_id, 10) + top_10 = Score.get_tops_n(10, puzzle_id) self.assertEqual(len(top_10), 1) - self.assertEqual(top_10[0]['score'], Score.display_score(better_score)) + + # Floats always get in the way, so do almostequal + self.assertAlmostEqual(top_10[0]['score'], + Score.display_score(better_score), + delta=0.5) # reporting a worse score shouldn't worse_score = 0.065 response = self.make_puzzle_score_request([1], [worse_score]) - top_10 = Score.get_tops_n(puzzle_id, 10) + top_10 = Score.get_tops_n(10, puzzle_id) self.assertEqual(len(top_10), 1) # should still be the better score - self.assertEqual(top_10[0]['score'], Score.display_score(better_score)) + self.assertAlmostEqual(top_10[0]['score'], + Score.display_score(better_score), + delta=0.5) - def test_SetPlayerPyzzleScores_manyplayers(self): + def test_SetPlayerPuzzleScores_manyplayers(self): """ Check that when we send scores from multiple users, the correct order of scores is displayed. @@ -138,18 +148,19 @@ class FolditTestCase(TestCase): puzzle_id = ['1'] player1_score = 0.07 player2_score = 0.08 - response1 = self.make_puzzle_score_request([puzzle_id], [player1_score], + response1 = self.make_puzzle_score_request(puzzle_id, player1_score, self.user) # There should now be a score in the db. - top_10 = Score.get_tops_n(puzzle_id, 10) + top_10 = Score.get_tops_n(10, puzzle_id) self.assertEqual(len(top_10), 1) self.assertEqual(top_10[0]['score'], Score.display_score(player1_score)) - response2 = self.make_puzzle_score_request([puzzle_id], [player2_score], + response2 = self.make_puzzle_score_request(puzzle_id, player2_score, self.user2) # There should now be two scores in the db + top_10 = Score.get_tops_n(10, puzzle_id) self.assertEqual(len(top_10), 2) # Top score should be player2_score. Second should be player1_score From adaa8463e3fa90e25f177f2f589170bfac02366e Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 26 Feb 2013 10:03:20 -0500 Subject: [PATCH 074/214] Removed commented-out fn --- lms/djangoapps/foldit/models.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/lms/djangoapps/foldit/models.py b/lms/djangoapps/foldit/models.py index 703962f422..7041be1446 100644 --- a/lms/djangoapps/foldit/models.py +++ b/lms/djangoapps/foldit/models.py @@ -38,24 +38,6 @@ class Score(models.Model): """ return (-score) * 10 + 8000 * sum_of - # TODO: delete this, incorporate it in get_tops_n - #@staticmethod - #def get_top_n(puzzle_id, n): - #""" - #Arguments: - #puzzle_id (int): id of the puzzle for which to look - #n (int): number of top scores to return. - - #Returns: - #The top (lowest energy, highest display score) n scores for the puzzle. If - #there are fewer than n, returns all. Output is a list of dictionaries, sorted - #by display_score: - #[ {username: 'a_user', - #score: 8500}, ...] - #""" - #scores = Score.objects.filter(puzzle_id=puzzle_id).order_by('-best_score')[:n] - #return [{'username': s.user.username, 'score': Score.display_score(s.best_score)} - #for s in scores] @staticmethod def get_tops_n(n, puzzles=['994559']): From 18233ef0d78a9ab390444ded01f7e9b6ad5d6045 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 26 Feb 2013 12:15:43 -0500 Subject: [PATCH 075/214] bolded the section titles and added unittest skeleton for module --- .../lib/xmodule/xmodule/annotatable_module.py | 29 +++++++------ .../xmodule/css/annotatable/display.scss | 5 ++- .../xmodule/tests/test_annotatable_module.py | 42 ++++++++++++++++++- lms/templates/annotatable.html | 6 ++- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 7a0adc0bf2..9e492f755a 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -63,26 +63,29 @@ class AnnotatableModule(XModule): return data_attrs + def _render_annotation(self, index, el): + """ Renders an annotation element for HTML output. """ + attr = {} + attr.update(self._get_annotation_class_attr(index, el)) + attr.update(self._get_annotation_data_attr(index, el)) + + el.tag = 'span' + + for key in attr.keys(): + el.set(key, attr[key]['value']) + if '_delete' in attr[key] and attr[key]['_delete'] is not None: + delete_key = attr[key]['_delete'] + del el.attrib[delete_key] + + def _render_content(self): """ Renders annotatable content with annotation spans and returns HTML. """ - xmltree = etree.fromstring(self.content) xmltree.tag = 'div' index = 0 for el in xmltree.findall('.//annotation'): - el.tag = 'span' - - attr = {} - attr.update(self._get_annotation_class_attr(index, el)) - attr.update(self._get_annotation_data_attr(index, el)) - - for key in attr.keys(): - el.set(key, attr[key]['value']) - if '_delete' in attr[key] and attr[key]['_delete'] is not None: - delete_key = attr[key]['_delete'] - del el.attrib[delete_key] - + self._render_annotation(index, el) index += 1 return etree.tostring(xmltree, encoding='unicode') diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index eef4ab28b7..fc22537899 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -14,7 +14,10 @@ border-radius: .5em; margin-bottom: .5em; - .annotatable-section-title {} + .annotatable-section-title { + font-weight: bold; + a { font-weight: normal; } + } .annotatable-section-body { border-top: 1px solid $border-color; margin-top: .5em; diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py index 5d270d2350..422372b1b0 100644 --- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -1,7 +1,47 @@ """Module annotatable tests""" import unittest -from xmodule import annotatable + +from lxml import etree +from mock import Mock + +from xmodule.annotatable_module import AnnotatableModule +from xmodule.modulestore import Location + +from . import test_system class AnnotatableModuleTestCase(unittest.TestCase): + location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"]) + sample_text = ''' + + Read the text. +

+ Sing, + O goddess, + the anger of Achilles son of Peleus, + that brought countless ills upon the Achaeans. Many a brave soul did it send + hurrying down to Hades, and many a hero did it yield a prey to dogs and +

vultures, for so were the counsels + of Jove fulfilled from the day on which the son of Atreus, king of men, and great + Achilles, first fell out with one another.
+

+ The Iliad of Homer by Samuel Butler +
+ ''' + definition = { 'data': sample_text } + descriptor = Mock() + instance_state = None + shared_state = None + annotation_el = { + 'tag': 'annotation', + 'attrib': [ + 'title', + 'body', # required + 'problem', + 'highlight' + ] + } + + def setUp(self): + self.annotatable = AnnotatableModule(test_system, self.location, self.definition, self.descriptor, self.instance_state, self.shared_state) diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index abefe77f1b..bdb5a8acc3 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -18,8 +18,10 @@ % endif
- Guided Discussion - Hide Annotations +
+ Guided Discussion + Hide Annotations +
${content_html}
From b6f3042c1d16f32e78279b0a576c13d6cde5248d Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 26 Feb 2013 12:38:09 -0500 Subject: [PATCH 076/214] Incorporate Victor's suggestions --- common/lib/xmodule/xmodule/foldit_module.py | 4 ++-- lms/djangoapps/foldit/views.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 7f46a34b0f..3990a61183 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -96,8 +96,8 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) - showbasic = (self.metadata.get("show_basic_score") == "true") - showleader = (self.metadata.get("show_leaderboard") == "true") + showbasic = (self.metadata.get("show_basic_score").lower() == "true") + showleader = (self.metadata.get("show_leaderboard").lower() == "true") context = { 'due': self.due_str, 'success': self.is_complete(), diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 9939d1aa63..988c113d23 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -132,7 +132,6 @@ def save_scores(user, puzzle_scores): score_version=score_version) obj.save() - # TODO: get info from db instead? score_responses.append({'PuzzleID': puzzle_id, 'Status': 'Success'}) From 12b30c1b69f23a3dcca1dca3439478ce1a53ff8f Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 26 Feb 2013 14:24:10 -0500 Subject: [PATCH 077/214] added unit test --- .../xmodule/tests/test_annotatable_module.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py index 422372b1b0..7a87dcc16d 100644 --- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -33,15 +33,18 @@ class AnnotatableModuleTestCase(unittest.TestCase): instance_state = None shared_state = None - annotation_el = { - 'tag': 'annotation', - 'attrib': [ - 'title', - 'body', # required - 'problem', - 'highlight' - ] - } - def setUp(self): self.annotatable = AnnotatableModule(test_system, self.location, self.definition, self.descriptor, self.instance_state, self.shared_state) + + def test_annotation_data_attr(self): + el = etree.fromstring('test') + + expected_attr = { + 'data-comment-body': {'value': 'foo', '_delete': 'body' }, + 'data-comment-title': {'value': 'bar', '_delete': 'title'}, + 'data-problem-id': {'value': '0', '_delete': 'problem'} + } + + data_attr = self.annotatable._get_annotation_data_attr(0, el) + self.assertTrue(type(data_attr) is dict) + self.assertDictEqual(expected_attr, data_attr) \ No newline at end of file From 8162d5473f464c40b0d6717ba2aefe68fe70583e Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 26 Feb 2013 17:08:01 -0500 Subject: [PATCH 078/214] added more unit tests for module --- .../lib/xmodule/xmodule/annotatable_module.py | 10 +-- .../xmodule/tests/test_annotatable_module.py | 70 ++++++++++++++++++- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 9e492f755a..665be210e4 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -29,14 +29,13 @@ class AnnotatableModule(XModule): attr = {} cls = ['annotatable-span', 'highlight'] - valid_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] highlight_key = 'highlight' - color = el.get(highlight_key) - if color is not None and color in valid_colors: - cls.append('highlight-'+color) - attr['_delete'] = highlight_key + if color is not None: + if color in self.highlight_colors: + cls.append('highlight-'+color) + attr['_delete'] = highlight_key attr['value'] = ' '.join(cls) return { 'class' : attr } @@ -120,6 +119,7 @@ class AnnotatableModule(XModule): self.instructions = self._extract_instructions(xmltree) self.content = etree.tostring(xmltree, encoding='unicode') self.element_id = self.location.html_id() + self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] class AnnotatableDescriptor(RawDescriptor): module_class = AnnotatableModule diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py index 7a87dcc16d..3a470879e8 100644 --- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -45,6 +45,70 @@ class AnnotatableModuleTestCase(unittest.TestCase): 'data-problem-id': {'value': '0', '_delete': 'problem'} } - data_attr = self.annotatable._get_annotation_data_attr(0, el) - self.assertTrue(type(data_attr) is dict) - self.assertDictEqual(expected_attr, data_attr) \ No newline at end of file + actual_attr = self.annotatable._get_annotation_data_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_default(self): + xml = 'test' + el = etree.fromstring(xml) + + expected_attr = { 'class': { 'value': 'annotatable-span highlight' } } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_with_valid_highlight(self): + xml = 'test' + + for color in self.annotatable.highlight_colors: + el = etree.fromstring(xml.format(highlight=color)) + value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color) + + expected_attr = { 'class': { + 'value': value, + '_delete': 'highlight' } + } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_annotation_class_attr_with_invalid_highlight(self): + xml = 'test' + + for invalid_color in ['rainbow', 'blink', 'invisible', '', None]: + el = etree.fromstring(xml.format(highlight=invalid_color)) + expected_attr = { 'class': { + 'value': 'annotatable-span highlight', + '_delete': 'highlight' } + } + actual_attr = self.annotatable._get_annotation_class_attr(0, el) + + self.assertTrue(type(actual_attr) is dict) + self.assertDictEqual(expected_attr, actual_attr) + + def test_render_annotation(self): + expected_html = 'z' + expected_el = etree.fromstring(expected_html) + + actual_el = etree.fromstring('z') + self.annotatable._render_annotation(0, actual_el) + + self.assertEqual(expected_el.tag, actual_el.tag) + self.assertEqual(expected_el.text, actual_el.text) + self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib)) + + def test_extract_instructions(self): + xmltree = etree.fromstring(self.sample_text) + + expected_xml = u"
Read the text.
" + actual_xml = self.annotatable._extract_instructions(xmltree) + self.assertIsNotNone(actual_xml) + self.assertEqual(expected_xml.strip(), actual_xml.strip()) + + xmltree = etree.fromstring('foo') + actual = self.annotatable._extract_instructions(xmltree) + self.assertIsNone(actual) \ No newline at end of file From 9d939cba94cb448f4ecb38b06a22d47b55ea260f Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 26 Feb 2013 17:49:18 -0500 Subject: [PATCH 079/214] added more unit tests to module --- .../lib/xmodule/xmodule/annotatable_module.py | 2 ++ .../xmodule/tests/test_annotatable_module.py | 21 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index 665be210e4..0cc567f7a1 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -81,6 +81,8 @@ class AnnotatableModule(XModule): """ Renders annotatable content with annotation spans and returns HTML. """ xmltree = etree.fromstring(self.content) xmltree.tag = 'div' + if 'display_name' in xmltree.attrib: + del xmltree.attrib['display_name'] index = 0 for el in xmltree.findall('.//annotation'): diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py index 3a470879e8..3f9fe349a0 100644 --- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -12,7 +12,7 @@ from . import test_system class AnnotatableModuleTestCase(unittest.TestCase): location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"]) - sample_text = ''' + sample_xml = ''' Read the text.

@@ -28,7 +28,7 @@ class AnnotatableModuleTestCase(unittest.TestCase): The Iliad of Homer by Samuel Butler ''' - definition = { 'data': sample_text } + definition = { 'data': sample_xml } descriptor = Mock() instance_state = None shared_state = None @@ -101,8 +101,23 @@ class AnnotatableModuleTestCase(unittest.TestCase): self.assertEqual(expected_el.text, actual_el.text) self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib)) + def test_render_content(self): + content = self.annotatable._render_content() + el = etree.fromstring(content) + + self.assertEqual('div', el.tag, 'root tag is a div') + + expected_num_annotations = 5 + actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])') + self.assertEqual(expected_num_annotations, actual_num_annotations, 'check number of annotations') + + def test_get_html(self): + context = self.annotatable.get_html() + for key in ['display_name', 'element_id', 'content_html', 'instructions_html']: + self.assertIn(key, context) + def test_extract_instructions(self): - xmltree = etree.fromstring(self.sample_text) + xmltree = etree.fromstring(self.sample_xml) expected_xml = u"

Read the text.
" actual_xml = self.annotatable._extract_instructions(xmltree) From e3f12607cdd2ba17a5c87f5a9b70067cf7f6aec0 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 01:44:07 -0500 Subject: [PATCH 080/214] add grading for annotationinput --- common/lib/capa/capa/inputtypes.py | 32 +++--- common/lib/capa/capa/responsetypes.py | 102 +++++++++++++++++- .../capa/capa/templates/annotationinput.html | 4 +- common/static/js/capa/annotationinput.js | 28 ++++- 4 files changed, 142 insertions(+), 24 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 37cd2a8fa4..f7788e90c9 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -988,27 +988,25 @@ class AnnotationInput(InputTypeBase): self.value = 'null' def _find_options(self): - options = [] - index = 0 - for option in self.xml.findall('./options/option'): - options.append({ + ''' Returns an array of dicts where each dict represents an option. ''' + elements = self.xml.findall('./options/option') + return [{ 'id': index, 'description': option.text, - 'score': option.get('score', 0) - }) - index += 1 - return options + 'choice': option.get('choice') + } for (index, option) in enumerate(elements) ] - def _unpack_value(self): - unpacked_value = json.loads(self.value) - if type(unpacked_value) != dict: - unpacked_value = {} + def _unpack(self, json_value): + ''' Unpacks the json input state into a dict. ''' + d = json.loads(json_value) + if type(d) != dict: + d = {} - comment_value = unpacked_value.get('comment', '') + comment_value = d.get('comment', '') if not isinstance(comment_value, basestring): comment_value = '' - options_value = unpacked_value.get('options', []) + options_value = d.get('options', []) if not isinstance(options_value, list): options_value = [] @@ -1027,9 +1025,9 @@ class AnnotationInput(InputTypeBase): 'options': self.options, 'return_to_annotation': self.return_to_annotation, 'debug': self.debug - } - unpacked_value = self._unpack_value() - extra_context.update(unpacked_value) + } + + extra_context.update(self._unpack(self.value)) return extra_context diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 529b409a96..1c3f179e52 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1843,6 +1843,105 @@ class ImageResponse(LoncapaResponse): dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) #----------------------------------------------------------------------------- +class AnnotationResponse(LoncapaResponse): + + response_tag = 'annotationresponse' + allowed_inputfields = ['annotationinput'] + max_inputfields = 1 + default_scoring = { 'incorrect': 0, 'partial': 1, 'correct': 2 } + + def setup_response(self): + xml = self.xml + self.points_map = self._get_points_map() + self.answer_map = self._get_answer_map() + + def _get_points_map(self): + ''' Returns a dict of option->scoring for each input. ''' + scoring = self.default_scoring + choices = dict(zip(scoring.keys(), scoring.keys())) + points_map = {} + + for inputfield in self.inputfields: + option_map = dict([(option['id'], { + 'correctness': choices.get(option['choice']), + 'points': scoring.get(option['choice']) + }) for option in self._find_options(inputfield) ]) + + points_map[inputfield.get('id')] = option_map + + return points_map + + def _get_answer_map(self): + ''' Returns a dict of answers for each input.''' + answer_map = {} + for inputfield in self.inputfields: + correct_option = self._find_option_with_choice(inputfield, 'correct') + answer_map[inputfield.get('id')] = correct_option['description'] + return answer_map + + def _find_options(self, inputfield): + ''' Returns an array of dicts where each dict represents an option. ''' + elements = inputfield.findall('./options/option') + return [{ + 'id': index, + 'description': option.text, + 'choice': option.get('choice') + } for (index, option) in enumerate(elements) ] + + def _find_option_with_choice(self, inputfield, choice): + ''' Returns the option with the given choice value, otherwise None. ''' + for option in self._find_options(inputfield): + if option['choice'] == choice: + return option + + def _unpack(self, json_value): + ''' Unpacks a student response value submitted as JSON.''' + d = json.loads(json_value) + if type(d) != dict: + d = {} + + comment_value = d.get('comment', '') + if not isinstance(d, basestring): + comment_value = '' + + options_value = d.get('options', []) + if not isinstance(options_value, list): + options_value = [] + + return { + 'options_value': options_value, + 'comment_value': comment_value + } + + def _get_submitted_option(self, student_answer): + ''' Return the single option that was selected, otherwise None.''' + value = self._unpack(student_answer) + options = value['options_value'] + if len(options) == 1: + return options[0] + return None + + def get_score(self, student_answers): + ''' Returns a CorrectMap for the student answer, which may include + partially correct answers.''' + student_answer = student_answers[self.answer_id] + student_option = self._get_submitted_option(student_answer) + + scoring = self.points_map[self.answer_id] + is_valid = student_option is not None and student_option in scoring.keys() + + (correctness, points) = ('incorrect', None) + if is_valid: + correctness = scoring[student_option]['correctness'] + points = scoring[student_option]['points'] + + return CorrectMap(self.answer_id, correctness=correctness, npoints=points) + + def get_answers(self): + return self.answer_map + +#----------------------------------------------------------------------------- + # TEMPORARY: List of all response subclasses # FIXME: To be replaced by auto-registration @@ -1859,4 +1958,5 @@ __all__ = [CodeResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse, - JavascriptResponse] + JavascriptResponse, + AnnotationResponse] diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index dce0434555..997b51b224 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -24,7 +24,7 @@ % if debug:
Rendered with value:
-
${value}
+
${value|h}
Current input value:
@@ -38,6 +38,8 @@ % elif status == 'correct': + % elif status == 'partial': + Partially Correct % elif status == 'incorrect': % elif status == 'incomplete': diff --git a/common/static/js/capa/annotationinput.js b/common/static/js/capa/annotationinput.js index 47b8ad342f..4353fd262a 100644 --- a/common/static/js/capa/annotationinput.js +++ b/common/static/js/capa/annotationinput.js @@ -1,13 +1,16 @@ (function () { - var debug = true; + var debug = false; var module = { debug: debug, inputSelector: '.annotation-input', tagSelector: '.tag', + tagsSelector: '.tags', commentSelector: 'textarea.comment', valueSelector: 'input.value', // stash tag selections and comment here as a JSON string... + singleSelect: true, + init: function() { var that = this; @@ -38,15 +41,30 @@ target_value = $(e.target).data('id'); if(!$(target_el).hasClass('selected')) { - current_value.options.push(target_value); + if(this.singleSelect) { + current_value.options = [target_value] + } else { + current_value.options.push(target_value); + } } else { - target_index = current_value.options.indexOf(target_value); - if(target_index !== -1) { - current_value.options.splice(target_index, 1); + if(this.singleSelect) { + current_value.options = [] + } else { + target_index = current_value.options.indexOf(target_value); + if(target_index !== -1) { + current_value.options.splice(target_index, 1); + } } } this.storeValue(value_el, current_value); + + if(this.singleSelect) { + $(target_el).closest(this.tagsSelector) + .find(this.tagSelector) + .not(target_el) + .removeClass('selected') + } $(target_el).toggleClass('selected'); }, findValueEl: function(target_el) { From 25fb210338f1d3db711c1426a60b9dca313f4801 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 12:45:58 -0500 Subject: [PATCH 081/214] move return link to problem header --- common/lib/capa/capa/responsetypes.py | 31 +++++++++---------- .../capa/capa/templates/annotationinput.html | 13 +++++--- .../lib/xmodule/xmodule/css/capa/display.scss | 11 +++++-- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 1c3f179e52..61a149d7e2 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1848,28 +1848,27 @@ class AnnotationResponse(LoncapaResponse): response_tag = 'annotationresponse' allowed_inputfields = ['annotationinput'] max_inputfields = 1 - default_scoring = { 'incorrect': 0, 'partial': 1, 'correct': 2 } - + default_scoring = {'incorrect': 0, 'partial': 1, 'correct': 2 } def setup_response(self): xml = self.xml - self.points_map = self._get_points_map() + self.scoring_map = self._get_scoring_map() self.answer_map = self._get_answer_map() - def _get_points_map(self): + def _get_scoring_map(self): ''' Returns a dict of option->scoring for each input. ''' scoring = self.default_scoring - choices = dict(zip(scoring.keys(), scoring.keys())) - points_map = {} + choices = dict([(choice,choice) for choice in scoring]) + scoring_map = {} for inputfield in self.inputfields: - option_map = dict([(option['id'], { + option_scoring = dict([(option['id'], { 'correctness': choices.get(option['choice']), 'points': scoring.get(option['choice']) }) for option in self._find_options(inputfield) ]) - points_map[inputfield.get('id')] = option_map + scoring_map[inputfield.get('id')] = option_scoring - return points_map + return scoring_map def _get_answer_map(self): ''' Returns a dict of answers for each input.''' @@ -1913,21 +1912,21 @@ class AnnotationResponse(LoncapaResponse): 'comment_value': comment_value } - def _get_submitted_option(self, student_answer): + def _get_submitted_option_id(self, student_answer): ''' Return the single option that was selected, otherwise None.''' - value = self._unpack(student_answer) - options = value['options_value'] - if len(options) == 1: - return options[0] + submitted = self._unpack(student_answer) + option_ids = submitted['options_value'] + if len(option_ids) == 1: + return option_ids[0] return None def get_score(self, student_answers): ''' Returns a CorrectMap for the student answer, which may include partially correct answers.''' student_answer = student_answers[self.answer_id] - student_option = self._get_submitted_option(student_answer) + student_option = self._get_submitted_option_id(student_answer) - scoring = self.points_map[self.answer_id] + scoring = self.scoring_map[self.answer_id] is_valid = student_option is not None and student_option in scoring.keys() (correctness, points) = ('incorrect', None) diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index 997b51b224..636477b7aa 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -1,7 +1,13 @@
-
${title}
+
+ ${title} + + % if return_to_annotation: + Return to Annotation
+ % endif +
${text}
@@ -32,7 +38,7 @@ % endif - +

% if status == 'unsubmitted': @@ -46,9 +52,6 @@ % endif - % if return_to_annotation: - Return to Annotation
- % endif
diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 277ac307ef..c067592e0e 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -816,8 +816,12 @@ section.problem { padding: .5em 1em; } .annotation-body { padding: .5em 1em; } - .annotation-return { float: right; } - .annotation-return:after { content: " \2191" } + a.annotation-return { + float: right; + font: inherit; + font-weight: normal; + } + a.annotation-return:after { content: " \2191" } .block, ul.tags { margin: .5em 0; @@ -851,6 +855,9 @@ section.problem { } } textarea.comment { width: 100%; } + .answer-annotation { display: block; margin: 0; } + + /* for debugging the input value field. enable the debug flag on the inputtype */ .debug-value { color: #fff; padding: 1em; From 9f8e172be9069dafaea98dd71c88352eaec5c430 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 13:35:10 -0500 Subject: [PATCH 082/214] show the status mark next to the submitted tag instead of at the bottom of the problem. --- common/lib/capa/capa/inputtypes.py | 2 +- .../capa/capa/templates/annotationinput.html | 28 +++++++++++-------- .../lib/xmodule/xmodule/css/capa/display.scss | 14 +++++++--- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index f7788e90c9..5b091785f9 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -980,7 +980,7 @@ class AnnotationInput(InputTypeBase): self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:') self.tag_prompt = xml.findtext('./tag_prompt', 'Select one or more tags:') self.options = self._find_options() - self.return_to_annotation = True + self.return_to_annotation = True # return only works in conjunction with annotatable xmodule # Need to provide a value that JSON can parse if there is no # student-supplied value yet. diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index 636477b7aa..23579b20c8 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -19,11 +19,23 @@
${tag_prompt}
    % for option in options: -
  • ${option['description']}
  • +
  • + % if all([c == 'correct' for c in option['choice'], status]): + + % elif all([c == 'partial' for c in option['choice'], status]): + P + % elif all([c == 'incorrect' for c in option['choice'], status]): + + % endif + + + ${option['description']} + +
  • % endfor
@@ -42,12 +54,6 @@ % if status == 'unsubmitted': - % elif status == 'correct': - - % elif status == 'partial': - Partially Correct - % elif status == 'incorrect': - % elif status == 'incomplete': % endif diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index c067592e0e..0b03357bb7 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -842,16 +842,22 @@ section.problem { margin-left: 1em; li { display: block; - margin: 1em 0 0 1.5em; - span { - cursor: pointer; + margin: 1em 0 0 0; + position: relative; + .tag { display: inline-block; - padding: .25em .5em; + cursor: pointer; border: 1px solid rgb(102,102,102); + margin-left: 40px; &.selected { background-color: $yellow; } } + .status { + position: absolute; + left: 0; + } + .tag, .status { padding: .25em .5em; } } } textarea.comment { width: 100%; } From b84d0ba8ba60d3814da94aae20761cd8b065c065 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 14:04:49 -0500 Subject: [PATCH 083/214] updated comments --- common/lib/capa/capa/inputtypes.py | 31 +++++++++++++-------------- common/lib/capa/capa/responsetypes.py | 6 ++++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 5b091785f9..0531a59e3c 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -943,25 +943,24 @@ registry.register(EditAGeneInput) class AnnotationInput(InputTypeBase): """ - Input type for annotations / tags: students can enter some notes or other text - (currently ungraded), and then choose from a set of tags, which are graded. + Input type for annotations: students can enter some notes or other text + (currently ungraded), and then choose from a set of tags/optoins, which are graded. Example: - - Annotation Exercise - Dr Seuss uses colors! How? - Why does Dr Seuss use colors!? - Write down some notes: - Now pick the right color - - - - - - - - The location of the sky + + Annotation Exercise + They are the ones who, at the public assembly, had put savage derangement [atē] into my thinking [phre +nes] |89 on that day when I myself deprived Achilles of his honorific portion [geras] + Agamemnon says that atē or ‘derangement’ was the cause of his actions: why could Zeus say the same thing? + Type a commentary below: + Select one or more tags: + + + + + + # TODO: allow ordering to be randomized """ diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 61a149d7e2..2de34dbac5 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1844,7 +1844,13 @@ class ImageResponse(LoncapaResponse): #----------------------------------------------------------------------------- class AnnotationResponse(LoncapaResponse): + ''' + Checking of annotation responses. + The response contains both a comment (student commentary) and an option (student tag). + Only the tag is currently graded. Answers may be incorrect, partially correct, or correct + and are scored accordingly. + ''' response_tag = 'annotationresponse' allowed_inputfields = ['annotationinput'] max_inputfields = 1 From 44a68e6903425db2687076f41d0b1ce946952d22 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 14:36:14 -0500 Subject: [PATCH 084/214] added partially correct icon from marco --- common/lib/capa/capa/inputtypes.py | 14 ++++++++------ common/lib/capa/capa/responsetypes.py | 2 +- .../lib/capa/capa/templates/annotationinput.html | 8 ++++---- common/lib/capa/capa/tests/test_responsetypes.py | 3 +++ common/lib/xmodule/xmodule/css/capa/display.scss | 13 +++++++++++-- common/static/images/partially-correct-icon.png | Bin 0 -> 1230 bytes 6 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 common/static/images/partially-correct-icon.png diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0531a59e3c..956d82bd94 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -950,15 +950,17 @@ class AnnotationInput(InputTypeBase): Annotation Exercise - They are the ones who, at the public assembly, had put savage derangement [atē] into my thinking [phre -nes] |89 on that day when I myself deprived Achilles of his honorific portion [geras] - Agamemnon says that atē or ‘derangement’ was the cause of his actions: why could Zeus say the same thing? + + They are the ones who, at the public assembly, had put savage derangement [ate] into my thinking + [phrenes] |89 on that day when I myself deprived Achilles of his honorific portion [geras] + + Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing? Type a commentary below: Select one or more tags: - - - + + + diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 2de34dbac5..d8703a4e2f 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1854,7 +1854,7 @@ class AnnotationResponse(LoncapaResponse): response_tag = 'annotationresponse' allowed_inputfields = ['annotationinput'] max_inputfields = 1 - default_scoring = {'incorrect': 0, 'partial': 1, 'correct': 2 } + default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 } def setup_response(self): xml = self.xml self.scoring_map = self._get_scoring_map() diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index 23579b20c8..bfbce08a0f 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -21,11 +21,11 @@ % for option in options:
  • % if all([c == 'correct' for c in option['choice'], status]): - - % elif all([c == 'partial' for c in option['choice'], status]): - P + + % elif all([c == 'partially-correct' for c in option['choice'], status]): + % elif all([c == 'incorrect' for c in option['choice'], status]): - + % endif ate - both a cause and an effect - + From 8f89768d62f46e09d0ffd80f2c0e92340b9d92b0 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 16:29:25 -0500 Subject: [PATCH 087/214] added annotationresponse grade test --- common/lib/capa/capa/responsetypes.py | 3 +-- .../tests/test_files/annotationresponse.xml | 17 +++++++++++++++++ .../lib/capa/capa/tests/test_responsetypes.py | 14 +++++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_files/annotationresponse.xml diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index c797170a56..10a0130a6e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1848,8 +1848,7 @@ class AnnotationResponse(LoncapaResponse): Checking of annotation responses. The response contains both a comment (student commentary) and an option (student tag). - Only the tag is currently graded. Answers may be incorrect, partially correct, or correct - and are scored accordingly. + Only the tag is currently graded. Answers may be incorrect, partially correct, or correct. ''' response_tag = 'annotationresponse' allowed_inputfields = ['annotationinput'] diff --git a/common/lib/capa/capa/tests/test_files/annotationresponse.xml b/common/lib/capa/capa/tests/test_files/annotationresponse.xml new file mode 100644 index 0000000000..86af0bb789 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/annotationresponse.xml @@ -0,0 +1,17 @@ + + + + the title + the text + the comment + Type a commentary below: + Select one or more tags: + + + + + + + + Instructor text here... + diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index daf09b4136..8a0c953d33 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -428,4 +428,16 @@ class JavascriptResponseTest(unittest.TestCase): self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') class AnnotationResponseTest(unittest.TestCase): - pass \ No newline at end of file + + def test_grade(self): + annotationresponse_file = os.path.dirname(__file__) + "/test_files/annotationresponse.xml" + test_lcp = lcp.LoncapaProblem(open(annotationresponse_file).read(), '1', system=test_system) + answers_for = { + 'correct': {'1_2_1': json.dumps({'options':[0]})}, + 'incorrect': {'1_2_1': json.dumps({'options':[1]})}, + 'partially-correct': {'1_2_1': json.dumps({'options':[2]})} + } + + for expected_correctness in answers_for.keys(): + actual_correctness = test_lcp.grade_answers(answers_for[expected_correctness]).get_correctness('1_2_1') + self.assertEquals(expected_correctness, actual_correctness) \ No newline at end of file From bc49d50fc2535e59b32c4420b29b5a43438dad07 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 27 Feb 2013 16:53:17 -0500 Subject: [PATCH 088/214] fixed the max points in the annotation response --- common/lib/capa/capa/responsetypes.py | 45 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 10a0130a6e..ae58c4995f 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1858,6 +1858,26 @@ class AnnotationResponse(LoncapaResponse): xml = self.xml self.scoring_map = self._get_scoring_map() self.answer_map = self._get_answer_map() + self.maxpoints = self._get_max_points() + + def get_score(self, student_answers): + ''' Returns a CorrectMap for the student answer, which may include + partially correct answers.''' + student_answer = student_answers[self.answer_id] + student_option = self._get_submitted_option_id(student_answer) + + scoring = self.scoring_map[self.answer_id] + is_valid = student_option is not None and student_option in scoring.keys() + + (correctness, points) = ('incorrect', None) + if is_valid: + correctness = scoring[student_option]['correctness'] + points = scoring[student_option]['points'] + + return CorrectMap(self.answer_id, correctness=correctness, npoints=points) + + def get_answers(self): + return self.answer_map def _get_scoring_map(self): ''' Returns a dict of option->scoring for each input. ''' @@ -1884,6 +1904,12 @@ class AnnotationResponse(LoncapaResponse): answer_map[inputfield.get('id')] = correct_option.get('description') return answer_map + def _get_max_points(self): + ''' Returns a dict of the max points for each input: input id -> maxpoints. ''' + scoring = self.default_scoring + correct_points = scoring.get('correct') + return dict([(inputfield.get('id'), correct_points) for inputfield in self.inputfields]) + def _find_options(self, inputfield): ''' Returns an array of dicts where each dict represents an option. ''' elements = inputfield.findall('./options/option') @@ -1926,25 +1952,6 @@ class AnnotationResponse(LoncapaResponse): return option_ids[0] return None - def get_score(self, student_answers): - ''' Returns a CorrectMap for the student answer, which may include - partially correct answers.''' - student_answer = student_answers[self.answer_id] - student_option = self._get_submitted_option_id(student_answer) - - scoring = self.scoring_map[self.answer_id] - is_valid = student_option is not None and student_option in scoring.keys() - - (correctness, points) = ('incorrect', None) - if is_valid: - correctness = scoring[student_option]['correctness'] - points = scoring[student_option]['points'] - - return CorrectMap(self.answer_id, correctness=correctness, npoints=points) - - def get_answers(self): - return self.answer_map - #----------------------------------------------------------------------------- # TEMPORARY: List of all response subclasses From bc6a085fdf917011db466a7272a2f7f2baf06711 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 13:10:37 -0500 Subject: [PATCH 089/214] converted annotationinput tests to the new responsetype testing format. --- .../capa/capa/tests/response_xml_factory.py | 36 ++++++++++++++++++ .../tests/test_files/annotationresponse.xml | 17 --------- .../lib/capa/capa/tests/test_responsetypes.py | 37 +++++++++++++++++++ 3 files changed, 73 insertions(+), 17 deletions(-) delete mode 100644 common/lib/capa/capa/tests/test_files/annotationresponse.xml diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index fe918ec5db..c7e43f5152 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -666,3 +666,39 @@ class StringResponseXMLFactory(ResponseXMLFactory): def create_input_element(self, **kwargs): return ResponseXMLFactory.textline_input_xml(**kwargs) + +class AnnotationResponseXMLFactory(ResponseXMLFactory): + """ Factory for creating XML trees """ + def create_response_element(self, **kwargs): + """ Create a element """ + return etree.Element("annotationresponse") + + def create_input_element(self, **kwargs): + """ Create a element.""" + + title = kwargs.get('title', 'super cool annotation') + text = kwargs.get('text', 'texty text') + comment = kwargs.get('comment', 'blah blah erudite comment blah blah') + comment_prompt = kwargs.get('comment_prompt', 'type a commentary below') + tag_prompt = kwargs.get('tag_prompt', 'select one tag') + options = kwargs.get('options', [ + ('green', 'correct'), + ('eggs', 'incorrect'), + ('ham', 'partially-correct') + ]) + + # Create the element + input_element = etree.Element("annotationinput") + etree.SubElement(input_element, 'title') + etree.SubElement(input_element, 'text') + etree.SubElement(input_element, 'comment') + etree.SubElement(input_element, 'comment_prompt') + etree.SubElement(input_element, 'tag_prompt') + + options_element = etree.SubElement(input_element, 'options') + for (description, correctness) in options: + option_element = etree.SubElement(options_element, 'option', {'choice': correctness}) + option_element.text = description + + return input_element + diff --git a/common/lib/capa/capa/tests/test_files/annotationresponse.xml b/common/lib/capa/capa/tests/test_files/annotationresponse.xml deleted file mode 100644 index 86af0bb789..0000000000 --- a/common/lib/capa/capa/tests/test_files/annotationresponse.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - the title - the text - the comment - Type a commentary below: - Select one or more tags: - - - - - - - - Instructor text here... - diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 33b84d213d..d592bff976 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -772,3 +772,40 @@ class SchematicResponseTest(ResponseTest): # (That is, our script verifies that the context # is what we expect) self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + +class AnnotationResponseTest(ResponseTest): + from response_xml_factory import AnnotationResponseXMLFactory + xml_factory_class = AnnotationResponseXMLFactory + + def test_grade(self): + (correct, partially, incorrect) = ('correct', 'partially-correct', 'incorrect') + + answer_id = '1_2_1' + options = (('x', correct),('y', partially),('z', incorrect)) + make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids })} + + tests = [ + {'correctness': correct, 'points': 2,'answers': make_answer([0]) }, + {'correctness': partially, 'points': 1, 'answers': make_answer([1]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([2]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([0,1,2]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer([]) }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer('') }, + {'correctness': incorrect, 'points': 0, 'answers': make_answer(None) }, + {'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null' } }, + ] + + for (index, test) in enumerate(tests): + expected_correctness = test['correctness'] + expected_points = test['points'] + answers = test['answers'] + + problem = self.build_problem(options=options) + correct_map = problem.grade_answers(answers) + actual_correctness = correct_map.get_correctness(answer_id) + actual_points = correct_map.get_npoints(answer_id) + + self.assertEqual(expected_correctness, actual_correctness, + msg="%s should be marked %s" % (answer_id, expected_correctness)) + self.assertEqual(expected_points, actual_points, + msg="%s should have %d points" % (answer_id, expected_points)) \ No newline at end of file From 6b74dcf782263b7b90b0243d3e49be1118d96bcb Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 13:38:29 -0500 Subject: [PATCH 090/214] adding unit test for annotationinput --- common/lib/capa/capa/tests/test_inputtypes.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 4a5ea5c429..8b8bbe74de 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -570,3 +570,64 @@ class DragAndDropTest(unittest.TestCase): context.pop('drag_and_drop_json') expected.pop('drag_and_drop_json') self.assertEqual(context, expected) + + +class AnnotationInputTest(unittest.TestCase): + ''' + Make sure option inputs work + ''' + def test_rendering(self): + xml_str = ''' + + foo + bar + my comment + type a commentary + select a tag + + + + + + +''' + element = etree.fromstring(xml_str) + + value = {"comment": "blah blah", "options": [1]} + json_value = json.dumps(value) + state = { + 'value': json_value, + 'id': 'annotation_input', + 'status': 'answered' + } + + tag = 'annotationinput' + + the_input = lookup_tag(tag)(test_system, element, state) + + context = the_input._get_render_context() + + expected = { + 'id': 'annotation_input', + 'value': value, + 'status': 'answered', + 'msg': '', + 'title': 'foo', + 'text': 'bar', + 'comment': 'my comment', + 'comment_prompt': 'type a commentary', + 'tag_prompt': 'select a tag', + 'options': [ + {'id': 0, 'description': 'x', 'choice': 'correct'}, + {'id': 1, 'description': 'y', 'choice': 'incorrect'}, + {'id': 2, 'description': 'z', 'choice': 'partially-correct'} + ], + 'value': json_value, + 'options_value': value['options'], + 'comment_value': value['comment'], + 'debug': False, + 'return_to_annotation': True + } + + self.maxDiff = None + self.assertDictEqual(context, expected) \ No newline at end of file From 430449501e06c5730fe4719d1aab55e99bdb981d Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 14:06:46 -0500 Subject: [PATCH 091/214] deleting test file --- common/lib/xmodule/xmodule/artie.py | 79 ----------------------------- 1 file changed, 79 deletions(-) delete mode 100644 common/lib/xmodule/xmodule/artie.py diff --git a/common/lib/xmodule/xmodule/artie.py b/common/lib/xmodule/xmodule/artie.py deleted file mode 100644 index 9941906c7e..0000000000 --- a/common/lib/xmodule/xmodule/artie.py +++ /dev/null @@ -1,79 +0,0 @@ -from lxml import etree - -class AnnotatableSource: - def __init__(self, source, **kwargs): - self._source = source - self._annotations = [] - - def render(self): - result = { 'html': None, 'json': None } - return result - - def problems(self): - return [] - - def annotations(self): - return self.annotations - -class Annotation: - def __init__(self, target, body, **kwargs): - self.target = target - self.body = body - self.problems = [] - -class Problem: - def __init__(self, definition, **kwargs): - self.definition = definition - - -TEXT = """ - -
    - -
    -

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. - - Pellentesque id mauris sit amet lectus interdum tincidunt quis at mi. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum massa enim, sollicitudin id rutrum non, laoreet quis massa. Donec pharetra porta est id feugiat. Suspendisse aliquet cursus augue, at placerat magna adipiscing sit amet. Suspendisse velit dolor, congue in venenatis eget, consectetur pharetra massa. Vivamus facilisis tincidunt mi, nec imperdiet nibh vehicula sit amet. Donec lectus nisl, interdum sit amet faucibus et, porttitor in est. - Instructor prompt here...Explanation here... - - Sed semper malesuada est et mattis. Mauris vel aliquet dolor. Vivamus rhoncus tristique dictum. Duis eu neque et enim euismod venenatis. Praesent porttitor commodo erat, hendrerit interdum risus sollicitudin a. Fusce neque augue, volutpat vitae vestibulum sit amet, gravida ut urna. Vivamus rutrum laoreet turpis, a gravida velit fringilla a.

    -

    - Nullam quis nisi non erat auctor tristique. Suspendisse a elit tellus. In consectetur mauris quis erat consectetur eu porta turpis sodales. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque egestas aliquam dignissim. Suspendisse fringilla, ante facilisis molestie ullamcorper, nisl erat elementum orci, a convallis ante massa id tellus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam nec leo eget enim imperdiet congue eget et quam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aenean a vulputate dui. Quisque gravida volutpat dolor eu porttitor. Sed varius aliquam dictum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla dictum ligula cursus nisl volutpat consectetur. Nunc iaculis tellus orci, id aliquet purus. Sed ac justo tellus. Mauris at lacus nisi. In tincidunt nisl sit amet nisi interdum non malesuada nulla pulvinar. Aliquam scelerisque ligula ut urna fermentum tincidunt. Aenean lacinia blandit metus et interdum. Phasellus porttitor porttitor consequat. Cras ultrices dictum velit, sit amet turpis duis. - Maecenas eu volutpat lacus. - - Morbi luctus est - - tincidunt - Ignore this for now. It's not important. - - mauris dictum sit amet ornare augue eleifend. Quisque sagittis varius enim vulputate congue. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque egestas aliquam dignissim. Suspendisse fringilla, ante facilisis molestie ullamcorper, nisl erat elementum orci, a convallis ante massa id tellus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam nec leo eget enim imperdiet congue eget et quam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aenean a vulputate dui. Quisque gravida volutpat dolor eu porttitor. Sed varius aliquam dictum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla dictum ligula cursus nisl volutpat consectetur. Nunc iaculis tellus orci, id aliquet purus. Sed ac justo tellus. Mauris at lacus nisi. In tincidunt nisl sit amet nisi interdum non malesuada nulla pulvinar. Aliquam scelerisque ligula ut urna fermentum tincidunt. Aenean lacinia blandit metus et interdum. Phasellus porttitor porttitor consequat. Cras ultrices dictum velit, sit amet turpis duis. - Mauris facilisis mauris id nunc euismod vehicula. Mauris dictum nisi ac ligula posuere ultricies. Maecenas eros nisl, aliquet non eleifend ac, posuere in ante. Aliquam erat volutpat. Mauris consequat fringilla cursus. Suspendisse euismod eros et mauris imperdiet a placerat sapien semper. - - Sed molestie laoreet magna in pharetra. Nunc mattis eleifend ultrices. Aenean ut quam vitae risus tincidunt tempor vitae sed arcu. -

    -

    - In adipiscing metus sit amet quam sollicitudin sed suscipit diam gravida. Maecenas aliquet ante id nunc scelerisque pulvinar. Praesent ante erat, condimentum vel scelerisque non, aliquam vel urna. Cras euismod, mi at congue dignissim, velit velit aliquet est, vel vestibulum sem turpis a dui. Donec vel rutrum felis. Fusce nulla risus, volutpat sit amet molestie non, sollicitudin quis felis. Maecenas a turpis mauris. Donec vel pulvinar nulla. - Chicken tenderloin boudin pig pork chop. Biltong rump frankfurter swine jowl turducken. Venison ham hock chuck pork chop, jowl chicken meatball doner meatloaf beef ribs ball tip ham. Pork drumstick fatback ribeye chicken pork chop frankfurter andouille ball tip strip steak spare ribs biltong capicola. - Vivamus nec mi quam, non gravida erat. Fusce iaculis eros eget mi tempus vitae cursus nulla ornare. Donec a nibh purus. - - Ut id risus quis nibh tincidunt consectetur sed ac metus. Praesent accumsan scelerisque neque, eu imperdiet justo pharetra euismod. Suspendisse potenti. Suspendisse turpis lectus, fermentum id pellentesque eu, iaculis ut tortor. Nullam ut accumsan diam. -

    -

    - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque egestas aliquam dignissim. Suspendisse fringilla, ante facilisis molestie ullamcorper, nisl erat elementum orci, a convallis ante massa id tellus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam nec leo eget enim imperdiet congue eget et quam. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Aenean a vulputate dui. Quisque gravida volutpat dolor eu porttitor. Sed varius aliquam dictum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla dictum ligula cursus nisl volutpat consectetur. Nunc iaculis tellus orci, id aliquet purus. Sed ac justo tellus. Mauris at lacus nisi. In tincidunt nisl sit amet nisi interdum non malesuada nulla pulvinar. Aliquam scelerisque ligula ut urna fermentum tincidunt. Aenean lacinia blandit metus et interdum. Phasellus porttitor porttitor consequat. Cras ultrices dictum velit, sit amet turpis duis. - Duis nisl nunc, iaculis et pretium vel, bibendum eget diam. Vestibulum consectetur facilisis pretium. Morbi tristique dui a dui tempus vitae fermentum nunc dapibus. Vestibulum bibendum nunc nec dui sollicitudin viverra. Cras quam justo, consectetur fringilla varius vitae, malesuada eu lacus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec nisi lacus, feugiat sed lobortis nec, sodales sit amet tortor. - - - Vestibulum lobortis mollis cursus. - foo! - -

    -
    -""" - -source = AnnotatableSource(TEXT) -rendered = source.render() -print ", ".join(rendered.keys()) - From 3f40781612f33c8f9f8c7d31753cf65769f65982 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 14:24:53 -0500 Subject: [PATCH 092/214] adjusted "incorrect" icon placement when no annotation options submitted. --- common/lib/capa/capa/inputtypes.py | 3 ++- .../capa/capa/templates/annotationinput.html | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 4569b07bab..7abaf0fe69 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -1025,7 +1025,8 @@ class AnnotationInput(InputTypeBase): return { 'options_value': options_value, - 'comment_value': comment_value + 'has_options_value': len(options_value) > 0, # for convenience + 'comment_value': comment_value, } def _extra_context(self): diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index bfbce08a0f..e0172bb13b 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -20,12 +20,14 @@
      % for option in options:
    • - % if all([c == 'correct' for c in option['choice'], status]): - - % elif all([c == 'partially-correct' for c in option['choice'], status]): - - % elif all([c == 'incorrect' for c in option['choice'], status]): - + % if has_options_value: + % if all([c == 'correct' for c in option['choice'], status]): + + % elif all([c == 'partially-correct' for c in option['choice'], status]): + + % elif all([c == 'incorrect' for c in option['choice'], status]): + + % endif % endif % endif -

      - % if status == 'unsubmitted': % elif status == 'incomplete': + % elif status == 'incorrect' and not has_options_value: + % endif +

  • From 179a4af5e7dca87c2bac8d86ff5816427540cf18 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 15:37:14 -0500 Subject: [PATCH 093/214] fixed failing annotationinput test and refactored response factory --- .../capa/capa/tests/response_xml_factory.py | 31 +++++++++---------- common/lib/capa/capa/tests/test_inputtypes.py | 1 + 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py index c7e43f5152..7aa299d20d 100644 --- a/common/lib/capa/capa/tests/response_xml_factory.py +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -676,26 +676,23 @@ class AnnotationResponseXMLFactory(ResponseXMLFactory): def create_input_element(self, **kwargs): """ Create a element.""" - title = kwargs.get('title', 'super cool annotation') - text = kwargs.get('text', 'texty text') - comment = kwargs.get('comment', 'blah blah erudite comment blah blah') - comment_prompt = kwargs.get('comment_prompt', 'type a commentary below') - tag_prompt = kwargs.get('tag_prompt', 'select one tag') - options = kwargs.get('options', [ - ('green', 'correct'), - ('eggs', 'incorrect'), - ('ham', 'partially-correct') - ]) - - # Create the element input_element = etree.Element("annotationinput") - etree.SubElement(input_element, 'title') - etree.SubElement(input_element, 'text') - etree.SubElement(input_element, 'comment') - etree.SubElement(input_element, 'comment_prompt') - etree.SubElement(input_element, 'tag_prompt') + text_children = [ + {'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') }, + {'tag': 'text', 'text': kwargs.get('text', 'texty text') }, + {'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') }, + {'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') }, + {'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') } + ] + + for child in text_children: + etree.SubElement(input_element, child['tag']).text = child['text'] + + default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')] + options = kwargs.get('options', default_options) options_element = etree.SubElement(input_element, 'options') + for (description, correctness) in options: option_element = etree.SubElement(options_element, 'option', {'choice': correctness}) option_element.text = description diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 8b8bbe74de..c9f14cb79b 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -624,6 +624,7 @@ class AnnotationInputTest(unittest.TestCase): ], 'value': json_value, 'options_value': value['options'], + 'has_options_value': len(value['options']) > 0, 'comment_value': value['comment'], 'debug': False, 'return_to_annotation': True From 0461d7288400ae405215fc9cd987093cb213b694 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 16:31:15 -0500 Subject: [PATCH 094/214] fixed tag prompt default text --- common/lib/capa/capa/inputtypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 7abaf0fe69..43531d19ee 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -956,7 +956,7 @@ class AnnotationInput(InputTypeBase): Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing? Type a commentary below: - Select one or more tags: + Select one tag: @@ -980,7 +980,7 @@ class AnnotationInput(InputTypeBase): self.text = xml.findtext('./text') self.comment = xml.findtext('./comment') self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:') - self.tag_prompt = xml.findtext('./tag_prompt', 'Select one or more tags:') + self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:') self.options = self._find_options() # Need to provide a value that JSON can parse if there is no From 3d4510ab0c28abe245310c26e29d1648bd8b490f Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 28 Feb 2013 18:35:32 -0500 Subject: [PATCH 095/214] fixed sass compiler issue with annotatable xmodule on cms --- common/lib/xmodule/xmodule/css/annotatable/display.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index fc22537899..4fb691fcb9 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -1,3 +1,6 @@ +$border-color: #C8C8C8; +$body-font-size: em(14); + .annotatable-header { margin-bottom: .5em; .annotatable-title { From 3dd49881c3cc197a130edf5237902452cfb468df Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Feb 2013 10:59:29 -0500 Subject: [PATCH 096/214] * Wrote unit tests for correctmap. * Fixed potential KeyError in correctmap.get_queue_time_str() * Modified correctmap.get_npoints() to return 0 if answer is incorrect, even when npoints has been set. * Added overall_message attribute to CorrectMap to allow messages that apply to the entire question, not just an individual input --- common/lib/capa/capa/correctmap.py | 33 ++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index a78b10d07a..1dc0e0d612 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -27,6 +27,7 @@ class CorrectMap(object): self.cmap = dict() self.items = self.cmap.items self.keys = self.cmap.keys + self.overall_message = "" self.set(*args, **kwargs) def __getitem__(self, *args, **kwargs): @@ -104,16 +105,21 @@ class CorrectMap(object): return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key def get_queuetime_str(self, answer_id): - return self.cmap[answer_id]['queuestate']['time'] + if self.cmap[answer_id]['queuestate']: + return self.cmap[answer_id]['queuestate']['time'] + else: + return None def get_npoints(self, answer_id): - npoints = self.get_property(answer_id, 'npoints') - if npoints is not None: - return npoints - elif self.is_correct(answer_id): - return 1 - # if not correct and no points have been assigned, return 0 - return 0 + """ Return the number of points for an answer: + If the answer is correct, return the assigned + number of points (default: 1 point) + Otherwise, return 0 points """ + if self.is_correct(answer_id): + npoints = self.get_property(answer_id, 'npoints') + return npoints if npoints else 1 + else: + return 0 def set_property(self, answer_id, property, value): if answer_id in self.cmap: @@ -153,3 +159,14 @@ class CorrectMap(object): if not isinstance(other_cmap, CorrectMap): raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap) self.cmap.update(other_cmap.get_dict()) + + + def set_overall_message(self, message_str): + """ Set a message that applies to the question as a whole, + rather than to individual inputs. """ + self.overall_message = str(message_str) if message_str else "" + + def get_overall_message(self): + """ Retrieve a message that applies to the question as a whole. + If no message is available, returns the empty string """ + return self.overall_message From 9f6119a19af72e4478353fd84399dec1bd2318c2 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Feb 2013 13:12:43 -0500 Subject: [PATCH 097/214] * Fixed a bug in CorrectMap in which overall_message was not copied in the update() method * Implemented CustomResponse behavior for returning overall_message, with unit tests * Added additional CustomResponse unit tests for exception handling --- common/lib/capa/capa/correctmap.py | 1 + common/lib/capa/capa/responsetypes.py | 72 ++++++-- .../lib/capa/capa/tests/test_responsetypes.py | 161 ++++++++++++++++-- 3 files changed, 205 insertions(+), 29 deletions(-) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 1dc0e0d612..feea917ded 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -159,6 +159,7 @@ class CorrectMap(object): if not isinstance(other_cmap, CorrectMap): raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap) self.cmap.update(other_cmap.get_dict()) + self.set_overall_message(other_cmap.get_overall_message()) def set_overall_message(self, message_str): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index a1a4e6b65e..89372ca2bd 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -965,6 +965,7 @@ def sympy_check2(): # not expecting 'unknown's correct = ['unknown'] * len(idset) messages = [''] * len(idset) + overall_message = "" # put these in the context of the check function evaluator # note that this doesn't help the "cfn" version - only the exec version @@ -996,6 +997,10 @@ def sympy_check2(): # the list of messages to be filled in by the check function 'messages': messages, + # a message that applies to the entire response + # instead of a particular input + 'overall_message': overall_message, + # any options to be passed to the cfn 'options': self.xml.get('options'), 'testdat': 'hello world', @@ -1010,6 +1015,7 @@ def sympy_check2(): exec self.code in self.context['global_context'], self.context correct = self.context['correct'] messages = self.context['messages'] + overall_message = self.context['overall_message'] except Exception as err: print "oops in customresponse (code) error %s" % err print "context = ", self.context @@ -1044,28 +1050,66 @@ def sympy_check2(): log.error(traceback.format_exc()) raise Exception("oops in customresponse (cfn) error %s" % err) log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret) + if type(ret) == dict: - correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset) - msg = ret['msg'] - if 1: - # try to clean up message html - msg = '' + msg + '' - msg = msg.replace('<', '<') - #msg = msg.replace('<','<') - msg = etree.tostring(fromstring_bs(msg, convertEntities=None), - pretty_print=True) - #msg = etree.tostring(fromstring_bs(msg),pretty_print=True) - msg = msg.replace(' ', '') - #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 - msg = re.sub('(?ms)(.*)', '\\1', msg) + # One kind of dictionary the check function can return has the + # form {'ok': BOOLEAN, 'msg': STRING} + # If there are multiple inputs, they all get marked + # to the same correct/incorrect value + # and the first input stores the message + if 'ok' in ret: + correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset) + msg = ret['msg'] - messages[0] = msg + if 1: + # try to clean up message html + msg = '' + msg + '' + msg = msg.replace('<', '<') + #msg = msg.replace('<','<') + msg = etree.tostring(fromstring_bs(msg, convertEntities=None), + pretty_print=True) + #msg = etree.tostring(fromstring_bs(msg),pretty_print=True) + msg = msg.replace(' ', '') + #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 + msg = re.sub('(?ms)(.*)', '\\1', msg) + + messages[0] = msg + + + # Another kind of dictionary the check function can return has + # the form: + # {'overall_message': STRING, + # 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] } + # + # This allows the function to return an 'overall message' + # that applies to the entire problem, as well as correct/incorrect + # status and messages for individual inputs + elif 'input_list' in ret: + overall_message = ret.get('overall_message', '') + input_list = ret['input_list'] + + correct = [] + messages = [] + for input_dict in input_list: + correct.append('correct' if input_dict['ok'] else 'incorrect') + messages.append(input_dict['msg'] if 'msg' in input_dict else None) + + # Otherwise, we do not recognize the dictionary + # Raise an exception + else: + log.error(traceback.format_exc()) + raise Exception("CustomResponse: check function returned an invalid dict") + + # The check function can return a boolean value, + # indicating whether all inputs should be marked + # correct or incorrect else: correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset) # build map giving "correct"ness of the answer(s) correct_map = CorrectMap() + correct_map.set_overall_message(overall_message) for k in range(len(idset)): npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0 correct_map.set(idset[k], correct[k], msg=messages[k], diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 33b84d213d..451e6ed14b 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -8,6 +8,7 @@ import json from nose.plugins.skip import SkipTest import os import unittest +import textwrap from . import test_system @@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest): # Inline code can update the global messages list # to pass messages to the CorrectMap for a particular input - inline_script = """messages[0] = "Test Message" """ + # The code can also set the global overall_message (str) + # to pass a message that applies to the whole response + inline_script = textwrap.dedent(""" + messages[0] = "Test Message" + overall_message = "Overall message" + """) problem = self.build_problem(answer=inline_script) input_dict = {'1_2_1': '0'} - msg = problem.grade_answers(input_dict).get_msg('1_2_1') - self.assertEqual(msg, "Test Message") + correctmap = problem.grade_answers(input_dict) - def test_function_code(self): + # Check that the message for the particular input was received + input_msg = correctmap.get_msg('1_2_1') + self.assertEqual(input_msg, "Test Message") - # For function code, we pass in three arguments: + # Check that the overall message (for the whole response) was received + overall_msg = correctmap.get_overall_message() + self.assertEqual(overall_msg, "Overall message") + + + def test_function_code_single_input(self): + + # For function code, we pass in these arguments: # # 'expect' is the expect attribute of the # # 'answer_given' is the answer the student gave (if there is just one input) # or an ordered list of answers (if there are multiple inputs) # - # 'student_answers' is a dictionary of answers by input ID - # # # The function should return a dict of the form # { 'ok': BOOL, 'msg': STRING } # - script = """def check_func(expect, answer_given, student_answers): - return {'ok': answer_given == expect, 'msg': 'Message text'}""" + script = textwrap.dedent(""" + def check_func(expect, answer_given): + return {'ok': answer_given == expect, 'msg': 'Message text'} + """) problem = self.build_problem(script=script, cfn="check_func", expect="42") @@ -710,17 +724,134 @@ class CustomResponseTest(ResponseTest): self.assertEqual(correctness, 'incorrect') self.assertEqual(msg, "Message text\n") - def test_multiple_inputs(self): + def test_function_code_multiple_input_no_msg(self): + + # Check functions also have the option of returning + # a single boolean value + # If true, mark all the inputs correct + # If false, mark all the inputs incorrect + script = textwrap.dedent(""" + def check_func(expect, answer_given): + return (answer_given[0] == expect and + answer_given[1] == expect) + """) + + problem = self.build_problem(script=script, cfn="check_func", + expect="42", num_inputs=2) + + # Correct answer -- expect both inputs marked correct + input_dict = {'1_2_1': '42', '1_2_2': '42'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + self.assertEqual(correctness, 'correct') + + correctness = correct_map.get_correctness('1_2_2') + self.assertEqual(correctness, 'correct') + + # One answer incorrect -- expect both inputs marked incorrect + input_dict = {'1_2_1': '0', '1_2_2': '42'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + self.assertEqual(correctness, 'incorrect') + + correctness = correct_map.get_correctness('1_2_2') + self.assertEqual(correctness, 'incorrect') + + def test_script_exception(self): + + # Construct a script that will raise an exception + script = textwrap.dedent(""" + def check_func(expect, answer_given): + raise Exception("Test") + """) + + problem = self.build_problem(script=script, cfn="check_func") + + # Expect that an exception gets raised when we check the answer + with self.assertRaises(Exception): + problem.grade_answers({'1_2_1': '42'}) + + def test_invalid_dict_exception(self): + + # Construct a script that passes back an invalid dict format + script = textwrap.dedent(""" + def check_func(expect, answer_given): + return {'invalid': 'test'} + """) + + problem = self.build_problem(script=script, cfn="check_func") + + # Expect that an exception gets raised when we check the answer + with self.assertRaises(Exception): + problem.grade_answers({'1_2_1': '42'}) + + + def test_function_code_multiple_inputs(self): + + # If the has multiple inputs associated with it, + # the check function can return a dict of the form: + # + # {'overall_message': STRING, + # 'input_list': [{'ok': BOOL, 'msg': STRING}, ...] } + # + # 'overall_message' is displayed at the end of the response + # + # 'input_list' contains dictionaries representing the correctness + # and message for each input. + script = textwrap.dedent(""" + def check_func(expect, answer_given): + check1 = (int(answer_given[0]) == 1) + check2 = (int(answer_given[1]) == 2) + check3 = (int(answer_given[2]) == 3) + return {'overall_message': 'Overall message', + 'input_list': [ + {'ok': check1, 'msg': 'Feedback 1'}, + {'ok': check2, 'msg': 'Feedback 2'}, + {'ok': check3, 'msg': 'Feedback 3'} ] } + """) + + problem = self.build_problem(script=script, + cfn="check_func", num_inputs=3) + + # Grade the inputs (one input incorrect) + input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' } + correct_map = problem.grade_answers(input_dict) + + # Expect that we receive the overall message (for the whole response) + self.assertEqual(correct_map.get_overall_message(), "Overall message") + + # Expect that the inputs were graded individually + self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect') + self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct') + self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct') + + # Expect that we received messages for each individual input + self.assertEqual(correct_map.get_msg('1_2_1'), 'Feedback 1') + self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2') + self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3') + + + def test_multiple_inputs_return_one_status(self): # When given multiple inputs, the 'answer_given' argument # to the check_func() is a list of inputs + # # The sample script below marks the problem as correct # if and only if it receives answer_given=[1,2,3] # (or string values ['1','2','3']) - script = """def check_func(expect, answer_given, student_answers): - check1 = (int(answer_given[0]) == 1) - check2 = (int(answer_given[1]) == 2) - check3 = (int(answer_given[2]) == 3) - return {'ok': (check1 and check2 and check3), 'msg': 'Message text'}""" + # + # Since we return a dict describing the status of one input, + # we expect that the same 'ok' value is applied to each + # of the inputs. + script = textwrap.dedent(""" + def check_func(expect, answer_given): + check1 = (int(answer_given[0]) == 1) + check2 = (int(answer_given[1]) == 2) + check3 = (int(answer_given[2]) == 3) + return {'ok': (check1 and check2 and check3), + 'msg': 'Message text'} + """) problem = self.build_problem(script=script, cfn="check_func", num_inputs=3) From 68cf4c4535f2a663b293efaa3200a86da5f81e3f Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Feb 2013 17:26:10 -0500 Subject: [PATCH 098/214] Added tests for HTMl rendering of problems Added support for rendering response messages --- common/lib/capa/capa/capa_problem.py | 4 +- common/lib/capa/capa/responsetypes.py | 12 +- common/lib/capa/capa/tests/test_correctmap.py | 146 ++++++++++++++ .../lib/capa/capa/tests/test_html_render.py | 186 ++++++++++++++++++ 4 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_correctmap.py create mode 100644 common/lib/capa/capa/tests/test_html_render.py diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 9b8bbd7288..fb0b63b83c 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -510,7 +510,9 @@ class LoncapaProblem(object): # let each Response render itself if problemtree in self.responders: - return self.responders[problemtree].render_html(self._extract_html) + overall_msg = self.correct_map.get_overall_message() + return self.responders[problemtree].render_html(self._extract_html, + response_msg=overall_msg) # let each custom renderer render itself: if problemtree.tag in customrender.registry.registered_tags(): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 89372ca2bd..897f922e93 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -174,13 +174,14 @@ class LoncapaResponse(object): ''' return sum(self.maxpoints.values()) - def render_html(self, renderer): + def render_html(self, renderer, response_msg=None): ''' Return XHTML Element tree representation of this Response. Arguments: - renderer : procedure which produces HTML given an ElementTree + - response_msg: a message displayed at the end of the Response ''' # render ourself as a + our content tree = etree.Element('span') @@ -195,6 +196,13 @@ class LoncapaResponse(object): if item_xhtml is not None: tree.append(item_xhtml) tree.tail = self.xml.tail + + # Add a
    for the message at the end of the response + if response_msg: + response_msg_div = etree.SubElement(tree, 'div') + response_msg_div.set("class", "response_message") + response_msg_div.text = response_msg + return tree def evaluate_answers(self, student_answers, old_cmap): @@ -1060,7 +1068,7 @@ def sympy_check2(): # and the first input stores the message if 'ok' in ret: correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset) - msg = ret['msg'] + msg = ret.get('msg', None) if 1: # try to clean up message html diff --git a/common/lib/capa/capa/tests/test_correctmap.py b/common/lib/capa/capa/tests/test_correctmap.py new file mode 100644 index 0000000000..ed0e9a1948 --- /dev/null +++ b/common/lib/capa/capa/tests/test_correctmap.py @@ -0,0 +1,146 @@ +import unittest +from capa.correctmap import CorrectMap +import datetime + +class CorrectMapTest(unittest.TestCase): + + def setUp(self): + self.cmap = CorrectMap() + + def test_set_input_properties(self): + + # Set the correctmap properties for two inputs + self.cmap.set(answer_id='1_2_1', + correctness='correct', + npoints=5, + msg='Test message', + hint='Test hint', + hintmode='always', + queuestate={'key':'secretstring', + 'time':'20130228100026'}) + + self.cmap.set(answer_id='2_2_1', + correctness='incorrect', + npoints=None, + msg=None, + hint=None, + hintmode=None, + queuestate=None) + + # Assert that each input has the expected properties + self.assertTrue(self.cmap.is_correct('1_2_1')) + self.assertFalse(self.cmap.is_correct('2_2_1')) + + self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct') + self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect') + + self.assertEqual(self.cmap.get_npoints('1_2_1'), 5) + self.assertEqual(self.cmap.get_npoints('2_2_1'), 0) + + self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message') + self.assertEqual(self.cmap.get_msg('2_2_1'), None) + + self.assertEqual(self.cmap.get_hint('1_2_1'), 'Test hint') + self.assertEqual(self.cmap.get_hint('2_2_1'), None) + + self.assertEqual(self.cmap.get_hintmode('1_2_1'), 'always') + self.assertEqual(self.cmap.get_hintmode('2_2_1'), None) + + self.assertTrue(self.cmap.is_queued('1_2_1')) + self.assertFalse(self.cmap.is_queued('2_2_1')) + + self.assertEqual(self.cmap.get_queuetime_str('1_2_1'), '20130228100026') + self.assertEqual(self.cmap.get_queuetime_str('2_2_1'), None) + + self.assertTrue(self.cmap.is_right_queuekey('1_2_1', 'secretstring')) + self.assertFalse(self.cmap.is_right_queuekey('1_2_1', 'invalidstr')) + self.assertFalse(self.cmap.is_right_queuekey('1_2_1', '')) + self.assertFalse(self.cmap.is_right_queuekey('1_2_1', None)) + + self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'secretstring')) + self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'invalidstr')) + self.assertFalse(self.cmap.is_right_queuekey('2_2_1', '')) + self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None)) + + + def test_get_npoints(self): + # Set the correctmap properties for 4 inputs + # 1) correct, 5 points + # 2) correct, None points + # 3) incorrect, 5 points + # 4) incorrect, None points + self.cmap.set(answer_id='1_2_1', + correctness='correct', + npoints=5) + + self.cmap.set(answer_id='2_2_1', + correctness='correct', + npoints=None) + + self.cmap.set(answer_id='3_2_1', + correctness='incorrect', + npoints=5) + + self.cmap.set(answer_id='4_2_1', + correctness='incorrect', + npoints=None) + + # Assert that we get the expected points + # If points assigned and correct --> npoints + # If no points assigned and correct --> 1 point + # Otherwise --> 0 points + self.assertEqual(self.cmap.get_npoints('1_2_1'), 5) + self.assertEqual(self.cmap.get_npoints('2_2_1'), 1) + self.assertEqual(self.cmap.get_npoints('3_2_1'), 0) + self.assertEqual(self.cmap.get_npoints('4_2_1'), 0) + + + def test_set_overall_message(self): + + # Default is an empty string string + self.assertEqual(self.cmap.get_overall_message(), "") + + # Set a message that applies to the whole question + self.cmap.set_overall_message("Test message") + + # Retrieve the message + self.assertEqual(self.cmap.get_overall_message(), "Test message") + + # Setting the message to None --> empty string + self.cmap.set_overall_message(None) + self.assertEqual(self.cmap.get_overall_message(), "") + + def test_update_from_correctmap(self): + # Initialize a CorrectMap with some properties + self.cmap.set(answer_id='1_2_1', + correctness='correct', + npoints=5, + msg='Test message', + hint='Test hint', + hintmode='always', + queuestate={'key':'secretstring', + 'time':'20130228100026'}) + + self.cmap.set_overall_message("Test message") + + # Create a second cmap, then update it to have the same properties + # as the first cmap + other_cmap = CorrectMap() + other_cmap.update(self.cmap) + + # Assert that it has all the same properties + self.assertEqual(other_cmap.get_overall_message(), + self.cmap.get_overall_message()) + + self.assertEqual(other_cmap.get_dict(), + self.cmap.get_dict()) + + + def test_update_from_invalid(self): + # Should get an exception if we try to update() a CorrectMap + # with a non-CorrectMap value + invalid_list = [None, "string", 5, datetime.datetime.today()] + + for invalid in invalid_list: + with self.assertRaises(Exception): + self.cmap.update(invalid) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py new file mode 100644 index 0000000000..aa5312aa14 --- /dev/null +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -0,0 +1,186 @@ +import unittest +from lxml import etree +import os +import textwrap +import json +import mock + +from capa.capa_problem import LoncapaProblem +from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory +from . import test_system + +class CapaHtmlRenderTest(unittest.TestCase): + + def test_include_html(self): + # Create a test file to include + self._create_test_file('test_include.xml', + 'Test include') + + # Generate some XML with an + xml_str = textwrap.dedent(""" + + + + """) + + # Create the problem + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Render the HTML + rendered_html = etree.XML(problem.get_html()) + + # Expect that the include file was embedded in the problem + test_element = rendered_html.find("test") + self.assertEqual(test_element.tag, "test") + self.assertEqual(test_element.text, "Test include") + + + def test_process_outtext(self): + # Generate some XML with and + xml_str = textwrap.dedent(""" + + Test text + + """) + + # Create the problem + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Render the HTML + rendered_html = etree.XML(problem.get_html()) + + # Expect that the and + # were converted to tags + span_element = rendered_html.find('span') + self.assertEqual(span_element.text, 'Test text') + + def test_render_script(self): + # Generate some XML with a + + """) + + # Create the problem + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Render the HTML + rendered_html = etree.XML(problem.get_html()) + + # Expect that the script element has been removed from the rendered HTML + script_element = rendered_html.find('script') + self.assertEqual(None, script_element) + + def test_render_response_xml(self): + # Generate some XML for a string response + kwargs = {'question_text': "Test question", + 'explanation_text': "Test explanation", + 'answer': 'Test answer', + 'hints': [('test prompt', 'test_hint', 'test hint text')]} + xml_str = StringResponseXMLFactory().build_xml(**kwargs) + + # Mock out the template renderer + test_system.render_template = mock.Mock() + test_system.render_template.return_value = "
    Input Template Render
    " + + # Create the problem and render the HTML + problem = LoncapaProblem(xml_str, '1', system=test_system) + rendered_html = etree.XML(problem.get_html()) + + # Expect problem has been turned into a
    + self.assertEqual(rendered_html.tag, "div") + + # Expect question text is in a

    child + question_element = rendered_html.find("p") + self.assertEqual(question_element.text, "Test question") + + # Expect that the response has been turned into a + response_element = rendered_html.find("span") + self.assertEqual(response_element.tag, "span") + + # Expect that the response + # that contains a

    for the textline + textline_element = response_element.find("div") + self.assertEqual(textline_element.text, 'Input Template Render') + + # Expect a child
    for the solution + # with the rendered template + solution_element = rendered_html.find("div") + self.assertEqual(solution_element.text, 'Input Template Render') + + # Expect that the template renderer was called with the correct + # arguments, once for the textline input and once for + # the solution + expected_textline_context = {'status': 'unsubmitted', + 'value': '', + 'preprocessor': None, + 'msg': '', + 'inline': False, + 'hidden': False, + 'do_math': False, + 'id': '1_2_1', + 'size': None} + + expected_solution_context = {'id': '1_solution_1'} + + expected_calls = [mock.call('textline.html', expected_textline_context), + mock.call('solutionspan.html', expected_solution_context)] + + self.assertEqual(test_system.render_template.call_args_list, + expected_calls) + + + def test_render_response_with_overall_msg(self): + # CustomResponse script that sets an overall_message + script=textwrap.dedent(""" + def check_func(*args): + return {'overall_message': 'Test message', + 'input_list': [ {'ok': True, 'msg': '' } ] } + """) + + # Generate some XML for a CustomResponse + kwargs = {'script':script, 'cfn': 'check_func'} + xml_str = CustomResponseXMLFactory().build_xml(**kwargs) + + # Create the problem and render the html + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Grade the problem + correctmap = problem.grade_answers({'1_2_1': 'test'}) + + # Render the html + rendered_html = etree.XML(problem.get_html()) + + + # Expect that there is a
    within the response
    + # with css class response_message + msg_div_element = rendered_html.find(".//div[@class='response_message']") + self.assertEqual(msg_div_element.tag, "div") + self.assertEqual(msg_div_element.get('class'), "response_message") + + + def test_substitute_python_vars(self): + # Generate some XML with Python variables defined in a script + # and used later as attributes + xml_str = textwrap.dedent(""" + + + + + """) + + # Create the problem and render the HTML + problem = LoncapaProblem(xml_str, '1', system=test_system) + rendered_html = etree.XML(problem.get_html()) + + # Expect that the variable $test has been replaced with its value + span_element = rendered_html.find('span') + self.assertEqual(span_element.get('attr'), "TEST") + + def _create_test_file(self, path, content_str): + test_fp = test_system.filestore.open(path, "w") + test_fp.write(content_str) + test_fp.close() + + self.addCleanup(lambda: os.remove(test_fp.name)) From 952716af13fa3365bf1952c929354787cc315f7d Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 1 Mar 2013 09:02:04 -0500 Subject: [PATCH 099/214] Added clean-up code for message HTML. If overall message is a parseable XHTML tree, it is inserted as a tree rather than text. --- common/lib/capa/capa/responsetypes.py | 47 ++++++++++++------- .../lib/capa/capa/tests/test_html_render.py | 7 ++- .../lib/capa/capa/tests/test_responsetypes.py | 4 +- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 897f922e93..698ec41a0a 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -201,7 +201,17 @@ class LoncapaResponse(object): if response_msg: response_msg_div = etree.SubElement(tree, 'div') response_msg_div.set("class", "response_message") - response_msg_div.text = response_msg + + # If the response message can be represented as an XHTML tree, + # create the tree and append it to the message
    + try: + response_tree = etree.XML(response_msg) + response_msg_div.append(response_tree) + + # Otherwise, assume that the message is text (not XHTML) + # and insert it as the text of the message
    + except: + response_msg_div.text = response_msg return tree @@ -1069,20 +1079,7 @@ def sympy_check2(): if 'ok' in ret: correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset) msg = ret.get('msg', None) - - if 1: - # try to clean up message html - msg = '' + msg + '' - msg = msg.replace('<', '<') - #msg = msg.replace('<','<') - msg = etree.tostring(fromstring_bs(msg, convertEntities=None), - pretty_print=True) - #msg = etree.tostring(fromstring_bs(msg),pretty_print=True) - msg = msg.replace(' ', '') - #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 - msg = re.sub('(?ms)(.*)', '\\1', msg) - - messages[0] = msg + messages[0] = self.clean_message_html(msg) # Another kind of dictionary the check function can return has @@ -1101,7 +1098,8 @@ def sympy_check2(): messages = [] for input_dict in input_list: correct.append('correct' if input_dict['ok'] else 'incorrect') - messages.append(input_dict['msg'] if 'msg' in input_dict else None) + msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None + messages.append(msg) # Otherwise, we do not recognize the dictionary # Raise an exception @@ -1117,13 +1115,30 @@ def sympy_check2(): # build map giving "correct"ness of the answer(s) correct_map = CorrectMap() + + overall_message = self.clean_message_html(overall_message) correct_map.set_overall_message(overall_message) + for k in range(len(idset)): npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0 correct_map.set(idset[k], correct[k], msg=messages[k], npoints=npoints) return correct_map + def clean_message_html(self, msg): + # try to clean up message html + msg = '' + msg + '' + msg = msg.replace('<', '<') + #msg = msg.replace('<','<') + msg = etree.tostring(fromstring_bs(msg, convertEntities=None), + pretty_print=True) + #msg = etree.tostring(fromstring_bs(msg),pretty_print=True) + msg = msg.replace(' ', '') + #msg = re.sub('(.*)','\\1',msg,flags=re.M|re.DOTALL) # python 2.7 + msg = re.sub('(?ms)(.*)', '\\1', msg) + + return msg.strip() + def get_answers(self): ''' Give correct answer expected for this response. diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index aa5312aa14..257e63b611 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -135,7 +135,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # CustomResponse script that sets an overall_message script=textwrap.dedent(""" def check_func(*args): - return {'overall_message': 'Test message', + return {'overall_message': '

    Test message

    ', 'input_list': [ {'ok': True, 'msg': '' } ] } """) @@ -159,6 +159,11 @@ class CapaHtmlRenderTest(unittest.TestCase): self.assertEqual(msg_div_element.tag, "div") self.assertEqual(msg_div_element.get('class'), "response_message") + # Expect that the
    contains our message (as part of the XML tree) + msg_p_element = msg_div_element.find('p') + self.assertEqual(msg_p_element.tag, "p") + self.assertEqual(msg_p_element.text, "Test message") + def test_substitute_python_vars(self): # Generate some XML with Python variables defined in a script diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 451e6ed14b..538ee6fe50 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -712,7 +712,7 @@ class CustomResponseTest(ResponseTest): msg = correct_map.get_msg('1_2_1') self.assertEqual(correctness, 'correct') - self.assertEqual(msg, "Message text\n") + self.assertEqual(msg, "Message text") # Incorrect answer input_dict = {'1_2_1': '0'} @@ -722,7 +722,7 @@ class CustomResponseTest(ResponseTest): msg = correct_map.get_msg('1_2_1') self.assertEqual(correctness, 'incorrect') - self.assertEqual(msg, "Message text\n") + self.assertEqual(msg, "Message text") def test_function_code_multiple_input_no_msg(self): From 9ac8ef542f4ced63fb7b8cf1769d0f6f8036c486 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 1 Mar 2013 09:35:37 -0500 Subject: [PATCH 100/214] Overall message HTMl now correctly renders when multiple HTML tags are passed as the message (even when there's no root tag) --- common/lib/capa/capa/responsetypes.py | 37 ++++++++++++------- .../lib/capa/capa/tests/test_html_render.py | 12 ++++-- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 698ec41a0a..6b57895013 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -199,19 +199,7 @@ class LoncapaResponse(object): # Add a
    for the message at the end of the response if response_msg: - response_msg_div = etree.SubElement(tree, 'div') - response_msg_div.set("class", "response_message") - - # If the response message can be represented as an XHTML tree, - # create the tree and append it to the message
    - try: - response_tree = etree.XML(response_msg) - response_msg_div.append(response_tree) - - # Otherwise, assume that the message is text (not XHTML) - # and insert it as the text of the message
    - except: - response_msg_div.text = response_msg + tree.append(self._render_response_msg_html(response_msg)) return tree @@ -337,6 +325,29 @@ class LoncapaResponse(object): def __unicode__(self): return u'LoncapaProblem Response %s' % self.xml.tag + def _render_response_msg_html(self, response_msg): + """ Render a
    for a message that applies to the entire response. + + *response_msg* is a string, which may contain XHTML markup + + Returns an etree element representing the response message
    """ + # First try wrapping the text in a
    and parsing + # it as an XHTML tree + try: + response_msg_div = etree.XML('
    %s
    ' % str(response_msg)) + + # If we can't do that, create the
    and set the message + # as the text of the
    + except: + response_msg_div = etree.Element('div') + response_msg_div.text = str(response_msg) + + + # Set the css class of the message
    + response_msg_div.set("class", "response_message") + + return response_msg_div + #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 257e63b611..64f031ea59 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -135,7 +135,8 @@ class CapaHtmlRenderTest(unittest.TestCase): # CustomResponse script that sets an overall_message script=textwrap.dedent(""" def check_func(*args): - return {'overall_message': '

    Test message

    ', + msg = '

    Test message 1

    Test message 2

    ' + return {'overall_message': msg, 'input_list': [ {'ok': True, 'msg': '' } ] } """) @@ -160,9 +161,12 @@ class CapaHtmlRenderTest(unittest.TestCase): self.assertEqual(msg_div_element.get('class'), "response_message") # Expect that the
    contains our message (as part of the XML tree) - msg_p_element = msg_div_element.find('p') - self.assertEqual(msg_p_element.tag, "p") - self.assertEqual(msg_p_element.text, "Test message") + msg_p_elements = msg_div_element.findall('p') + self.assertEqual(msg_p_elements[0].tag, "p") + self.assertEqual(msg_p_elements[0].text, "Test message 1") + + self.assertEqual(msg_p_elements[1].tag, "p") + self.assertEqual(msg_p_elements[1].text, "Test message 2") def test_substitute_python_vars(self): From c3da73ed1eea6be8afb24d5505190b2d4fa65ce9 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 1 Mar 2013 10:57:57 -0500 Subject: [PATCH 101/214] Changed title of custom response doc. Added custom response doc to index --- .../course_data_formats/custom_response.rst | 142 ++++++++++++++++++ doc/public/index.rst | 1 + 2 files changed, 143 insertions(+) create mode 100644 doc/public/course_data_formats/custom_response.rst diff --git a/doc/public/course_data_formats/custom_response.rst b/doc/public/course_data_formats/custom_response.rst new file mode 100644 index 0000000000..1ca8214915 --- /dev/null +++ b/doc/public/course_data_formats/custom_response.rst @@ -0,0 +1,142 @@ +#################################### +CustomResponse XML and Python Script +#################################### + +This document explains how to write a CustomResponse problem. CustomResponse +problems execute Python script to check student answers and provide hints. + +There are two general ways to create a CustomResponse problem: + + +***************** +Answer tag format +***************** +One format puts the Python code in an ```` tag: + +.. code-block:: xml + + +

    What is the sum of 2 and 3?

    + + + + + + + # Python script goes here + +
    + + +The Python script interacts with these variables in the global context: + * ``answers``: An ordered list of answers the student provided. + For example, if the student answered ``6``, then ``answers[0]`` would + equal ``6``. + * ``expect``: The value of the ``expect`` attribute of ```` + (if provided). + * ``correct``: An ordered list of strings indicating whether the + student answered the question correctly. Valid values are + ``"correct"``, ``"incorrect"``, and ``"unknown"``. You can set these + values in the script. + * ``messages``: An ordered list of message strings that will be displayed + beneath each input. You can use this to provide hints to users. + For example ``messages[0] = "The capital of California is Sacramento"`` + would display that message beneath the first input of the response. + * ``overall_message``: A string that will be displayed beneath the + entire problem. You can use this to provide a hint that applies + to the entire problem rather than a particular input. + +Example of a checking script: + +.. code-block:: python + + if answers[0] == expect: + correct[0] = 'correct' + overall_message = 'Good job!' + else: + correct[0] = 'incorrect' + messages[0] = 'This answer is incorrect' + overall_message = 'Please try again' + +**Important**: Python is picky about indentation. Within the ```` tag, +you must begin your script with no indentation. + +***************** +Script tag format +***************** +The other way to create a CustomResponse is to put a "checking function" +in a `` + + + +**Important**: Python is picky about indentation. Within the `` - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/3BFB2B59BF73690E64CA963B37E3E6C2.cache.html b/common/static/js/capa/genex/3BFB2B59BF73690E64CA963B37E3E6C2.cache.html deleted file mode 100644 index f47030bf01..0000000000 --- a/common/static/js/capa/genex/3BFB2B59BF73690E64CA963B37E3E6C2.cache.html +++ /dev/null @@ -1,631 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/4DA5F49EF0DE7E7B1720F5F56CD2F9F3.cache.html b/common/static/js/capa/genex/4DA5F49EF0DE7E7B1720F5F56CD2F9F3.cache.html new file mode 100644 index 0000000000..1272a53a74 --- /dev/null +++ b/common/static/js/capa/genex/4DA5F49EF0DE7E7B1720F5F56CD2F9F3.cache.html @@ -0,0 +1,640 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/4EC2E5D94B410DDAB081BBAC4222386F.cache.html b/common/static/js/capa/genex/4EC2E5D94B410DDAB081BBAC4222386F.cache.html deleted file mode 100644 index 090d22b68c..0000000000 --- a/common/static/js/capa/genex/4EC2E5D94B410DDAB081BBAC4222386F.cache.html +++ /dev/null @@ -1,618 +0,0 @@ - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/5D39C857C99EA0E0722C72B4A9A60272.cache.html b/common/static/js/capa/genex/5D39C857C99EA0E0722C72B4A9A60272.cache.html new file mode 100644 index 0000000000..cb510fbff2 --- /dev/null +++ b/common/static/js/capa/genex/5D39C857C99EA0E0722C72B4A9A60272.cache.html @@ -0,0 +1,650 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/7504BC625F3CBFF0967F88C441871055.cache.html b/common/static/js/capa/genex/7504BC625F3CBFF0967F88C441871055.cache.html deleted file mode 100644 index 143af1d438..0000000000 --- a/common/static/js/capa/genex/7504BC625F3CBFF0967F88C441871055.cache.html +++ /dev/null @@ -1,631 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/88AB039AB796F1D6C3B133DAD892A057.cache.html b/common/static/js/capa/genex/88AB039AB796F1D6C3B133DAD892A057.cache.html deleted file mode 100644 index a75fd5115e..0000000000 --- a/common/static/js/capa/genex/88AB039AB796F1D6C3B133DAD892A057.cache.html +++ /dev/null @@ -1,607 +0,0 @@ - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/BE6A498E963EF437B7FAFAFAD9F6A252.cache.html b/common/static/js/capa/genex/BE6A498E963EF437B7FAFAFAD9F6A252.cache.html new file mode 100644 index 0000000000..7236a3af99 --- /dev/null +++ b/common/static/js/capa/genex/BE6A498E963EF437B7FAFAFAD9F6A252.cache.html @@ -0,0 +1,652 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/C66BAF3695DBE904ECE0FB5DC56AED92.cache.html b/common/static/js/capa/genex/C66BAF3695DBE904ECE0FB5DC56AED92.cache.html deleted file mode 100644 index 545dcff856..0000000000 --- a/common/static/js/capa/genex/C66BAF3695DBE904ECE0FB5DC56AED92.cache.html +++ /dev/null @@ -1,621 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/CD3437123EB1250A9B0D3DD724676C7D.cache.html b/common/static/js/capa/genex/CD3437123EB1250A9B0D3DD724676C7D.cache.html new file mode 100644 index 0000000000..41d6ac3260 --- /dev/null +++ b/common/static/js/capa/genex/CD3437123EB1250A9B0D3DD724676C7D.cache.html @@ -0,0 +1,640 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/E09CC241B930308C35EE3D52C93FA3F5.cache.html b/common/static/js/capa/genex/E09CC241B930308C35EE3D52C93FA3F5.cache.html new file mode 100644 index 0000000000..38e8785086 --- /dev/null +++ b/common/static/js/capa/genex/E09CC241B930308C35EE3D52C93FA3F5.cache.html @@ -0,0 +1,626 @@ + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/F0126043DD1A3B0036CEE5B4C0118B81.cache.html b/common/static/js/capa/genex/F0126043DD1A3B0036CEE5B4C0118B81.cache.html new file mode 100644 index 0000000000..a5d110176c --- /dev/null +++ b/common/static/js/capa/genex/F0126043DD1A3B0036CEE5B4C0118B81.cache.html @@ -0,0 +1,650 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/genex.nocache.js b/common/static/js/capa/genex/genex.nocache.js index b130ea3689..a9128971d3 100644 --- a/common/static/js/capa/genex/genex.nocache.js +++ b/common/static/js/capa/genex/genex.nocache.js @@ -1,4 +1,4 @@ -function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='1B3B0A256735176413A40727372820E6',Rb='3BFB2B59BF73690E64CA963B37E3E6C2',Sb='4EC2E5D94B410DDAB081BBAC4222386F',Tb='7504BC625F3CBFF0967F88C441871055',Ub='88AB039AB796F1D6C3B133DAD892A057',Wb=':',pb='::',dc=' + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/1F433010E1134C95BF6CB43F552F3019.cache.html b/common/static/js/capa/genex/1F433010E1134C95BF6CB43F552F3019.cache.html new file mode 100644 index 0000000000..1e99fe0f19 --- /dev/null +++ b/common/static/js/capa/genex/1F433010E1134C95BF6CB43F552F3019.cache.html @@ -0,0 +1,649 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/2DDA730EDABB80B88A6B0DFA3AFEACA2.cache.html b/common/static/js/capa/genex/2DDA730EDABB80B88A6B0DFA3AFEACA2.cache.html new file mode 100644 index 0000000000..743492768b --- /dev/null +++ b/common/static/js/capa/genex/2DDA730EDABB80B88A6B0DFA3AFEACA2.cache.html @@ -0,0 +1,639 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/4DA5F49EF0DE7E7B1720F5F56CD2F9F3.cache.html b/common/static/js/capa/genex/4DA5F49EF0DE7E7B1720F5F56CD2F9F3.cache.html deleted file mode 100644 index 1272a53a74..0000000000 --- a/common/static/js/capa/genex/4DA5F49EF0DE7E7B1720F5F56CD2F9F3.cache.html +++ /dev/null @@ -1,640 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/4EEB1DCF4B30D366C27968D1B5C0BD04.cache.html b/common/static/js/capa/genex/4EEB1DCF4B30D366C27968D1B5C0BD04.cache.html new file mode 100644 index 0000000000..4aa12e55d4 --- /dev/null +++ b/common/static/js/capa/genex/4EEB1DCF4B30D366C27968D1B5C0BD04.cache.html @@ -0,0 +1,651 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/5033ABB047340FB9346B622E2CC7107D.cache.html b/common/static/js/capa/genex/5033ABB047340FB9346B622E2CC7107D.cache.html new file mode 100644 index 0000000000..167a193adb --- /dev/null +++ b/common/static/js/capa/genex/5033ABB047340FB9346B622E2CC7107D.cache.html @@ -0,0 +1,625 @@ + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/5D39C857C99EA0E0722C72B4A9A60272.cache.html b/common/static/js/capa/genex/5D39C857C99EA0E0722C72B4A9A60272.cache.html deleted file mode 100644 index cb510fbff2..0000000000 --- a/common/static/js/capa/genex/5D39C857C99EA0E0722C72B4A9A60272.cache.html +++ /dev/null @@ -1,650 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/BE6A498E963EF437B7FAFAFAD9F6A252.cache.html b/common/static/js/capa/genex/BE6A498E963EF437B7FAFAFAD9F6A252.cache.html deleted file mode 100644 index 7236a3af99..0000000000 --- a/common/static/js/capa/genex/BE6A498E963EF437B7FAFAFAD9F6A252.cache.html +++ /dev/null @@ -1,652 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/CD3437123EB1250A9B0D3DD724676C7D.cache.html b/common/static/js/capa/genex/CD3437123EB1250A9B0D3DD724676C7D.cache.html deleted file mode 100644 index 41d6ac3260..0000000000 --- a/common/static/js/capa/genex/CD3437123EB1250A9B0D3DD724676C7D.cache.html +++ /dev/null @@ -1,640 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/DF3D3A7FAEE63D711CF2D95BDB3F538C.cache.html b/common/static/js/capa/genex/DF3D3A7FAEE63D711CF2D95BDB3F538C.cache.html new file mode 100644 index 0000000000..913b90be20 --- /dev/null +++ b/common/static/js/capa/genex/DF3D3A7FAEE63D711CF2D95BDB3F538C.cache.html @@ -0,0 +1,639 @@ + + + + \ No newline at end of file diff --git a/common/static/js/capa/genex/E09CC241B930308C35EE3D52C93FA3F5.cache.html b/common/static/js/capa/genex/E09CC241B930308C35EE3D52C93FA3F5.cache.html deleted file mode 100644 index 38e8785086..0000000000 --- a/common/static/js/capa/genex/E09CC241B930308C35EE3D52C93FA3F5.cache.html +++ /dev/null @@ -1,626 +0,0 @@ - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/F0126043DD1A3B0036CEE5B4C0118B81.cache.html b/common/static/js/capa/genex/F0126043DD1A3B0036CEE5B4C0118B81.cache.html deleted file mode 100644 index a5d110176c..0000000000 --- a/common/static/js/capa/genex/F0126043DD1A3B0036CEE5B4C0118B81.cache.html +++ /dev/null @@ -1,650 +0,0 @@ - - - - \ No newline at end of file diff --git a/common/static/js/capa/genex/genex.nocache.js b/common/static/js/capa/genex/genex.nocache.js index a9128971d3..07da038234 100644 --- a/common/static/js/capa/genex/genex.nocache.js +++ b/common/static/js/capa/genex/genex.nocache.js @@ -1,4 +1,4 @@ -function genex(){var P='',xb='" for "gwt:onLoadErrorFn"',vb='" for "gwt:onPropertyErrorFn"',ib='"><\/script>',Z='#',Xb='.cache.html',_='/',lb='//',Qb='4DA5F49EF0DE7E7B1720F5F56CD2F9F3',Rb='5D39C857C99EA0E0722C72B4A9A60272',Wb=':',pb='::',dc=' From 88a30cb7330a079238b00ea68b6ae619dd3e20da Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 4 Mar 2013 13:21:19 -0500 Subject: [PATCH 144/214] Fixes in response to Victor's comments. --- lms/djangoapps/courseware/models.py | 4 +++- lms/djangoapps/courseware/views.py | 3 ++- lms/templates/courseware/submission_history.html | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 58ede38d58..3ad850f066 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -67,6 +67,8 @@ class StudentModuleHistory(models.Model): Student. Right now, we restrict this to problems so that the table doesn't explode in size.""" + HISTORY_SAVING_TYPES = {'problem'} + class Meta: get_latest_by = "created" @@ -81,7 +83,7 @@ class StudentModuleHistory(models.Model): @receiver(post_save, sender=StudentModule) def save_history(sender, instance, **kwargs): - if instance.module_type == 'problem': + if instance.module_type in StudentModuleHistory.HISTORY_SAVING_TYPES: history_entry = StudentModuleHistory(student_module=instance, version=None, created=instance.modified, diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index b6fb31fc25..d2da893776 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -616,10 +616,11 @@ def submission_history(request, course_id, student_username, location): Right now this only works for problems because that's all StudentModuleHistory records. """ - # Make sure our has_access check uses the course_id, eh? or is ourself course = get_course_with_access(request.user, course_id, 'load') staff_access = has_access(request.user, course, 'staff') + # Permission Denied if they don't have staff access and are trying to see + # somebody else's submission history. if (student_username != request.user.username) and (not staff_access): raise PermissionDenied diff --git a/lms/templates/courseware/submission_history.html b/lms/templates/courseware/submission_history.html index cd14e9b2ab..3d78cbd4f0 100644 --- a/lms/templates/courseware/submission_history.html +++ b/lms/templates/courseware/submission_history.html @@ -4,7 +4,7 @@ % for i, entry in enumerate(history_entries):
    -#${len(history_entries) - i}: ${entry.created} EST
    +#${len(history_entries) - i}: ${entry.created} UTC
    Score: ${entry.grade} / ${entry.max_grade}
     ${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h}
    
    From 2e6115c73462a7cbace4114c12ccb607d5c2a554 Mon Sep 17 00:00:00 2001
    From: Arthur Barrett 
    Date: Mon, 4 Mar 2013 14:19:28 -0500
    Subject: [PATCH 145/214] constrain the annotation call-out horizontally so it
     is always visible in the window
    
    ---
     .../xmodule/js/src/annotatable/display.coffee | 21 +++++++++++++++++--
     1 file changed, 19 insertions(+), 2 deletions(-)
    
    diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
    index bb4ddf5404..c97250c8f8 100644
    --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
    +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
    @@ -73,8 +73,13 @@ class @Annotatable
                 classes: 'ui-tooltip-annotatable'
             events:
                 show: @onShowTip
    +            visible: @onVisibleTip
     
    -    onShowTip: (event, api) => event.preventDefault() if @annotationsHidden
    +    onShowTip: (event, api) =>
    +        event.preventDefault() if @annotationsHidden
    +
    +    onVisibleTip: (event, api) =>
    +        @constrainTipHorizontally(api.elements.tooltip, event.originalEvent.pageX)
     
         onClickToggleAnnotations: (e) => @toggleAnnotations()
     
    @@ -199,7 +204,19 @@ class @Annotatable
                 $(el).qtip('show')
                 api = $(el).qtip('api')
                 $(api?.elements.tooltip).offset(offset)
    - 
    +
    +    constrainTipHorizontally: (tip, mouseX) ->
    +        win_width = $(window).width()
    +        tip_center = $(tip).width() / 2 # see position setting of tip
    +        tip_offset = $(tip).offset()
    +
    +        if (tip_center + mouseX) > win_width
    +          adjust_left = '-=' + (tip_center + mouseX - win_width)
    +        else if (mouseX - tip_center) < 0
    +          adjust_left = '+=' + (tip_center - mouseX)
    +
    +        $(tip).animate({ left: adjust_left }) if adjust_left?
    +
         _once: (fn) ->
             done = false
             return =>
    
    From b0abfa07c83d484b16dde061d42aaf5f52e26cf5 Mon Sep 17 00:00:00 2001
    From: Arthur Barrett 
    Date: Mon, 4 Mar 2013 14:23:41 -0500
    Subject: [PATCH 146/214] style the guided discussion text so it is boxed like
     the instructions text.
    
    ---
     common/lib/xmodule/xmodule/css/annotatable/display.scss | 1 +
     lms/templates/annotatable.html                          | 5 +++--
     2 files changed, 4 insertions(+), 2 deletions(-)
    
    diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss
    index 4fb691fcb9..2db3b85e66 100644
    --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss
    +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss
    @@ -25,6 +25,7 @@ $body-font-size: em(14);
         border-top: 1px solid $border-color;
         margin-top: .5em;
         padding-top: .5em;
    +    @include clearfix;
       }
     }
     
    diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html
    index bdb5a8acc3..af872b8c95 100644
    --- a/lms/templates/annotatable.html
    +++ b/lms/templates/annotatable.html
    @@ -22,7 +22,8 @@
                 Guided Discussion
                 Hide Annotations
             
    +
    + ${content_html} +
    - -
    ${content_html}
    From a81e9a673c45d58088387f932d929d20c710c444 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 4 Mar 2013 14:59:45 -0500 Subject: [PATCH 147/214] additional courseware view optimizations. Do a 'depth' fetch on the selected section so that it does a more efficient set of queries to the database. Also, in the CachingDescriptorSystem, if we have a 'cache miss', when we do the actual fetch (which creates a new 'system'), keep that fetched data around in our own collection, in case it is queried again --- common/lib/xmodule/xmodule/modulestore/mongo.py | 5 ++++- lms/djangoapps/courseware/views.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 8068129559..b46c29b2bc 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -64,7 +64,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem): location = Location(location) json_data = self.module_data.get(location) if json_data is None: - return self.modulestore.get_item(location) + module = self.modulestore.get_item(location) + if module is not None: + self.module_data.update(module.system.module_data) + return module else: # load the module and apply the inherited metadata try: diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 8b48572818..a9e8298db7 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -306,6 +306,10 @@ def index(request, course_id, chapter=None, section=None, # Specifically asked-for section doesn't exist raise Http404 + # cdodge: this looks silly, but let's refetch the section_descriptor with depth=None + # which will prefetch the children more efficiently than doing a recursive load + section_descriptor = modulestore().get_instance(course.id, section_descriptor.location, depth=None) + # Load all descendants of the section, because we're going to display its # html, which in general will need all of its children section_module_cache = StudentModuleCache.cache_for_descriptor_descendents( From 01a1bf6b6f3a3adb07a249e767c3d8a6b56d03d0 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 4 Mar 2013 15:19:24 -0500 Subject: [PATCH 148/214] It's not UTC time, it's whatever the local settings are set to (New York in prod) --- lms/templates/courseware/submission_history.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/courseware/submission_history.html b/lms/templates/courseware/submission_history.html index 3d78cbd4f0..683c61c5a0 100644 --- a/lms/templates/courseware/submission_history.html +++ b/lms/templates/courseware/submission_history.html @@ -4,7 +4,7 @@ % for i, entry in enumerate(history_entries):
    -#${len(history_entries) - i}: ${entry.created} UTC
    +#${len(history_entries) - i}: ${entry.created} (${TIME_ZONE} time)
    Score: ${entry.grade} / ${entry.max_grade}
     ${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h}
    
    From 020e1e94fbf7b6977b8d34c872f9a09a635910ae Mon Sep 17 00:00:00 2001
    From: Don Mitchell 
    Date: Mon, 4 Mar 2013 15:38:47 -0500
    Subject: [PATCH 149/214] Move side effecting of definition for grader to the
     course_module and don't make caller responsible.
    
    ---
     cms/djangoapps/models/settings/course_grading.py |  2 --
     common/lib/xmodule/xmodule/course_module.py      | 13 ++++---------
     2 files changed, 4 insertions(+), 11 deletions(-)
    
    diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py
    index f1f68b5f5b..3d0b8f78af 100644
    --- a/cms/djangoapps/models/settings/course_grading.py
    +++ b/cms/djangoapps/models/settings/course_grading.py
    @@ -118,8 +118,6 @@ class CourseGradingModel(object):
                 descriptor.raw_grader[index] = grader
             else:
                 descriptor.raw_grader.append(grader)
    -        # make definition notice the update
    -        descriptor.raw_grader = descriptor.raw_grader
     
             get_modulestore(course_location).update_item(course_location, descriptor.definition['data'])
     
    diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
    index 2ed780fcae..86ae673ae8 100644
    --- a/common/lib/xmodule/xmodule/course_module.py
    +++ b/common/lib/xmodule/xmodule/course_module.py
    @@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor):
             # NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
             #   disable the syllabus content for courses that do not provide a syllabus
             self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
    +        self._grading_policy = {}
             self.set_grading_policy(self.definition['data'].get('grading_policy', None))
     
             self.test_center_exams = []
    @@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor):
             grading_policy.update(course_policy)
     
             # Here is where we should parse any configurations, so that we can fail early
    -        grading_policy['RAW_GRADER'] = grading_policy['GRADER']  # used for cms access
    -        grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
    -        self._grading_policy = grading_policy
    -
    -
    +        # Use setters so that side effecting to .definitions works
    +        self.raw_grader = grading_policy['GRADER']  # used for cms access
    +        self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
     
         @classmethod
         def read_grading_policy(cls, paths, system):
    @@ -317,10 +316,6 @@ class CourseDescriptor(SequenceDescriptor):
             if isinstance(value, time.struct_time):
                 self.metadata['enrollment_end'] = stringify_time(value)
     
    -    @property
    -    def grader(self):
    -        return self._grading_policy['GRADER']
    -
         @property
         def raw_grader(self):
             return self._grading_policy['RAW_GRADER']
    
    From d14890685179460236fbdde5b040c677252de244 Mon Sep 17 00:00:00 2001
    From: Will Daly 
    Date: Mon, 4 Mar 2013 16:06:14 -0500
    Subject: [PATCH 150/214] Added unit tests for capa module check_problem()
     method
    
    ---
     .../xmodule/xmodule/tests/test_capa_module.py | 147 ++++++++++++++++--
     1 file changed, 137 insertions(+), 10 deletions(-)
    
    diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
    index 7b565ff2da..c569309af9 100644
    --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
    +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
    @@ -1,9 +1,11 @@
     import datetime
     import json
    -from mock import Mock
    +from mock import Mock, patch
     from pprint import pprint
     import unittest
     
    +import xmodule
    +import capa
     from xmodule.capa_module import CapaModule
     from xmodule.modulestore import Location
     from lxml import etree
    @@ -33,6 +35,14 @@ class CapaFactory(object):
             CapaFactory.num += 1
             return CapaFactory.num
     
    +    @staticmethod
    +    def input_key():
    +        """ Return the input key to use when passing GET parameters """
    +        return ("input_" + 
    +                "-".join(['i4x', 'edX', 'capa_test', 'problem', 
    +                        'SampleProblem%d' % CapaFactory.num]) +
    +                "_2_1")
    +
         @staticmethod
         def create(graceperiod=None,
                    due=None,
    @@ -59,11 +69,10 @@ class CapaFactory(object):
                     module.
     
                 attempts: also added to instance state.  Will be converted to an int.
    -            correct: if True, the problem will be initialized to be answered correctly.
             """
             definition = {'data': CapaFactory.sample_problem_xml, }
             location = Location(["i4x", "edX", "capa_test", "problem",
    -                             "SampleProblem{0}".format(CapaFactory.next_num())])
    +                             "SampleProblem%d" % CapaFactory.next_num()])
             metadata = {}
             if graceperiod is not None:
                 metadata['graceperiod'] = graceperiod
    @@ -89,13 +98,6 @@ class CapaFactory(object):
                 # since everything else is a string.
                 instance_state_dict['attempts'] = int(attempts)
     
    -        if correct:
    -            # TODO: make this actually set an answer of 3.14, and mark it correct
    -            #instance_state_dict['student_answers'] = {}
    -            #instance_state_dict['correct_map'] = {}
    -            pass
    -
    -
             if len(instance_state_dict) > 0:
                 instance_state = json.dumps(instance_state_dict)
             else:
    @@ -332,3 +334,128 @@ class CapaModuleTest(unittest.TestCase):
                                 'input_1': 'test 2' }
             with self.assertRaises(ValueError):
                 result = CapaModule.make_dict_of_responses(invalid_get_dict)
    +
    +    def test_check_problem_correct(self):
    +
    +        module = CapaFactory.create(attempts=1)
    +
    +        # Simulate that all answers are marked correct, no matter
    +        # what the input is, by patching CorrectMap.is_correct()
    +        with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct:
    +            mock_is_correct.return_value = True
    +
    +            # Check the problem
    +            get_request_dict = { CapaFactory.input_key(): '3.14' }
    +            result = module.check_problem(get_request_dict)
    +
    +        # Expect that the problem is marked correct
    +        self.assertEqual(result['success'], 'correct')
    +
    +        # Expect that the number of attempts is incremented by 1
    +        self.assertEqual(module.attempts, 2)
    +
    +
    +    def test_check_problem_incorrect(self):
    +
    +        module = CapaFactory.create(attempts=0)
    +
    +        # Simulate marking the input incorrect
    +        with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct:
    +            mock_is_correct.return_value = False
    +
    +            # Check the problem
    +            get_request_dict = { CapaFactory.input_key(): '0' }
    +            result = module.check_problem(get_request_dict)
    +
    +        # Expect that the problem is marked correct
    +        self.assertEqual(result['success'], 'incorrect')
    +
    +        # Expect that the number of attempts is incremented by 1
    +        self.assertEqual(module.attempts, 1)
    +
    +
    +    def test_check_problem_closed(self):
    +        module = CapaFactory.create(attempts=3)
    +
    +        # Problem closed -- cannot submit
    +        # Simulate that CapaModule.closed() always returns True
    +        with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
    +            mock_closed.return_value = True
    +            with self.assertRaises(xmodule.exceptions.NotFoundError):
    +                get_request_dict = { CapaFactory.input_key(): '3.14' }
    +                module.check_problem(get_request_dict)
    +
    +        # Expect that number of attempts NOT incremented
    +        self.assertEqual(module.attempts, 3)
    +
    +
    +    def test_check_problem_resubmitted_with_randomize(self):
    +        # Randomize turned on
    +        module = CapaFactory.create(rerandomize='always', attempts=0)
    +
    +        # Simulate that the problem is completed
    +        module.lcp.done = True
    +
    +        # Expect that we cannot submit
    +        with self.assertRaises(xmodule.exceptions.NotFoundError):
    +            get_request_dict = { CapaFactory.input_key(): '3.14' }
    +            module.check_problem(get_request_dict)
    +
    +        # Expect that number of attempts NOT incremented
    +        self.assertEqual(module.attempts, 0)
    +
    +
    +    def test_check_problem_resubmitted_no_randomize(self):
    +        # Randomize turned off
    +        module = CapaFactory.create(rerandomize='never', attempts=0)
    +
    +        # Simulate that the problem is completed
    +        module.lcp.done = True
    +
    +        # Expect that we can submit successfully
    +        get_request_dict = { CapaFactory.input_key(): '3.14' }
    +        result = module.check_problem(get_request_dict)
    +
    +        self.assertEqual(result['success'], 'correct')
    +
    +        # Expect that number of attempts IS incremented
    +        self.assertEqual(module.attempts, 1)
    +
    +
    +    def test_check_problem_queued(self):
    +        module = CapaFactory.create(attempts=1)
    +
    +        # Simulate that the problem is queued
    +        with patch('capa.capa_problem.LoncapaProblem.is_queued') \
    +                as mock_is_queued,\
    +            patch('capa.capa_problem.LoncapaProblem.get_recentmost_queuetime') \
    +                as mock_get_queuetime:
    +
    +            mock_is_queued.return_value = True
    +            mock_get_queuetime.return_value = datetime.datetime.now()
    +        
    +            get_request_dict = { CapaFactory.input_key(): '3.14' }
    +            result = module.check_problem(get_request_dict)
    +
    +            # Expect an AJAX alert message in 'success'
    +            self.assertTrue('You must wait' in result['success'])
    +
    +        # Expect that the number of attempts is NOT incremented
    +        self.assertEqual(module.attempts, 1)
    +
    +
    +    def test_check_problem_student_input_error(self):
    +        module = CapaFactory.create(attempts=1)
    +
    +        # Simulate a student input exception
    +        with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade:
    +            mock_grade.side_effect = capa.responsetypes.StudentInputError('test error')
    +
    +            get_request_dict = { CapaFactory.input_key(): '3.14' }
    +            result = module.check_problem(get_request_dict)
    +
    +            # Expect an AJAX alert message in 'success'
    +            self.assertTrue('test error' in result['success'])
    +
    +        # Expect that the number of attempts is NOT incremented
    +        self.assertEqual(module.attempts, 1)
    
    From 0c4a52567e036fd27d1142ae29fc98ab96d5e1a0 Mon Sep 17 00:00:00 2001
    From: Don Mitchell 
    Date: Mon, 4 Mar 2013 16:13:29 -0500
    Subject: [PATCH 151/214] Provide convenience converter of the grading policy
     but don't save it on the object.
    
    ---
     common/lib/xmodule/xmodule/course_module.py | 4 ++++
     1 file changed, 4 insertions(+)
    
    diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
    index 86ae673ae8..72196f92a2 100644
    --- a/common/lib/xmodule/xmodule/course_module.py
    +++ b/common/lib/xmodule/xmodule/course_module.py
    @@ -316,6 +316,10 @@ class CourseDescriptor(SequenceDescriptor):
             if isinstance(value, time.struct_time):
                 self.metadata['enrollment_end'] = stringify_time(value)
     
    +    @property
    +    def grader(self):
    +        return grader_from_conf(self.raw_grader)
    +
         @property
         def raw_grader(self):
             return self._grading_policy['RAW_GRADER']
    
    From 9743f6bea9ce4cdcf859d5f0c8a735e3a287080c Mon Sep 17 00:00:00 2001
    From: Will Daly 
    Date: Mon, 4 Mar 2013 16:16:38 -0500
    Subject: [PATCH 152/214] Fixed indentation in capa module. Added test of HTML
     output for successful check to capa module unit tests
    
    ---
     common/lib/xmodule/xmodule/capa_module.py            | 6 +++---
     common/lib/xmodule/xmodule/tests/test_capa_module.py | 8 +++++++-
     2 files changed, 10 insertions(+), 4 deletions(-)
    
    diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
    index d9ada0038e..cf1d786b72 100644
    --- a/common/lib/xmodule/xmodule/capa_module.py
    +++ b/common/lib/xmodule/xmodule/capa_module.py
    @@ -649,11 +649,11 @@ class CapaModule(XModule):
             #       'success' will always be incorrect
             event_info['correct_map'] = correct_map.get_dict()
             event_info['success'] = success
    -	event_info['attempts'] = self.attempts
    +        event_info['attempts'] = self.attempts
             self.system.track_function('save_problem_check', event_info)
     
    -	if hasattr(self.system, 'psychometrics_handler'):  	# update PsychometricsData using callback
    -		self.system.psychometrics_handler(self.get_instance_state())
    +        if hasattr(self.system, 'psychometrics_handler'):  	# update PsychometricsData using callback
    +            self.system.psychometrics_handler(self.get_instance_state())
     
             # render problem into HTML
             html = self.get_problem_html(encapsulate=False)
    diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
    index c569309af9..453365f73b 100644
    --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
    +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
    @@ -341,8 +341,11 @@ class CapaModuleTest(unittest.TestCase):
     
             # Simulate that all answers are marked correct, no matter
             # what the input is, by patching CorrectMap.is_correct()
    -        with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct:
    +        # Also simulate rendering the HTML
    +        with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct,\
    +                patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
                 mock_is_correct.return_value = True
    +            mock_html.return_value = "Test HTML"
     
                 # Check the problem
                 get_request_dict = { CapaFactory.input_key(): '3.14' }
    @@ -351,6 +354,9 @@ class CapaModuleTest(unittest.TestCase):
             # Expect that the problem is marked correct
             self.assertEqual(result['success'], 'correct')
     
    +        # Expect that we get the (mocked) HTML
    +        self.assertEqual(result['contents'], 'Test HTML')
    +
             # Expect that the number of attempts is incremented by 1
             self.assertEqual(module.attempts, 2)
     
    
    From b0106a41c796310294cfac103a590b6399dd71d9 Mon Sep 17 00:00:00 2001
    From: cahrens 
    Date: Mon, 4 Mar 2013 16:42:04 -0500
    Subject: [PATCH 153/214] make branch
    
    ---
     .../contentstore/features/section.feature     |  8 +++
     .../contentstore/features/section.py          | 45 ++++++++++++++---
     .../contentstore/features/subsection.feature  |  8 +++
     .../contentstore/features/subsection.py       | 49 ++++++++++++++++---
     4 files changed, 96 insertions(+), 14 deletions(-)
    
    diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature
    index ad00ba2911..75e7a4af10 100644
    --- a/cms/djangoapps/contentstore/features/section.feature
    +++ b/cms/djangoapps/contentstore/features/section.feature
    @@ -11,6 +11,14 @@ Feature: Create Section
         And I see a release date for my section
         And I see a link to create a new subsection
     
    +  Scenario: Add a new section (with a quote in the name) to a course (bug #216)
    +    Given I have opened a new course in Studio
    +    When I click the New Section link
    +    And I enter a section name with a quote and click save
    +    Then I see my section name with a quote on the Courseware page
    +    And I click to edit the section name
    +    Then I see the complete section name with a quote in the editor
    +
       Scenario: Edit section release date
         Given I have opened a new course in Studio
         And I have added a new section
    diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py
    index ca67c477fb..cfa4e4bb52 100644
    --- a/cms/djangoapps/contentstore/features/section.py
    +++ b/cms/djangoapps/contentstore/features/section.py
    @@ -1,5 +1,6 @@
     from lettuce import world, step
     from common import *
    +from nose.tools import assert_equal
     
     ############### ACTIONS ####################
     
    @@ -12,10 +13,12 @@ def i_click_new_section_link(step):
     
     @step('I enter the section name and click save$')
     def i_save_section_name(step):
    -    name_css = '.new-section-name'
    -    save_css = '.new-section-name-save'
    -    css_fill(name_css, 'My Section')
    -    css_click(save_css)
    +    save_section_name('My Section')
    +
    +
    +@step('I enter a section name with a quote and click save$')
    +def i_save_section_name_with_quote(step):
    +    save_section_name('Section with "Quote"')
     
     
     @step('I have added a new section$')
    @@ -45,8 +48,24 @@ def i_save_a_new_section_release_date(step):
     
     @step('I see my section on the Courseware page$')
     def i_see_my_section_on_the_courseware_page(step):
    -    section_css = 'span.section-name-span'
    -    assert_css_with_text(section_css, 'My Section')
    +    see_my_section_on_the_courseware_page('My Section')
    +
    +
    +@step('I see my section name with a quote on the Courseware page$')
    +def i_see_my_section_name_with_quote_on_the_courseware_page(step):
    +    see_my_section_on_the_courseware_page('Section with "Quote"')
    +
    +
    +@step('I click to edit the section name$')
    +def i_click_to_edit_section_name(step):
    +    css_click('span.section-name-span')
    +
    +
    +@step('I see the complete section name with a quote in the editor$')
    +def i_see_complete_section_name_with_quote_in_editor(step):
    +    css = '.edit-section-name'
    +    assert world.browser.is_element_present_by_css(css, 5)
    +    assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
     
     
     @step('the section does not exist$')
    @@ -88,3 +107,17 @@ def the_section_release_date_is_updated(step):
         css = 'span.published-status'
         status_text = world.browser.find_by_css(css).text
         assert status_text == 'Will Release: 12/25/2013 at 12:00am'
    +
    +
    +############ HELPER METHODS ###################
    +
    +def save_section_name(name):
    +    name_css = '.new-section-name'
    +    save_css = '.new-section-name-save'
    +    css_fill(name_css, name)
    +    css_click(save_css)
    +
    +
    +def see_my_section_on_the_courseware_page(name):
    +    section_css = 'span.section-name-span'
    +    assert_css_with_text(section_css, name)
    \ No newline at end of file
    diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature
    index 5acb5bfe44..4b5f5b869d 100644
    --- a/cms/djangoapps/contentstore/features/subsection.feature
    +++ b/cms/djangoapps/contentstore/features/subsection.feature
    @@ -9,6 +9,14 @@ Feature: Create Subsection
         And I enter the subsection name and click save
         Then I see my subsection on the Courseware page
     
    +    Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
    +    Given I have opened a new course section in Studio
    +    When I click the New Subsection link
    +    And I enter a subsection name with a quote and click save
    +    Then I see my subsection name with a quote on the Courseware page
    +    And I click to edit the subsection name
    +    Then I see the complete subsection name with a quote in the editor
    +
         Scenario: Delete a subsection
         Given I have opened a new course section in Studio
         And I have added a new subsection
    diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py
    index e2041b8dbf..88e1424898 100644
    --- a/cms/djangoapps/contentstore/features/subsection.py
    +++ b/cms/djangoapps/contentstore/features/subsection.py
    @@ -1,5 +1,6 @@
     from lettuce import world, step
     from common import *
    +from nose.tools import assert_equal
     
     ############### ACTIONS ####################
     
    @@ -20,28 +21,60 @@ def i_click_the_new_subsection_link(step):
     
     @step('I enter the subsection name and click save$')
     def i_save_subsection_name(step):
    -    name_css = 'input.new-subsection-name-input'
    -    save_css = 'input.new-subsection-name-save'
    -    css_fill(name_css, 'Subsection One')
    -    css_click(save_css)
    +    save_subsection_name('Subsection One')
    +
    +
    +@step('I enter a subsection name with a quote and click save$')
    +def i_save_subsection_name_with_quote(step):
    +    save_subsection_name('Subsection With "Quote"')
    +
    +
    +@step('I click to edit the subsection name$')
    +def i_click_to_edit_subsection_name(step):
    +    css_click('span.subsection-name-value')
    +
    +
    +@step('I see the complete subsection name with a quote in the editor$')
    +def i_see_complete_subsection_name_with_quote_in_editor(step):
    +    css = '.subsection-display-name-input'
    +    assert world.browser.is_element_present_by_css(css, 5)
    +    assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"')
     
     
     @step('I have added a new subsection$')
     def i_have_added_a_new_subsection(step):
         add_subsection()
     
    +
     ############ ASSERTIONS ###################
     
     
     @step('I see my subsection on the Courseware page$')
     def i_see_my_subsection_on_the_courseware_page(step):
    -    css = 'span.subsection-name'
    -    assert world.browser.is_element_present_by_css(css)
    -    css = 'span.subsection-name-value'
    -    assert_css_with_text(css, 'Subsection One')
    +    see_subsection_name('Subsection One')
    +
    +
    +@step('I see my subsection name with a quote on the Courseware page$')
    +def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
    +    see_subsection_name('Subsection With "Quote"')
     
     
     @step('the subsection does not exist$')
     def the_subsection_does_not_exist(step):
         css = 'span.subsection-name'
         assert world.browser.is_element_not_present_by_css(css)
    +
    +
    +############ HELPER METHODS ###################
    +
    +def save_subsection_name(name):
    +    name_css = 'input.new-subsection-name-input'
    +    save_css = 'input.new-subsection-name-save'
    +    css_fill(name_css, name)
    +    css_click(save_css)
    +
    +def see_subsection_name(name):
    +    css = 'span.subsection-name'
    +    assert world.browser.is_element_present_by_css(css)
    +    css = 'span.subsection-name-value'
    +    assert_css_with_text(css, name)
    
    From cc3b9557f97fc39bf44dd01371f168e792dc1216 Mon Sep 17 00:00:00 2001
    From: Brian Talbot 
    Date: Mon, 4 Mar 2013 17:02:59 -0500
    Subject: [PATCH 154/214] studio - open ended and annotations: added in
     component button icon assets and styling
    
    ---
     cms/static/img/large-annotations-icon.png | Bin 0 -> 275 bytes
     cms/static/img/large-openended-icon.png   | Bin 0 -> 379 bytes
     cms/static/sass/_graphics.scss            |  10 +++++++++-
     3 files changed, 9 insertions(+), 1 deletion(-)
     create mode 100644 cms/static/img/large-annotations-icon.png
     create mode 100644 cms/static/img/large-openended-icon.png
    
    diff --git a/cms/static/img/large-annotations-icon.png b/cms/static/img/large-annotations-icon.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..249193521fe862d0514b212b00634639363b9db2
    GIT binary patch
    literal 275
    zcmeAS@N?(olHy`uVBq!ia0vp^av;pX3?zBp#Z3TGx&b~RuK$SxK;=M%0p(F!fGoa}
    zAirP*gZTmX6AIP~yx;Fo&v19k4m+SsyQhm|NX4z*OOAX;6gV6L-Bvq@zN^3EmNeNc
    zbjJ2C`67&xD;_N5XBXR)uyb==!Q7bv(f_*sSwvhCvbw&`AuQu6{1-oD!M<{-}kn
    
    literal 0
    HcmV?d00001
    
    diff --git a/cms/static/img/large-openended-icon.png b/cms/static/img/large-openended-icon.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..4d31815413fe0a7809f8d6eb5be505a4b53f858a
    GIT binary patch
    literal 379
    zcmeAS@N?(olHy`uVBq!ia0vp^av;pX3?zBp#Z3TG#sNMduK!5^Kutj9qFbB30a*ei
    zL4LvO@AscKm>*D}kRb5hVShct#I0+e0_C22x;TbZ#4Wvav+uA0N83f6zTV!(|9|I~
    zNx6tDo4HE-bF8YB(WL1|gBEi#UpleQ$lPs7{Rc)5d9zncZywJzUvT_+$_Z)z>njYt
    zm~->Jek5wl+JA;cB&p&vYtOu$Nn6`y^XHsL5os~Z}UJ*Uu6(*r;Y{EBH?dQU#-$!?!=z19*)E?QZYFqJ3aazejnYyAQ
    zZ>#vEdu{JnPRa^@c(py=oJ%ZpYSAyw2yvU)*B>+1KlmVb`28jOz&F;SGPZ5@JI`Ke
    i{%`n4`nB<{pX`
    Date: Mon, 4 Mar 2013 17:21:32 -0500
    Subject: [PATCH 155/214] annotation now hides automatically on mouseleave
     events. click to hide/show or re-position the annotation.
    
    ---
     .../lib/xmodule/xmodule/js/src/annotatable/display.coffee   | 6 ++++--
     1 file changed, 4 insertions(+), 2 deletions(-)
    
    diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
    index c97250c8f8..6a5aae81c5 100644
    --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
    +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
    @@ -65,10 +65,12 @@ class @Annotatable
                     mouse: false # dont follow the mouse
                     y: -10
             show:
    -            event: 'mouseenter'
    +            event: 'click mouseenter'
                 solo: true
             hide:
    -            event: 'unfocus'
    +            event: 'click mouseleave'
    +            delay: 250,
    +            fixed: true
             style:
                 classes: 'ui-tooltip-annotatable'
             events:
    
    From 94db91fceff1edcd65b39f1f9bbfb1cc1bd25982 Mon Sep 17 00:00:00 2001
    From: John Hess 
    Date: Mon, 4 Mar 2013 18:19:24 -0500
    Subject: [PATCH 156/214] Cast cursor responses as lists.  MySQL returns them
     as tuples.
    
    Removed print statemetns
    Moved import call to top of file
    ---
     lms/djangoapps/dashboard/views.py | 12 +++++++-----
     1 file changed, 7 insertions(+), 5 deletions(-)
    
    diff --git a/lms/djangoapps/dashboard/views.py b/lms/djangoapps/dashboard/views.py
    index e74d462432..266e769db5 100644
    --- a/lms/djangoapps/dashboard/views.py
    +++ b/lms/djangoapps/dashboard/views.py
    @@ -3,6 +3,7 @@ import json
     from datetime import datetime
     from django.http import Http404
     from mitxmako.shortcuts import render_to_response
    +from django.db import connection
     
     from student.models import CourseEnrollment, CourseEnrollmentAllowed
     from django.contrib.auth.models import User
    @@ -12,16 +13,18 @@ def dictfetchall(cursor):
         '''Returns a list of all rows from a cursor as a column: result dict.
         Borrowed from Django documentation'''
         desc = cursor.description
    -    table=[]
    +    table = []
         table.append([col[0] for col in desc])
    -    table = table + cursor.fetchall()
    -    print "Table: " + str(table)
    +    
    +    # ensure response from db is a list, not a tuple (which is returned
    +    # by MySQL backed django instances)
    +    rows_from_cursor=cursor.fetchall()
    +    table = table + [list(row) for row in rows_from_cursor]
         return table
     
     def SQL_query_to_list(cursor, query_string):
         cursor.execute(query_string)
         raw_result=dictfetchall(cursor)
    -    print raw_result
         return raw_result
     
     def dashboard(request):
    @@ -50,7 +53,6 @@ def dashboard(request):
         results["scalars"]["Total Enrollments Across All Courses"]=CourseEnrollment.objects.count()
     
         # establish a direct connection to the database (for executing raw SQL)
    -    from django.db import connection
         cursor = connection.cursor()
     
         # define the queries that will generate our user-facing tables
    
    From 6ce3493f00a5c7395923273c50d0094cc2732d4a Mon Sep 17 00:00:00 2001
    From: Chris Dodge 
    Date: Mon, 4 Mar 2013 19:59:18 -0500
    Subject: [PATCH 157/214] add comment
    
    ---
     common/lib/xmodule/xmodule/modulestore/mongo.py | 1 +
     1 file changed, 1 insertion(+)
    
    diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
    index b46c29b2bc..e2a4524188 100644
    --- a/common/lib/xmodule/xmodule/modulestore/mongo.py
    +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
    @@ -66,6 +66,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
             if json_data is None:
                 module = self.modulestore.get_item(location)
                 if module is not None:
    +                # update our own cache after going to the DB to get cache miss
                     self.module_data.update(module.system.module_data)
                 return module
             else:
    
    From 3bcd1467d96c9dc0051fc039695097adf26bd701 Mon Sep 17 00:00:00 2001
    From: Valera Rozuvan 
    Date: Tue, 5 Mar 2013 13:36:24 +0200
    Subject: [PATCH 158/214] Added documentation for drag and drop - targets on
     draggables feature.
    
    ---
     .../drag_and_drop/drag-n-drop-demo3.xml       | 262 ++++++++++++++++++
     .../drag_and_drop/drag_and_drop_input.rst     |  99 ++++++-
     2 files changed, 359 insertions(+), 2 deletions(-)
     create mode 100644 doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml
    
    diff --git a/doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml b/doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml
    new file mode 100644
    index 0000000000..860f488089
    --- /dev/null
    +++ b/doc/public/course_data_formats/drag_and_drop/drag-n-drop-demo3.xml
    @@ -0,0 +1,262 @@
    +
    +
    +
    +    
    +        

    [Simple grading example: draggables on draggables]


    +

    Describe carbon molecule in LCAO-MO.


    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    [Complex grading example: draggables on draggables]


    +

    Describe carbon molecule in LCAO-MO.

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + +

    [Complex grading example: no draggables on draggables]


    +

    Describe carbon molecule in LCAO-MO.

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    diff --git a/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst b/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst index 4d61038054..ba46398257 100644 --- a/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst +++ b/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst @@ -83,9 +83,51 @@ the slider. If no targets are provided, then a draggable can be dragged and placed anywhere on the base image. -correct answer format +Targets on draggables --------------------- +Sometimes it is not enough to have targets only on the base image, and all of the +draggables on these targets. If a complex problem exists where a draggable must +become itself a target (or many targets), then the following extended syntax +can be used: :: + + + + + + ... + + +The attribute list in the tags above ('draggable' and 'target') is the same as for +normal 'draggable' and 'target' tags. The only difference is when you will be +specifying inner target position coordinates. Using the 'x' and 'y' attributes you +are setting the offset of the inner target from the upper-left corner of the +parent draggable (that contains the inner target). + +Limitations of targets on draggables +------------------------------------ + +1.) Currently there is a limitation to the level of nesting of targets. + +Even though you can pile up a large number of draggables on targets that themselves +are on draggables, the Drag and Drop instance will be graded only in the case if +there is a maximum of two levels of targets. The first level are the "base" targets. +They are attached to the base image. The second level are the targets defined on +draggables. + +2.) Another limitation is that the target bounds are not checked against +other targets. + +For now, it is the responsibility of the person who is constructing the course +material to make sure that there is no overlapping of targets. It is also preferable +that targets on draggables are smaller than the actual parent draggable. Technically +this is not necessary, but from the usability perspective it is desirable. + +Correct answer format +--------------------- + +(NOTE: For specifying answers for targets on draggables please see next section.) + There are two correct answer formats: short and long If short from correct answer is mapping of 'draggable_id' to 'target_id':: @@ -180,7 +222,7 @@ Rules are: exact, anyof, unordered_equal, anyof+number, unordered_equal+number 'rule': 'unordered_equal' }] -- And sometimes you want to allow drag only two 'b' draggables, in these case you sould use 'anyof+number' of 'unordered_equal+number' rule:: +- And sometimes you want to allow drag only two 'b' draggables, in these case you should use 'anyof+number' of 'unordered_equal+number' rule:: correct_answer = [ { @@ -204,6 +246,54 @@ for same number of draggables, anyof is equal to unordered_equal If we have can_reuse=true, than one must use only long form of correct answer. +Answer format for targets on draggables +--------------------------------------- + +As with the cases described above, an answer must provide precise positioning for +each draggable (on which targets it must reside). In the case when a draggable must +be placed on a target that itself is on a draggable, then the answer must contain +the chain of target-draggable-target. It is best to understand this on an example. + +Suppose we have three draggables - 'up', 's', and 'p'. Draggables 's', and 'p' have targets +on themselves. More specifically, 'p' has three targets - '1', '2', and '3'. The first +requirement is that 's', and 'p' are positioned on specific targets on the base image. +The second requirement is that draggable 'up' is positioned on specific targets of +draggable 'p'. Below is an excerpt from a problem.:: + + + + + + + + + + + + + + ... + + correct_answer = [ + { + 'draggables': ['p'], + 'targets': ['p-left-target', 'p-right-target'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['s'], + 'targets': ['s-left-target', 's-right-target'], + 'rule': 'unordered_equal' + }, + { + 'draggables': ['up'], + 'targets': ['p-left-target[p][1]', 'p-left-target[p][2]', 'p-right-target[p][2]', 'p-right-target[p][3]',], + 'rule': 'unordered_equal' + } + ] + +Note that it is a requirement to specify rules for all draggables, even if some draggable gets included +in more than one chain. Grading logic ------------- @@ -321,3 +411,8 @@ Draggables can be reused ------------------------ .. literalinclude:: drag-n-drop-demo2.xml + +Examples of targets on draggables +------------------------ + +.. literalinclude:: drag-n-drop-demo3.xml From ff26913795fb0cc67a7cc3e7c71d565e19b68758 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Tue, 5 Mar 2013 14:40:02 +0200 Subject: [PATCH 159/214] Added a limitation to the DnD docs. --- .../drag_and_drop/drag_and_drop_input.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst b/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst index ba46398257..4927deeec6 100644 --- a/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst +++ b/doc/public/course_data_formats/drag_and_drop/drag_and_drop_input.rst @@ -123,6 +123,13 @@ material to make sure that there is no overlapping of targets. It is also prefer that targets on draggables are smaller than the actual parent draggable. Technically this is not necessary, but from the usability perspective it is desirable. +3.) You can have targets on draggables only in the case when there are base targets +defined (base targets are attached to the base image). + +If you do not have base targets, then you can only have a single level of nesting +(draggables on the base image). In this case the client side will be reporting (x,y) +positions of each draggables on the base image. + Correct answer format --------------------- From faf5c3f0a2e3927637285abb4e5bc8c9a16365d8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 09:10:02 -0500 Subject: [PATCH 160/214] Updated Deena's tests after merge with master to ensure that none of the tests fail. Deleted some tests. --- .../contentstore/tests/factories.py | 150 ------ .../contentstore/tests/test_views.py | 447 ------------------ .../courseware/tests/test_course_creation.py | 61 --- .../courseware/tests/test_module_render.py | 26 +- .../courseware/tests/test_progress.py | 8 - lms/djangoapps/courseware/tests/test_views.py | 73 --- 6 files changed, 1 insertion(+), 764 deletions(-) delete mode 100644 cms/djangoapps/contentstore/tests/factories.py delete mode 100644 cms/djangoapps/contentstore/tests/test_views.py delete mode 100644 lms/djangoapps/courseware/tests/test_course_creation.py diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py deleted file mode 100644 index cb01cb447e..0000000000 --- a/cms/djangoapps/contentstore/tests/factories.py +++ /dev/null @@ -1,150 +0,0 @@ -from factory import Factory -from datetime import datetime -from uuid import uuid4 -from student.models import (User, UserProfile, Registration, - CourseEnrollmentAllowed) -from django.contrib.auth.models import Group - -from django.contrib.auth.models import Group - -from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from xmodule.timeparse import stringify_time -from student.models import (User, UserProfile, Registration, - CourseEnrollmentAllowed) -from django.contrib.auth.models import Group - -class UserProfileFactory(Factory): - FACTORY_FOR = UserProfile - - user = None - name = 'Robot Studio' - courseware = 'course.xml' - - -class RegistrationFactory(Factory): - FACTORY_FOR = Registration - - user = None - activation_key = uuid4().hex - - -class UserFactory(Factory): - FACTORY_FOR = User - - username = 'robot' - email = 'robot@edx.org' - password = 'test' - first_name = 'Robot' - last_name = 'Tester' - is_staff = False - is_active = True - is_superuser = False - last_login = datetime.now() - date_joined = datetime.now() - - -class GroupFactory(Factory): - FACTORY_FOR = Group - - name = 'test_group' - - -class CourseEnrollmentAllowedFactory(Factory): - FACTORY_FOR = CourseEnrollmentAllowed - -class CourseFactory(XModuleCourseFactory): - FACTORY_FOR = Course - - template = 'i4x://edx/templates/course/Empty' - org = 'MITx' - number = '999' - display_name = 'Robot Super Course' - -class XModuleItemFactory(Factory): - """ - Factory for XModule items. - """ - - ABSTRACT_FACTORY = True - _creation_function = (XMODULE_ITEM_CREATION,) - - @classmethod - def _create(cls, target_class, *args, **kwargs): - """ - kwargs must include parent_location, template. Can contain display_name - target_class is ignored - """ - - DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] - - parent_location = Location(kwargs.get('parent_location')) - template = Location(kwargs.get('template')) - display_name = kwargs.get('display_name') - - store = modulestore('direct') - - # This code was based off that in cms/djangoapps/contentstore/views.py - parent = store.get_item(parent_location) - dest_location = parent_location._replace(category=template.category, name=uuid4().hex) - - new_item = store.clone_item(template, dest_location) - - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - - # replace the display name with an optional parameter passed in from the caller - if display_name is not None: - new_item.metadata['display_name'] = display_name - - store.update_metadata(new_item.location.url(), new_item.own_metadata) - - if new_item.location.category not in DETACHED_CATEGORIES: - store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) - - return new_item - -class Item: - pass - -class ItemFactory(XModuleItemFactory): - FACTORY_FOR = Item - - parent_location = 'i4x://MITx/999/course/Robot_Super_Course' - template = 'i4x://edx/templates/chapter/Empty' - display_name = 'Section One' - -class UserProfileFactory(Factory): - FACTORY_FOR = UserProfile - - user = None - name = 'Robot Studio' - courseware = 'course.xml' - -class RegistrationFactory(Factory): - FACTORY_FOR = Registration - - user = None - activation_key = uuid.uuid4().hex - -class UserFactory(Factory): - FACTORY_FOR = User - - username = 'robot' - email = 'robot@edx.org' - password = 'test' - first_name = 'Robot' - last_name = 'Tester' - is_staff = False - is_active = True - is_superuser = False - last_login = datetime.now() - date_joined = datetime.now() - -class GroupFactory(Factory): - FACTORY_FOR = Group - - name = 'test_group' - -class CourseEnrollmentAllowedFactory(Factory): - FACTORY_FOR = CourseEnrollmentAllowed diff --git a/cms/djangoapps/contentstore/tests/test_views.py b/cms/djangoapps/contentstore/tests/test_views.py deleted file mode 100644 index 85f960e7d5..0000000000 --- a/cms/djangoapps/contentstore/tests/test_views.py +++ /dev/null @@ -1,447 +0,0 @@ -import logging -from mock import MagicMock, patch -import json -import factory -import unittest -from nose.tools import set_trace -from nose.plugins.skip import SkipTest -from collections import defaultdict -import re - -from django.http import (Http404, HttpResponse, HttpRequest, - HttpResponseRedirect, HttpResponseBadRequest, - HttpResponseForbidden) -from django.conf import settings -from django.contrib.auth.models import User -from django.test.client import Client, RequestFactory -from django.test import TestCase -from django.core.exceptions import PermissionDenied -from override_settings import override_settings -from django.core.exceptions import PermissionDenied - -from xmodule.modulestore.django import modulestore, _MODULESTORES -from xmodule.modulestore import Location -from xmodule.x_module import ModuleSystem -from xmodule.error_module import ErrorModule -from xmodule.seq_module import SequenceModule -from xmodule.templates import update_templates -from contentstore.utils import get_course_for_item -from contentstore.tests.factories import UserFactory -from contentstore.tests.factories import CourseFactory, ItemFactory -import contentstore.views as views -from contentstore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore import Location -from xmodule.x_module import ModuleSystem -from xmodule.error_module import ErrorModule -from contentstore.utils import get_course_for_item -from xmodule.templates import update_templates - -class Stub(): - pass - -def xml_store_config(data_dir): - return { - 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - } - } -} - -TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) - -class ViewsTestCase(TestCase): - def setUp(self): - # empty Modulestore - self._MODULESTORES = {} - modulestore().collection.drop() - update_templates() - self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] - self.location_2 = ['i4x', 'edX', 'full', 'course', '6.002_Spring_2012'] - self.location_3 = ['i4x', 'MITx', '999', 'course', 'Robot_Super_Course'] - self.course_id = 'edX/toy/2012_Fall' - self.course_id_2 = 'edx/full/6.002_Spring_2012' - # is a CourseDescriptor object? - self.course = CourseFactory.create() - # is a sequence descriptor - self.item = ItemFactory.create(template = 'i4x://edx/templates/sequential/Empty') - self.no_permit_user = UserFactory() - self.permit_user = UserFactory(is_staff = True, username = 'Wizardly Herbert') - - def tearDown(self): - _MODULESTORES = {} - modulestore().collection.drop() - - def test_has_access(self): - self.assertTrue(views.has_access(self.permit_user, self.location_2)) - self.assertFalse(views.has_access(self.no_permit_user, self.location_2)) - # done - - def test_course_index(self): - request = RequestFactory().get('foo') - request.user = self.no_permit_user - # Redirects if request.user doesn't have access to location - self.assertRaises(PermissionDenied, views.course_index, request, 'edX', - 'full', '6.002_Spring_2012') - request_2 = RequestFactory().get('foo') - request.user = self.permit_user - - def test_has_access(self): - user = MagicMock(is_staff = True, is_active = True, is_authenticated = True) - m = MagicMock() - m.count.return_value = 1 - user.groups.filter.return_value = m - self.assertTrue(views.has_access(user, self.location_2)) - user.is_authenticated = False - self.assertFalse(views.has_access(user, self.location_2)) - - def test_course_index(self): - # UserFactory doesn't work? - self.user = MagicMock(is_staff = False, is_active = False) - self.user.is_authenticated.return_value = False - request = MagicMock(user = self.user) - # Redirects if request.user doesn't have access to location - self.assertIsInstance(views.course_index(request, 'edX', - 'full', '6.002_Spring_2012'), HttpResponseRedirect) - self.user_2 = MagicMock(is_staff = True, is_active = True) - self.user_2.is_authenticated.return_value = True - request_2 = MagicMock(user = self.user_2) - # Doesn't work unless we figure out render_to_response -## views.course_index(request_2, 'MITx', -## '999', 'Robot_Super_Course') - - def test_edit_subsection(self): - # Redirects if request.user doesn't have access to location - self.request = RequestFactory().get('foo') - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.edit_subsection, self.request, - self.location_2) - # If location isn't for a "sequential", return Bad Request - self.request_2 = RequestFactory().get('foo') - self.request_2.user = self.permit_user - self.user = MagicMock(is_staff = False, is_active = False) - self.user.is_authenticated.return_value = False - self.request = MagicMock(user = self.user) - self.assertIsInstance(views.edit_subsection(self.request, self.location_2), - HttpResponseRedirect) - # If location isn't for a "sequential", return Bad Request - self.user_2 = MagicMock(is_staff = True, is_active = True) - self.user_2.is_authenticated.return_value = True - self.request_2 = MagicMock(user = self.user_2) - self.assertIsInstance(views.edit_subsection(self.request_2, - self.location_3), HttpResponseBadRequest) - # Need render_to_response - #views.edit_subsection(self.request_2, self.item.location) - - def test_edit_unit(self): - raise SkipTest - # if user doesn't have access, should redirect - self.request = RequestFactory().get('foo') - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.edit_unit, self.request, - self.location_2) - self.request_2 = RequestFactory().get('foo') - self.request_2.user = self.permit_user - # Problem: no parent locations, so IndexError - #print modulestore().get_parent_locations(self.location_3, None) - views.edit_unit(self.request_2, self.location_3) - # Needs render_to_response - - def test_assignment_type_update(self): - raise SkipTest - # If user doesn't have access, should return HttpResponseForbidden() - self.request = RequestFactory().get('foo') - self.request.user = self.no_permit_user - self.assertIsInstance(views.assignment_type_update(self.request, - 'MITx', '999', 'course', 'Robot_Super_Course'), - HttpResponseForbidden) -## views.assignment_type_update(self.request, 'MITx', '999', 'course', 'Robot_Super_Course') - # if user has access, then should return HttpResponse - self.request.user = self.permit_user - # if user doesn't have access, should redirect - self.user = MagicMock(is_staff = False, is_active = False) - self.user.is_authenticated.return_value = False - self.request = MagicMock(user = self.user) - self.assertIsInstance(views.edit_unit(self.request, self.location_2), - HttpResponseRedirect) - - def test_assignment_type_update(self): - # If user doesn't have access, should redirect - self.user = MagicMock(is_staff = False, is_active = False) - self.user.is_authenticated.return_value = False - self.request = RequestFactory().get('foo') - self.request.user = self.user - self.assertIsInstance(views.assignment_type_update(self.request, - 'MITx', '999', 'course', 'Robot_Super_Course'), - HttpResponseRedirect) - # if user has access, then should return HttpResponse - self.user_2 = MagicMock(is_staff = True, is_active = True) - self.user_2.is_authenticated.return_value = True - self.request.user = self.user_2 - get_response = views.assignment_type_update(self.request,'MITx', '999', - 'course', 'Robot_Super_Course') - self.assertIsInstance(get_response,HttpResponse) - get_response_string = '{"id": 99, "location": ["i4x", "MITx", "999", "course", "Robot_Super_Course", null], "graderType": "Not Graded"}' - self.assertEquals(get_response.content, get_response_string) - self.request_2 = RequestFactory().post('foo') - self.request_2.user = self.permit_user - post_response = views.assignment_type_update(self.request_2,'MITx', '999', - 'course', 'Robot_Super_Course') - self.assertIsInstance(post_response,HttpResponse) - self.assertEquals(post_response.content, 'null') - - def test_load_preview_state(self): - # Tests that function creates empty defaultdict when request.session - # is empty - # location cannot be a list or other mutable type - self.request = RequestFactory().get('foo') - self.request.session = {} - instance_state, shared_state = views.load_preview_state(self.request, - 'foo', 'bar') - self.assertIsNone(instance_state) - self.assertIsNone(shared_state) - # Done - - def test_save_preview_state(self): - self.request = RequestFactory().get('foo') - self.request.session = {} - loc = Location(self.location_3) - result = {'preview_states': - {('id', loc):{'instance':None, - 'shared':None, - } - } - } - views.save_preview_state(self.request, 'id', loc, None, None) - self.assertEquals(self.request.session, result) - # Done - - def test_get_preview_module(self): - self.request = RequestFactory().get('foo') - self.request.user = self.permit_user - self.request.session = {} - module = views.get_preview_module(self.request, 'id', self.course) - self.assertIsInstance(module, SequenceModule) - # Done - - def test_preview_module_system(self): - # Returns a ModuleSystem - self.request = RequestFactory().get('foo') - self.request.user = self.no_permit_user - self.assertIsInstance(views.preview_module_system(self.request, - 'id', self.course), - ModuleSystem) - # done - - def test_load_preview_module(self): - # if error in getting module, return ErrorModule - self.request = RequestFactory().get('foo') - self.request.user = self.no_permit_user - self.assertIsInstance(views.preview_module_system(self.request, - 'id', self.course), - ModuleSystem) - self.request.session = {} - self.assertIsInstance(views.load_preview_module(self.request, 'id', - self.course, 'instance', 'shared'), - ErrorModule) - instance_state, shared_state = self.course.get_sample_state()[0] - module = views.load_preview_module(self.request,'id', self.course, - instance_state, shared_state) - self.assertIsInstance(module, SequenceModule) - # I'd like to test module.get_html, but it relies on render_to_string - # Test static_tab - self.course_2 = CourseFactory(display_name = 'Intro_to_intros', location = Location('i4x', 'MITx', '666', 'static_tab', 'Intro_to_intros')) - module_2 = views.load_preview_module(self.request,'id', self.course_2, - instance_state, shared_state) - self.assertIsInstance(module, SequenceModule) - # needs render_to_string - - def test__xmodule_recurse(self): - #There shouldn't be a difference, but the code works with defined - # function f but not with lambda functions - mock_item = MagicMock() - mock_item.get_children.return_value = [] - s = Stub() - s.children.append(Stub()) - views._xmodule_recurse(s, f) - self.assertEquals(s.n, 1) - self.assertEquals(s.children[0].n, 1) - - def test_get_module_previews(self): - raise SkipTest - # needs a working render_to_string - self.request = RequestFactory().get('foo') - self.request.user = UserFactory() - self.request.session = {} - print views.get_module_previews(self.request, self.course) - - def test_delete_item(self): - raise SkipTest - # If user doesn't have permission, redirect - self.request = RequestFactory().post('i4x://MITx/999/course/Robot_Super_Course') - self.request.POST = self.request.POST.copy() - self.request.POST.update({'id':'i4x://MITx/999/course/Robot_Super_Course'}) - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.delete_item, self.request) - # Should return an HttpResponse - self.request_2 = RequestFactory().post(self.item.location.url()) - self.request_2.POST = self.request_2.POST.copy() - self.request_2.POST.update({'id':self.item.location.url()}) - self.request_2.user = self.permit_user - response = views.delete_item(self.request_2) - self.assertIsInstance(response, HttpResponse) - self.assertEquals(modulestore().get_items(self.item.location.url()), []) - # Set delete_children to True to delete all children - # Create children - self.item_2 = ItemFactory.create() - child_item = ItemFactory.create() -## print type(self.item_2) -## print self.item_2.__dict__ - # Is there better way of adding children? What format are children in? - self.item_2.definition['children'] = [child_item.location.url()] - self.request_3 = RequestFactory().post(self.item_2.location.url()) - self.request_3.POST = self.request_3.POST.copy() - self.request_3.POST.update({'id':self.item_2.location.url(), - 'delete_children':True, - 'delete_all_versions':True}) - self.request_3.user = self.permit_user - print self.item_2.get_children() - self.assertIsInstance(views.delete_item(self.request_3), HttpResponse) - self.assertEquals(modulestore().get_items(self.item_2.location.url()), []) - # Problem: Function doesn't delete child item? - # child_item can be manually deleted, but can't delete it using function - # Not sure if problem with _xmodule_recurse and lambda functions - #store = views.get_modulestore(child_item.location.url()) - #store.delete_item(child_item.location) - self.assertEquals(modulestore().get_items(child_item.location.url()), []) - # Check delete_item on 'vertical' - self.item_3 = ItemFactory.create(template = 'i4x://edx/templates/vertical/Empty') - self.request_4 = RequestFactory().post(self.item_3.location.url()) - self.request_4.POST = self.request_4.POST.copy() - self.request_4.POST.update({'id':self.item_3.location.url(), - 'delete_children':True, - 'delete_all_versions':True}) - self.request_4.user = self.permit_user - self.assertIsInstance(views.delete_item(self.request_4), HttpResponse) - self.assertEquals(modulestore().get_items(self.item_3.location.url()), []) - - def test_save_item(self): - # Test that user with no permissions gets redirected - self.request = RequestFactory().post(self.item.location.url()) - self.request.POST = self.request.POST.copy() - self.request.POST.update({'id':self.item.location.url()}) - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.save_item, self.request) - # Test user with permissions but nothing in request.POST - self.item_2 = ItemFactory.create() - self.request_2 = RequestFactory().post(self.item_2.location.url()) - self.request_2.POST = self.request.POST.copy() - self.request_2.POST.update({'id':self.item_2.location.url()}) - self.request_2.user = self.permit_user - self.assertIsInstance(views.save_item(self.request_2), HttpResponse) - # Test updating data - self.request_3 = RequestFactory().post(self.item_2.location.url()) - self.request_3.POST = self.request.POST.copy() - self.request_3.POST.update({'id':self.item_2.location.url(), - 'data':{'foo':'bar'}}) - self.request_3.user = self.permit_user - self.assertIsInstance(views.save_item(self.request_3), HttpResponse) - self.assertEquals(modulestore().get_item(self.item_2.location.dict()).definition['data'], - {u'foo': u'bar'}) - # Test updating metadata - self.request_4 = RequestFactory().post(self.item_2.location.url()) - self.request_4.POST = self.request.POST.copy() - self.request_4.POST.update({'id':self.item_2.location.url(), - 'metadata':{'foo':'bar'}}) - self.request_4.user = self.permit_user - self.assertIsInstance(views.save_item(self.request_4), HttpResponse) - self.assertEquals(modulestore().get_item(self.item_2.location.dict()).metadata['foo'], - 'bar') - #done - - def test_clone_item(self): - # Test that user with no permissions gets redirected - self.request = RequestFactory().post(self.item.location.url()) - self.request.POST = self.request.POST.copy() - self.request.POST.update({'id':self.item.location.url(), - 'parent_location':self.course.location.url(), - 'template':self.location_3, - 'display_name':'bar'}) - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.clone_item, self.request) - self.request.user = self.permit_user - response = views.clone_item(self.request) - self.assertIsInstance(response, HttpResponse) - self.assertRegexpMatches(response.content, '{"id": "i4x://MITx/999/course/') - # Done - - def test_upload_asset(self): - # Test get request - self.request = RequestFactory().get('foo') - self.assertIsInstance(views.upload_asset(self.request,'org', 'course', - 'coursename'), HttpResponseBadRequest) - # Test no permissions - self.request_2 = RequestFactory().post('foo') - self.request_2.user = self.no_permit_user - self.assertIsInstance(views.upload_asset(self.request_2, 'MITx', '999', - 'Robot_Super_Course'), HttpResponseForbidden) - # Test if course exists - - self.request_3 = RequestFactory().post('foo') - self.request_3.user = self.permit_user - # Throws error because of improperly formatted log -## self.assertIsInstance(views.upload_asset(self.request_3,'org', 'course', -## 'coursename'),HttpResponseBadRequest) - # Test response with fake file attached - # Not sure how to create fake file for testing purposes because - # can't override request.FILES -## print self.request_3.FILES -## print type(self.request_3.FILES) -## f = open('file.txt') -## self.request_4 = RequestFactory().post('foo', f) -## print self.request_3.FILES -## mock_file = MagicMock(name = 'Secrets', content_type = 'foo') -## mock_file.read.return_value = 'stuff' -## file_dict = {'file':mock_file} -## self.request_3.FILES = file_dict -## print views.upload_asset(self.request_3, 'MITx', '999', -## 'Robot_Super_Course') - - def test_manage_users(self): - self.request = RequestFactory().get('foo') - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.manage_users, self.request, - self.location_3) - # Needs render_to_response - - def test_create_json_response(self): - ok_response = views.create_json_response() - self.assertIsInstance(ok_response, HttpResponse) - self.assertEquals(ok_response.content, '{"Status": "OK"}') - bad_response = views.create_json_response('Spacetime collapsing') - self.assertIsInstance(bad_response, HttpResponse) - self.assertEquals(bad_response.content, '{"Status": "Failed", "ErrMsg": "Spacetime collapsing"}') - - def test_reorder_static_tabs(self): - self.request = RequestFactory().get('foo') - self.request.POST = {'tabs':[self.location_3]} - self.request.user = self.no_permit_user - self.assertRaises(PermissionDenied, views.reorder_static_tabs, self.request) - self.request.user = self.permit_user - self.assertIsInstance(views.reorder_static_tabs(self.request), - HttpResponseBadRequest) - # to be continued ... - -def f(x): - x.n += 1 - -class Stub(): - def __init__(self): - self.n = 0 - self.children = [] - def get_children(self): - return self.children - diff --git a/lms/djangoapps/courseware/tests/test_course_creation.py b/lms/djangoapps/courseware/tests/test_course_creation.py deleted file mode 100644 index af3e3ee0e1..0000000000 --- a/lms/djangoapps/courseware/tests/test_course_creation.py +++ /dev/null @@ -1,61 +0,0 @@ -import logging -from mock import MagicMock, patch -import factory -import copy -from path import path - -from django.test import TestCase -from django.test.client import Client -from django.core.urlresolvers import reverse -from django.conf import settings -from override_settings import override_settings - -from xmodule.modulestore.xml_importer import import_from_xml -import xmodule.modulestore.django - -TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) -TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') - -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) -class CreateTest(TestCase): - def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) - - def check_edit_item(self, test_course_name): - import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) - for descriptor in modulestore().get_items(Location(None, None, None, None, None)): - print "Checking ", descriptor.location.url() - print descriptor.__class__, descriptor.location - resp = self.client.get(reverse('edit_item'), {'id': descriptor.location.url()}) - self.assertEqual(resp.status_code, 200) - - def test_edit_item_toy(self): - self.check_edit_item('toy') - -## def setUp(self): -## self.client = Client() -## self.username = 'username' -## self.email = 'test@foo.com' -## self.pw = 'password' -## -## def create_account(self, username, email, pw): -## resp = self.client.post('/create_account', { -## 'username': username, -## 'email': email, -## 'password': pw, -## 'location': 'home', -## 'language': 'Franglish', -## 'name': 'Fred Weasley', -## 'terms_of_service': 'true', -## 'honor_code': 'true', -## }) -## return resp -## -## def registration(self, email): -## '''look up registration object by email''' -## return Registration.objects.get(user__email=email) -## -## def activate_user(self, email): -## activation_key = self.registration(email).activation_key diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index f419e6f582..d5f821e0bf 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -14,13 +14,13 @@ from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory from django.core.urlresolvers import reverse +from django.test.utils import override_settings from courseware.models import StudentModule, StudentModuleCache from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.exceptions import NotFoundError from xmodule.modulestore import Location import courseware.module_render as render -from override_settings import override_settings from xmodule.modulestore.django import modulestore, _MODULESTORES from xmodule.seq_module import SequenceModule from courseware.tests.tests import PageLoader @@ -58,35 +58,11 @@ class ModuleRenderTestCase(PageLoader): self.course_id = 'edX/toy/2012_Fall' self.toy_course = modulestore().get_course(self.course_id) - def test_toc_for_course(self): - mock_course = MagicMock() - mock_course.id = 'dummy' - mock_course.location = Location(self.location) - mock_course.get_children.return_value = [] - mock_user = MagicMock() - mock_user.is_authenticated.return_value = False - self.assertIsNone(render.toc_for_course(mock_user,'dummy', - mock_course, 'dummy', 'dummy')) - # rest of tests are in class TestTOC - def test_get_module(self): self.assertIsNone(render.get_module('dummyuser',None,\ 'invalid location',None,None)) - #done - - def test__get_module(self): - mock_user = MagicMock() - mock_user.is_authenticated.return_value = False - location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') - mock_request = MagicMock() - s = render._get_module(mock_user, mock_request, location, - 'dummy', 'edX/toy/2012_Fall') - self.assertIsInstance(s, SequenceModule) - # Don't know how to generate error in line 260 to test - # Can't tell if sequence module is an error? def test_get_instance_module(self): - # done mock_user = MagicMock() mock_user.is_authenticated.return_value = False self.assertIsNone(render.get_instance_module('dummy', mock_user, 'dummy', diff --git a/lms/djangoapps/courseware/tests/test_progress.py b/lms/djangoapps/courseware/tests/test_progress.py index 480d594863..4e528f44a4 100644 --- a/lms/djangoapps/courseware/tests/test_progress.py +++ b/lms/djangoapps/courseware/tests/test_progress.py @@ -55,13 +55,5 @@ class ProgessTests(TestCase): self.c.__setitem__('questions_correct', 4) self.assertEqual(str(self.c),str(self.d)) - # def test_add(self): - # self.assertEqual(self.c.__add__(self.c2), self.cplusc2) - - def test_contains(self): - - return self.c.__contains__('meow') - #self.assertEqual(self.c.__contains__('done'), True) - def test_repr(self): self.assertEqual(self.c.__repr__(), str(progress.completion())) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c901f87720..de4d934ee7 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -4,7 +4,6 @@ import datetime import factory import unittest import os -from nose.plugins.skip import SkipTest from django.test import TestCase from django.http import Http404, HttpResponse @@ -20,13 +19,6 @@ from xmodule.modulestore.exceptions import InvalidLocationError,\ import courseware.views as views from xmodule.modulestore import Location -def skipped(func): - from nose.plugins.skip import SkipTest - def _(): - raise SkipTest("Test %s is skipped" % func.__name__) - _.__name__ = func.__name__ - return _ - #from override_settings import override_settings class Stub(): @@ -38,13 +30,6 @@ class UserFactory(factory.Factory): is_staff = True is_active = True -def skipped(func): - from nose.plugins.skip import SkipTest - def _(): - raise SkipTest("Test %s is skipped" % func.__name__) - _.__name__ = func.__name__ - return _ - # This part is required for modulestore() to work properly def xml_store_config(data_dir): return { @@ -139,20 +124,6 @@ class ViewsTestCase(TestCase): self.assertRaises(Http404, views.redirect_to_course_position, mock_module, True) - def test_index(self): - assert SkipTest - request = self.request_factory.get(self.chapter_url) - request.user = UserFactory() - response = views.index(request, self.course_id) - self.assertIsInstance(response, HttpResponse) - self.assertEqual(response.status_code, 302) - # views.index does not throw 404 if chapter, section, or position are - # not valid, which doesn't match index's comments - views.index(request, self.course_id, chapter='foo', section='bar', - position='baz') - request_2 = self.request_factory.get(self.chapter_url) - request_2.user = self.user - response = views.index(request_2, self.course_id) def test_registered_for_course(self): self.assertFalse(views.registered_for_course('Basketweaving', None)) @@ -187,47 +158,3 @@ class ViewsTestCase(TestCase): ## request_2 = self.request_factory.get('foo') ## request_2.user = UserFactory() - def test_static_university_profile(self): - # TODO - # Can't test unless havehttp://toastdriven.com/blog/2011/apr/10/guide-to-testing-in-django/ a valid template file - raise SkipTest - request = self.client.get('university_profile/edX') - self.assertIsInstance(views.static_university_profile(request, 'edX'), HttpResponse) - - def test_university_profile(self): - raise SkipTest - request = self.request_factory.get(self.chapter_url) - request.user = UserFactory() - self.assertRaisesRegexp(Http404, 'University Profile*', - views.university_profile, request, 'Harvard') - # TODO - #request_2 = self.client.get('/university_profile/edx') - self.assertIsInstance(views.university_profile(request, 'edX'), HttpResponse) - # Can't continue testing unless have valid template file - - - def test_syllabus(self): - raise SkipTest - request = self.request_factory.get(self.chapter_url) - request.user = UserFactory() - # Can't find valid template - # TODO - views.syllabus(request, 'edX/toy/2012_Fall') - - def test_render_notifications(self): - raise SkipTest - request = self.request_factory.get('foo') - #views.render_notifications(request, self.course_id, 'dummy') - # TODO - # Needs valid template - - def test_news(self): - raise SkipTest - # Bug? get_notifications is actually in lms/lib/comment_client/legacy.py - request = self.client.get('/news') - self.user.id = 'foo' - request.user = self.user - course_id = 'edX/toy/2012_Fall' - self.assertIsInstance(views.news(request, course_id), HttpResponse) - - # TODO From 437aadb28e3ce110d1f75cd0f8af79c52a6dbf32 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 09:32:20 -0500 Subject: [PATCH 161/214] Cleaned up some tests; deleted skip tests --- .../courseware/tests/test_courses.py | 63 -------------- .../courseware/tests/test_module_render.py | 87 ------------------- lms/djangoapps/courseware/tests/test_views.py | 25 +----- 3 files changed, 1 insertion(+), 174 deletions(-) delete mode 100644 lms/djangoapps/courseware/tests/test_courses.py diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py deleted file mode 100644 index 91b6af4dfc..0000000000 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ /dev/null @@ -1,63 +0,0 @@ -from mock import MagicMock, patch -import datetime - -from django.test import TestCase -from django.contrib.auth.models import User -from django.conf import settings -from django.test.utils import override_settings - -from student.models import CourseEnrollment -import courseware.courses as courses -from xmodule.modulestore.xml import XMLModuleStore -from xmodule.modulestore.django import modulestore -from xmodule.modulestore import Location - -def xml_store_config(data_dir): - return { - 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - } - } -} - -TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class CoursesTestCase(TestCase): - def setUp(self): -## self.user = User.objects.create(username='dummy', password='123456', -## email='test@mit.edu') -## self.date = datetime.datetime(2013,1,22) -## self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, -## course_id = self.course_id, -## created = self.date)[0] - self._MODULESTORES = {} - #self.course_id = 'edx/toy/2012_Fall' - self.toy_course = modulestore().get_course('edX/toy/2012_Fall') - - def test_get_course_by_id(self): - courses.get_course_by_id("edX/toy/2012_Fall") - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class CoursesTests(TestCase): - # runs - def setUp(self): - self._MODULESTORES = {} - #self.course_id = 'edX/toy/2012_Fall' - self.toy_course = modulestore().get_course('edX/toy/2012_Fall') -## self.fake_user = User.objects.create(is_superuser=True) - - ''' - no test written for get_request_for_thread - ''' - - def test_get_course_by_id(self): - #self.test_course_id = "edX/toy/2012_Fall" - courses.get_course_by_id("edX/toy/2012_Fall") - # print modulestore().get_instance(test_course_id, Location('i4x', 'edx', 'toy', 'course', '2012_Fall')) - #self.assertEqual(courses.get_course_by_id(self.test_course_id),modulestore().get_instance(self.test_course_id, Location('i4x', 'edX', 'toy', 'course', '2012_Fall'), None)) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index d5f821e0bf..9b9b3f6d2f 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -4,7 +4,6 @@ import json import factory import unittest from nose.tools import set_trace -from nose.plugins.skip import SkipTest from django.http import Http404, HttpResponse, HttpRequest from django.conf import settings @@ -74,62 +73,6 @@ class ModuleRenderTestCase(PageLoader): self.assertIsNone(render.get_instance_module('dummy', mock_user_2, mock_module,'dummy')) - def test_get_shared_instance_module(self): - raise SkipTest - mock_user = MagicMock(User) - mock_user.is_authenticated.return_value = False - self.assertIsNone(render.get_shared_instance_module('dummy', mock_user, 'dummy', - 'dummy')) - mock_user_2 = MagicMock(User) - mock_user_2.is_authenticated.return_value = True - - mock_module = MagicMock(shared_state_key = 'key') - mock_module.location = Location('i4x', 'edX', 'toy', 'chapter', 'Overview') - mock_module.get_shared_state.return_value = '{}' - mock_cache = MagicMock() - mock_cache.lookup.return_value = False - #mock_cache._state = 'dummy' - #set_trace() - print mock_module.get_shared_state() - s = render.get_shared_instance_module(self.course_id, mock_user_2, - mock_module, mock_cache) - self.assertIsInstance(s, StudentModule) - # Problem: can't get code to take branch that creates StudentModule? - # Can't finish testing modx_dispatch - - def test_xqueue_callback(self): - mock_request = MagicMock() - mock_request.POST.copy.return_value = {} - # 339 - self.assertRaises(Http404, render.xqueue_callback,mock_request, - 'dummy', 'dummy', 'dummy', 'dummy') - mock_request_2 = MagicMock() - xpackage = {'xqueue_header': json.dumps({}), - 'xqueue_body' : 'Message from grader'} - mock_request_2.POST.copy.return_value = xpackage - # 342 - self.assertRaises(Http404, render.xqueue_callback,mock_request_2, - 'dummy', 'dummy', 'dummy', 'dummy') - mock_request_3 = MagicMock() - xpackage_2 = {'xqueue_header': json.dumps({'lms_key':'secretkey'}), - 'xqueue_body' : 'Message from grader'} - mock_request_3.POST.copy.return_value = xpackage_2 - # Roadblock: how to get user registered in class? - raise SkipTest - # - # trying alternate way of creating account in hopes of getting valid id - # Problem: Can't activate user - - self.student_name = '12' - self.password = 'foo' - self.email = 'test@mit.edu' - self.create_account(self.student_name, self.email, self.password) - self.activate_user(self.email) - request = RequestFactory().get('stuff') - # This doesn't work to install user - render.xqueue_callback(mock_request_3, self.course_id, - self.student_name, self.password, 'dummy') - def test_modx_dispatch(self): self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy', 'invalid Location', 'dummy') @@ -162,40 +105,10 @@ class ModuleRenderTestCase(PageLoader): mock_request_3, 'dummy', self.location, 'toy') self.assertRaises(Http404,render.modx_dispatch, mock_request_3, 'dummy', self.location, self.course_id) -## student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(self.course_id, -## mock_request_3.user, modulestore().get_instance(self.course_id, self.location)) -## get_shared_instance_module(course_id, request.user, instance, student_module_cache) - # 'goto_position' is the only dispatch that will work mock_request_3.POST.copy.return_value = {'position':1} self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position', self.location, self.course_id), HttpResponse) - # keep going - def test_preview_chemcalc(self): - mock_request = MagicMock(method = 'notGET') - self.assertRaises(Http404, render.preview_chemcalc, mock_request) - mock_request_2 = MagicMock(method = 'GET') - mock_request_2.GET.get.return_value = None - self.assertEquals(render.preview_chemcalc(mock_request_2).content, - json.dumps({'preview':'', - 'error':'No formula specified.'})) - - mock_request_3 = MagicMock() - mock_request_3.method = 'GET' - # Test fails because chemcalc.render_to_html always parses strings? - mock_request_3.GET.get.return_value = unicode('\x12400', errors="strict") -## self.assertEquals(render.preview_chemcalc(mock_request_3).content, -## json.dumps({'preview':'', -## 'error':"Couldn't parse formula: formula"})) -## - mock_request_3 = MagicMock() - mock_request_3.method = 'GET' - mock_request_3.GET.get.return_value = Stub() - self.assertEquals(render.preview_chemcalc(mock_request_3).content, - json.dumps({'preview':'', - 'error':"Error while rendering preview"})) - - def test_get_score_bucket(self): self.assertEquals(render.get_score_bucket(0, 10), 'incorrect') self.assertEquals(render.get_score_bucket(1, 10), 'partial') diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index de4d934ee7..76047aabda 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -19,8 +19,6 @@ from xmodule.modulestore.exceptions import InvalidLocationError,\ import courseware.views as views from xmodule.modulestore import Location -#from override_settings import override_settings - class Stub(): pass @@ -45,9 +43,6 @@ def xml_store_config(data_dir): TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) - -#class ModulestoreTest(TestCase): - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestJumpTo(TestCase): """Check the jumpto link for a course""" @@ -134,27 +129,9 @@ class ViewsTestCase(TestCase): mock_course.id = self.course_id self.assertTrue(views.registered_for_course(mock_course, self.user)) - def test_jump_to(self): + def test_jump_to_invalid(self): request = self.request_factory.get(self.chapter_url) self.assertRaisesRegexp(Http404, 'Invalid location', views.jump_to, request, 'bar', ()) self.assertRaisesRegexp(Http404, 'No data*', views.jump_to, request, 'dummy', self.location) -## print type(self.toy_course) -## print dir(self.toy_course) -## print self.toy_course.location -## print self.toy_course.__dict__ -## valid = ['i4x', 'edX', 'toy', 'chapter', 'overview'] -## L = Location('i4x', 'edX', 'toy', 'chapter', 'Overview', None) -## -## views.jump_to(request, 'dummy', L) - - def test_static_tab(self): - request = self.request_factory.get('foo') - request.user = self.user - self.assertRaises(Http404, views.static_tab, request, 'edX/toy/2012_Fall', - 'dummy') - # What are valid tab_slugs? -## request_2 = self.request_factory.get('foo') -## request_2.user = UserFactory() - From 2fd9ccece1cb39b5234d905ae7f4ca2d4ae02aa6 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 09:34:52 -0500 Subject: [PATCH 162/214] Fixed whitespace issues; removed a test that made no assertions --- lms/djangoapps/courseware/tests/test_progress.py | 3 +-- lms/djangoapps/courseware/tests/test_views.py | 11 ----------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_progress.py b/lms/djangoapps/courseware/tests/test_progress.py index 4e528f44a4..44a0f0cb30 100644 --- a/lms/djangoapps/courseware/tests/test_progress.py +++ b/lms/djangoapps/courseware/tests/test_progress.py @@ -30,8 +30,6 @@ class ProgessTests(TestCase): 'questions_incorrect': 1, 'questions_total': 0}) - - self.oth = dict({'duration_total': 0, 'duration_watched': 0, 'done': True, @@ -48,6 +46,7 @@ class ProgessTests(TestCase): 'questions_correct': 4, 'questions_incorrect': 0, 'questions_total': 7} + def test_getitem(self): self.assertEqual(self.c.__getitem__('duration_watched'), 0) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 76047aabda..3ff845834a 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -84,22 +84,12 @@ class ViewsTestCase(TestCase): chapter = 'Overview' self.chapter_url = '%s/%s/%s' % ('/courses', self.course_id, chapter) - def test_user_groups(self): # depreciated function mock_user = MagicMock() mock_user.is_authenticated.return_value = False self.assertEquals(views.user_groups(mock_user),[]) - - @override_settings(DEBUG = True) - def test_user_groups_debug(self): - mock_user = MagicMock() - mock_user.is_authenticated.return_value = True - pass - #views.user_groups(mock_user) - #Keep going later - def test_get_current_child(self): self.assertIsNone(views.get_current_child(Stub())) mock_xmodule = MagicMock() @@ -119,7 +109,6 @@ class ViewsTestCase(TestCase): self.assertRaises(Http404, views.redirect_to_course_position, mock_module, True) - def test_registered_for_course(self): self.assertFalse(views.registered_for_course('Basketweaving', None)) mock_user = MagicMock() From d03fe79e070caa980018acf17c012e372f465c88 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 10:36:22 -0500 Subject: [PATCH 163/214] Small metadata naming fix --- .../xmodule/xmodule/templates/combinedopenended/default.yaml | 2 +- common/lib/xmodule/xmodule/templates/peer_grading/default.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml index 60143d40e9..f2aba0e18b 100644 --- a/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml +++ b/common/lib/xmodule/xmodule/templates/combinedopenended/default.yaml @@ -1,7 +1,7 @@ --- metadata: display_name: Open Ended Response - attempts: 1 + max_attempts: 1 max_score: 1 is_graded: False version: 1 diff --git a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml index 46b5572bc8..cb8e29dfa2 100644 --- a/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml +++ b/common/lib/xmodule/xmodule/templates/peer_grading/default.yaml @@ -5,6 +5,7 @@ metadata: use_for_single_location: False link_to_location: None is_graded: False + max_grade: 1 data: | From ef9f963f494ac3627d20c7f82616bfa867c68ce4 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 5 Mar 2013 10:39:21 -0500 Subject: [PATCH 164/214] studio - advanced modules: added in advanced module button icon per artie/vik work --- cms/static/img/large-advanced-icon.png | Bin 0 -> 342 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cms/static/img/large-advanced-icon.png diff --git a/cms/static/img/large-advanced-icon.png b/cms/static/img/large-advanced-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c6a19ea5a931e4a65daecb30ab6e741238db9b62 GIT binary patch literal 342 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX3?zBp#Z3TG#sNMduK!5^Kutj9qFbB30a*ei zL4Lsn{R!(0?w=P>2$;YBy+b{NjoN-`pxhx(7srr_TWc?#Eo)KWX?s}mgO~Hi-Tzmw zZF%L;yZO~_&h~V{zDs2$23ODDu77lE`Kj6K7j^JX%y@iEb^~L{^q(nZmJ4rYc*yZ* zY>X65e)_e{;dQU{rA?_%?QCbf-BaXtV;xIn>ZaOo(FHTRmA Date: Tue, 5 Mar 2013 10:51:59 -0500 Subject: [PATCH 165/214] merged openended and advanced categories per discussion at today's standup --- cms/djangoapps/contentstore/views.py | 29 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index e14a3108d4..92cced6106 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -70,8 +70,7 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] ADVANCED_COMPONENT_TYPES = { - 'openended' : ['combinedopenended', 'peergrading'], - 'advanced' : ['annotatable'], + 'advanced' : ['annotatable','combinedopenended', 'peergrading'] } ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @@ -293,18 +292,20 @@ def edit_unit(request, location): course_metadata = CourseMetadata.fetch(course.location) course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) - #First try to parse with json - try: - course_advanced_keys = json.loads(course_advanced_keys) - except: - log.error("Cannot json decode course advanced policy: {0}".format(course_advanced_keys)) - #It may be that it is not a json object, but can be evaluated as a python literal - try: - #This is a safe evaluation. See docs for ast - course_advanced_keys = ast.literal_eval(course_advanced_keys) - except: - log.error("Cannot parse course advanced policy at all: {0}".format(course_advanced_keys)) - course_advanced_keys=[] + # check if the keys are in JSON format, or perhaps a literal python expression + if isinstance(course_advanced_keys, basestring): + # Are you JSON? + try: + course_advanced_keys = json.loads(course_advanced_keys) + except: + log.error("Cannot JSON decode course advanced policy: {0}".format(course_advanced_keys)) + # Not JSON? How about Python? + try: + #This is a safe evaluation. See docs for ast + course_advanced_keys = ast.literal_eval(course_advanced_keys) + except: + log.error("Cannot parse course advanced policy at all: {0}".format(course_advanced_keys)) + course_advanced_keys=[] #Set component types according to course policy file component_types = COMPONENT_TYPES From c9b2a6a203921249b9cfafa92172db0ad5e3013e Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Mar 2013 10:52:39 -0500 Subject: [PATCH 166/214] updated CSS to use the new advanced icon --- cms/static/sass/_graphics.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/sass/_graphics.scss b/cms/static/sass/_graphics.scss index 48d3453777..300cf3b692 100644 --- a/cms/static/sass/_graphics.scss +++ b/cms/static/sass/_graphics.scss @@ -275,7 +275,7 @@ width: 100px; height: 60px; margin-right: 5px; - background: url(../img/large-problem-icon.png) center no-repeat; + background: url(../img/large-advanced-icon.png) center no-repeat; } .large-textbook-icon { From cc234d45e7fde00e06c23aecfb1b160931c18f00 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 5 Mar 2013 10:55:27 -0500 Subject: [PATCH 167/214] Don't collapse multiple underscores for asset names. #202 --- common/lib/xmodule/xmodule/contentstore/content.py | 3 ++- common/lib/xmodule/xmodule/modulestore/__init__.py | 11 +++++++++++ common/lib/xmodule/xmodule/tests/test_content.py | 11 ++++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index be33401bc8..9dc4b1367b 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -35,7 +35,8 @@ class StaticContent(object): @staticmethod def compute_location(org, course, name, revision=None, is_thumbnail=False): name = name.replace('/', '_') - return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail', Location.clean(name), revision]) + return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail', + Location.clean_keeping_underscores(name), revision]) def get_id(self): return StaticContent.get_id_from_location(self.location) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 0ba7e36540..525527c93f 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -71,6 +71,17 @@ class Location(_LocationBase): """ return Location._clean(value, INVALID_CHARS) + + @staticmethod + def clean_keeping_underscores(value): + """ + Return value, replacing INVALID_CHARS, but not collapsing multiple '_' chars. + This for cleaning asset names, as the YouTube ID's may have underscores in them, and we need the + transcript asset name to match. In the future we may want to change the behavior of _clean. + """ + return INVALID_CHARS.sub('_', value) + + @staticmethod def clean_for_url_name(value): """ diff --git a/common/lib/xmodule/xmodule/tests/test_content.py b/common/lib/xmodule/xmodule/tests/test_content.py index 1bcd2f4ebe..e73c33197c 100644 --- a/common/lib/xmodule/xmodule/tests/test_content.py +++ b/common/lib/xmodule/xmodule/tests/test_content.py @@ -19,9 +19,14 @@ class ContentTest(unittest.TestCase): content = StaticContent('loc', 'name', 'content_type', 'data') self.assertIsNone(content.thumbnail_location) - def test_generate_thumbnail_nonimage(self): + def test_generate_thumbnail_image(self): contentStore = ContentStore() - content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters.jpg'), None) + content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters__.jpg'), None) (thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content) self.assertIsNone(thumbnail_content) - self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg'), thumbnail_file_location) + self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location) + def test_compute_location(self): + # We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space) + # still happen. + asset_location = StaticContent.compute_location('mitX', '400', 'subs__1eo_jXvZnE .srt.sjson') + self.assertEqual(Location(u'c4x', u'mitX', u'400', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location) From cee24c5cb23bdd077f195c291d705a50ca8f977b Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 5 Mar 2013 11:18:07 -0500 Subject: [PATCH 168/214] Import existing UserFactory --- lms/djangoapps/courseware/tests/test_module_render.py | 8 ++------ lms/djangoapps/courseware/tests/test_views.py | 7 ++----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 9b9b3f6d2f..61b70d3656 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -25,6 +25,8 @@ from xmodule.seq_module import SequenceModule from courseware.tests.tests import PageLoader from student.models import Registration +from factories import UserFactory + class Stub: def __init__(self): pass @@ -40,12 +42,6 @@ def xml_store_config(data_dir): } } -class UserFactory(factory.Factory): - first_name = 'Test' - last_name = 'Robot' - is_staff = True - is_active = True - TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 3ff845834a..cb15b5b7b6 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -19,14 +19,11 @@ from xmodule.modulestore.exceptions import InvalidLocationError,\ import courseware.views as views from xmodule.modulestore import Location +from factories import UserFactory + class Stub(): pass -class UserFactory(factory.Factory): - first_name = 'Test' - last_name = 'Robot' - is_staff = True - is_active = True # This part is required for modulestore() to work properly def xml_store_config(data_dir): From 21af0493fdda90e389955a8c939290c5b2b175b5 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 11:21:28 -0500 Subject: [PATCH 169/214] Added unit tests for capa_module reset_problem() --- common/lib/xmodule/xmodule/capa_module.py | 10 +++- .../xmodule/xmodule/tests/test_capa_module.py | 59 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index cf1d786b72..22f09b4591 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -703,7 +703,12 @@ class CapaModule(XModule): ''' Changes problem state to unfinished -- removes student answers, and causes problem to rerender itself. - Returns problem html as { 'html' : html-string }. + Returns a dictionary of the form: + {'success': True/False, + 'html': Problem HTML string } + + If an error occurs, the dictionary will also have an + 'error' key containing an error message. ''' event_info = dict() event_info['old_state'] = self.lcp.get_state() @@ -734,7 +739,8 @@ class CapaModule(XModule): event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) - return {'html': self.get_problem_html(encapsulate=False)} + return { 'success': True, + 'html': self.get_problem_html(encapsulate=False)} class CapaDescriptor(RawDescriptor): diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 453365f73b..afe4a73725 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1,6 +1,6 @@ import datetime import json -from mock import Mock, patch +from mock import Mock, MagicMock, patch from pprint import pprint import unittest @@ -465,3 +465,60 @@ class CapaModuleTest(unittest.TestCase): # Expect that the number of attempts is NOT incremented self.assertEqual(module.attempts, 1) + + + def test_reset_problem(self): + module = CapaFactory.create() + + # Mock the module's capa problem + # to simulate that the problem is done + mock_problem = MagicMock(capa.capa_problem.LoncapaProblem) + mock_problem.done = True + module.lcp = mock_problem + + # Stub out HTML rendering + with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html: + mock_html.return_value = "
    Test HTML
    " + + # Reset the problem + get_request_dict = {} + result = module.reset_problem(get_request_dict) + + # Expect that the request was successful + self.assertTrue('success' in result and result['success']) + + # Expect that the problem HTML is retrieved + self.assertTrue('html' in result) + self.assertEqual(result['html'], "
    Test HTML
    ") + + # Expect that the problem was reset + mock_problem.do_reset.assert_called_once_with() + + + def test_reset_problem_closed(self): + module = CapaFactory.create() + + # Simulate that the problem is closed + with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: + mock_closed.return_value = True + + # Try to reset the problem + get_request_dict = {} + result = module.reset_problem(get_request_dict) + + # Expect that the problem was NOT reset + self.assertTrue('success' in result and not result['success']) + + + def test_reset_problem_not_done(self): + module = CapaFactory.create() + + # Simulate that the problem is NOT done + module.lcp.done = False + + # Try to reset the problem + get_request_dict = {} + result = module.reset_problem(get_request_dict) + + # Expect that the problem was NOT reset + self.assertTrue('success' in result and not result['success']) From 49784009d538a686e8b47455b5e4c83d7b95ae9e Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 11:23:14 -0500 Subject: [PATCH 170/214] Fixed incorrect doc string --- common/lib/xmodule/xmodule/capa_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 22f09b4591..8e2f8825de 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -590,7 +590,7 @@ class CapaModule(XModule): ''' Checks whether answers to a problem are correct, and returns a map of correct/incorrect answers: - {'success' : bool, + {'success' : 'correct' | 'incorrect' | AJAX alert msg string, 'contents' : html} ''' event_info = dict() From 6ec3b94d0c0df050257fffc9deab4783206777ef Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 11:47:41 -0500 Subject: [PATCH 171/214] Added test for capa module closed() method; modified closed() to handle edge cases --- common/lib/xmodule/xmodule/capa_module.py | 2 +- .../xmodule/xmodule/tests/test_capa_module.py | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 8e2f8825de..5e0e2e9ac4 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -419,7 +419,7 @@ class CapaModule(XModule): def closed(self): ''' Is the student still allowed to submit answers? ''' - if self.attempts == self.max_attempts: + if self.max_attempts is not None and self.attempts >= self.max_attempts: return True if self.is_past_due(): return True diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index afe4a73725..803b91d139 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -286,6 +286,34 @@ class CapaModuleTest(unittest.TestCase): self.assertTrue(still_in_grace.answer_available()) + def test_closed(self): + + # Attempts < Max attempts --> NOT closed + module = CapaFactory.create(max_attempts="1", attempts="0") + self.assertFalse(module.closed()) + + # Attempts < Max attempts --> NOT closed + module = CapaFactory.create(max_attempts="2", attempts="1") + self.assertFalse(module.closed()) + + # Attempts = Max attempts --> closed + module = CapaFactory.create(max_attempts="1", attempts="1") + self.assertTrue(module.closed()) + + # Attempts > Max attempts --> closed + module = CapaFactory.create(max_attempts="1", attempts="2") + self.assertTrue(module.closed()) + + # Max attempts = 0 --> closed + module = CapaFactory.create(max_attempts="0", attempts="2") + self.assertTrue(module.closed()) + + # Past due --> closed + module = CapaFactory.create(max_attempts="1", attempts="0", + due=self.yesterday_str) + self.assertTrue(module.closed()) + + def test_parse_get_params(self): # Valid GET param dict @@ -522,3 +550,21 @@ class CapaModuleTest(unittest.TestCase): # Expect that the problem was NOT reset self.assertTrue('success' in result and not result['success']) + + + def test_save_problem(self): + module = CapaFactory.create() + + # Simulate + + + def test_save_problem_closed(self): + self.fail() + + + def test_save_problem_submitted_with_randomize(self): + self.fail() + + + def test_save_problem_submitted_no_randomize(self): + self.fail() From b0a018195ad78f180dc3e210e995798c9d7a1d31 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 5 Mar 2013 11:53:00 -0500 Subject: [PATCH 172/214] Pep8 fixes for tests --- .../courseware/tests/test_access.py | 11 +- .../courseware/tests/test_module_render.py | 97 +++++++++-------- .../courseware/tests/test_progress.py | 87 ++++++++------- lms/djangoapps/courseware/tests/test_views.py | 31 +++--- lms/djangoapps/courseware/tests/tests.py | 101 ++++++++---------- 5 files changed, 158 insertions(+), 169 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 0b32da17a4..acb05d5d78 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -1,21 +1,20 @@ import unittest -import logging +import logging import time from mock import Mock, MagicMock, patch from django.conf import settings from django.test import TestCase -from xmodule.course_module import CourseDescriptor -from xmodule.error_module import ErrorDescriptor -from xmodule.modulestore import Location -from xmodule.timeparse import parse_time +from xmodule.course_module import CourseDescriptor +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore import Location +from xmodule.timeparse import parse_time from xmodule.x_module import XModule, XModuleDescriptor import courseware.access as access from factories import CourseEnrollmentAllowedFactory - class AccessTestCase(TestCase): def test__has_global_staff_access(self): u = Mock(is_staff=False) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 61b70d3656..81f95a85e4 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -12,39 +12,42 @@ from django.test.client import Client from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory -from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse from django.test.utils import override_settings -from courseware.models import StudentModule, StudentModuleCache +from courseware.models import StudentModule, StudentModuleCache from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.exceptions import NotFoundError +from xmodule.exceptions import NotFoundError from xmodule.modulestore import Location import courseware.module_render as render from xmodule.modulestore.django import modulestore, _MODULESTORES from xmodule.seq_module import SequenceModule from courseware.tests.tests import PageLoader -from student.models import Registration +from student.models import Registration from factories import UserFactory + class Stub: def __init__(self): pass + def xml_store_config(data_dir): return { - 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } } } -} TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class ModuleRenderTestCase(PageLoader): def setUp(self): @@ -54,8 +57,8 @@ class ModuleRenderTestCase(PageLoader): self.toy_course = modulestore().get_course(self.course_id) def test_get_module(self): - self.assertIsNone(render.get_module('dummyuser',None,\ - 'invalid location',None,None)) + self.assertIsNone(render.get_module('dummyuser', None, + 'invalid location', None, None)) def test_get_instance_module(self): mock_user = MagicMock() @@ -67,17 +70,17 @@ class ModuleRenderTestCase(PageLoader): mock_module = MagicMock() mock_module.descriptor.stores_state = False self.assertIsNone(render.get_instance_module('dummy', mock_user_2, - mock_module,'dummy')) + mock_module, 'dummy')) def test_modx_dispatch(self): self.assertRaises(Http404, render.modx_dispatch, 'dummy', 'dummy', 'invalid Location', 'dummy') mock_request = MagicMock() mock_request.FILES.keys.return_value = ['file_id'] - mock_request.FILES.getlist.return_value = ['file']*(settings.MAX_FILEUPLOADS_PER_INPUT + 1) + mock_request.FILES.getlist.return_value = ['file'] * (settings.MAX_FILEUPLOADS_PER_INPUT + 1) self.assertEquals(render.modx_dispatch(mock_request, 'dummy', self.location, 'dummy').content, - json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' %\ + json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' % settings.MAX_FILEUPLOADS_PER_INPUT})) mock_request_2 = MagicMock() mock_request_2.FILES.keys.return_value = ['file_id'] @@ -88,8 +91,8 @@ class ModuleRenderTestCase(PageLoader): mock_request_2.FILES.getlist.return_value = filelist self.assertEquals(render.modx_dispatch(mock_request_2, 'dummy', self.location, 'dummy').content, - json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %\ - (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE/(1000**2))})) + json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % + (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))})) mock_request_3 = MagicMock() mock_request_3.POST.copy.return_value = {} mock_request_3.FILES = False @@ -99,12 +102,12 @@ class ModuleRenderTestCase(PageLoader): inputfile_2.name = 'name' self.assertRaises(ItemNotFoundError, render.modx_dispatch, mock_request_3, 'dummy', self.location, 'toy') - self.assertRaises(Http404,render.modx_dispatch, mock_request_3, 'dummy', + self.assertRaises(Http404, render.modx_dispatch, mock_request_3, 'dummy', self.location, self.course_id) - mock_request_3.POST.copy.return_value = {'position':1} + mock_request_3.POST.copy.return_value = {'position': 1} self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position', self.location, self.course_id), HttpResponse) - + def test_get_score_bucket(self): self.assertEquals(render.get_score_bucket(0, 10), 'incorrect') self.assertEquals(render.get_score_bucket(1, 10), 'partial') @@ -113,6 +116,7 @@ class ModuleRenderTestCase(PageLoader): self.assertEquals(render.get_score_bucket(11, 10), 'incorrect') self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect') + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestTOC(TestCase): """Check the Table of Contents for a course""" @@ -130,19 +134,19 @@ class TestTOC(TestCase): factory = RequestFactory() request = factory.get(chapter_url) - expected = ([{'active': True, 'sections': - [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, - 'format': u'Lecture Sequence', 'due': '', 'active': False}, - {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, - 'format': '', 'due': '', 'active': False}, - {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, - 'format': '', 'due': '', 'active': False}, - {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, - 'format': '', 'due': '', 'active': False}], - 'url_name': 'Overview', 'display_name': u'Overview'}, - {'active': False, 'sections': - [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, - 'format': '', 'due': '', 'active': False}], + expected = ([{'active': True, 'sections': + [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, + 'format': u'Lecture Sequence', 'due': '', 'active': False}, + {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'Overview', 'display_name': u'Overview'}, + {'active': False, 'sections': + [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, + 'format': '', 'due': '', 'active': False}], 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None) @@ -155,21 +159,20 @@ class TestTOC(TestCase): factory = RequestFactory() request = factory.get(chapter_url) - expected = ([{'active': True, 'sections': - [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, - 'format': u'Lecture Sequence', 'due': '', 'active': False}, - {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, - 'format': '', 'due': '', 'active': True}, - {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, - 'format': '', 'due': '', 'active': False}, - {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, - 'format': '', 'due': '', 'active': False}], - 'url_name': 'Overview', 'display_name': u'Overview'}, - {'active': False, 'sections': - [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, - 'format': '', 'due': '', 'active': False}], + expected = ([{'active': True, 'sections': + [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, + 'format': u'Lecture Sequence', 'due': '', 'active': False}, + {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, + 'format': '', 'due': '', 'active': True}, + {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'Overview', 'display_name': u'Overview'}, + {'active': False, 'sections': + [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, + 'format': '', 'due': '', 'active': False}], 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section) self.assertEqual(expected, actual) - diff --git a/lms/djangoapps/courseware/tests/test_progress.py b/lms/djangoapps/courseware/tests/test_progress.py index 44a0f0cb30..a70cbe4b9a 100644 --- a/lms/djangoapps/courseware/tests/test_progress.py +++ b/lms/djangoapps/courseware/tests/test_progress.py @@ -1,58 +1,57 @@ -from django.test import TestCase +from django.test import TestCase from courseware import progress from mock import MagicMock - class ProgessTests(TestCase): - def setUp(self): + def setUp(self): - self.d = dict({'duration_total': 0, - 'duration_watched': 0, - 'done': True, - 'questions_correct': 4, - 'questions_incorrect': 0, - 'questions_total': 0}) + self.d = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 4, + 'questions_incorrect': 0, + 'questions_total': 0}) - self.c = progress.completion() - self.c2= progress.completion() - self.c2.dict = dict({'duration_total': 0, - 'duration_watched': 0, - 'done': True, - 'questions_correct': 2, - 'questions_incorrect': 1, - 'questions_total': 0}) + self.c = progress.completion() + self.c2 = progress.completion() + self.c2.dict = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 2, + 'questions_incorrect': 1, + 'questions_total': 0}) - self.cplusc2 = dict({'duration_total': 0, - 'duration_watched': 0, - 'done': True, - 'questions_correct': 2, - 'questions_incorrect': 1, - 'questions_total': 0}) + self.cplusc2 = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 2, + 'questions_incorrect': 1, + 'questions_total': 0}) - self.oth = dict({'duration_total': 0, - 'duration_watched': 0, - 'done': True, - 'questions_correct': 4, - 'questions_incorrect': 0, - 'questions_total': 7}) + self.oth = dict({'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 4, + 'questions_incorrect': 0, + 'questions_total': 7}) - self.x = MagicMock() - self.x.dict = self.oth + self.x = MagicMock() + self.x.dict = self.oth - self.d_oth = {'duration_total': 0, - 'duration_watched': 0, - 'done': True, - 'questions_correct': 4, - 'questions_incorrect': 0, - 'questions_total': 7} + self.d_oth = {'duration_total': 0, + 'duration_watched': 0, + 'done': True, + 'questions_correct': 4, + 'questions_incorrect': 0, + 'questions_total': 7} - def test_getitem(self): - self.assertEqual(self.c.__getitem__('duration_watched'), 0) + def test_getitem(self): + self.assertEqual(self.c.__getitem__('duration_watched'), 0) - def test_setitem(self): - self.c.__setitem__('questions_correct', 4) - self.assertEqual(str(self.c),str(self.d)) + def test_setitem(self): + self.c.__setitem__('questions_correct', 4) + self.assertEqual(str(self.c), str(self.d)) - def test_repr(self): - self.assertEqual(self.c.__repr__(), str(progress.completion())) + def test_repr(self): + self.assertEqual(self.c.__repr__(), str(progress.completion())) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index cb15b5b7b6..eeac999813 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -15,12 +15,13 @@ from django.test.client import RequestFactory from student.models import CourseEnrollment from xmodule.modulestore.django import modulestore, _MODULESTORES from xmodule.modulestore.exceptions import InvalidLocationError,\ - ItemNotFoundError, NoPathToItem + ItemNotFoundError, NoPathToItem import courseware.views as views from xmodule.modulestore import Location from factories import UserFactory + class Stub(): pass @@ -28,18 +29,19 @@ class Stub(): # This part is required for modulestore() to work properly def xml_store_config(data_dir): return { - 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } } } -} TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestJumpTo(TestCase): """Check the jumpto link for a course""" @@ -64,15 +66,16 @@ class TestJumpTo(TestCase): response = self.client.get(jumpto_url) self.assertRedirects(response, expected, status_code=302, target_status_code=302) + class ViewsTestCase(TestCase): def setUp(self): self.user = User.objects.create(username='dummy', password='123456', email='test@mit.edu') - self.date = datetime.datetime(2013,1,22) + self.date = datetime.datetime(2013, 1, 22) self.course_id = 'edX/toy/2012_Fall' - self.enrollment = CourseEnrollment.objects.get_or_create(user = self.user, - course_id = self.course_id, - created = self.date)[0] + self.enrollment = CourseEnrollment.objects.get_or_create(user=self.user, + course_id=self.course_id, + created=self.date)[0] self.location = ['tag', 'org', 'course', 'category', 'name'] self._MODULESTORES = {} # This is a CourseDescriptor object @@ -85,13 +88,13 @@ class ViewsTestCase(TestCase): # depreciated function mock_user = MagicMock() mock_user.is_authenticated.return_value = False - self.assertEquals(views.user_groups(mock_user),[]) - + self.assertEquals(views.user_groups(mock_user), []) + def test_get_current_child(self): self.assertIsNone(views.get_current_child(Stub())) mock_xmodule = MagicMock() mock_xmodule.position = -1 - mock_xmodule.get_display_items.return_value = ['one','two'] + mock_xmodule.get_display_items.return_value = ['one', 'two'] self.assertEquals(views.get_current_child(mock_xmodule), 'one') mock_xmodule_2 = MagicMock() mock_xmodule_2.position = 3 diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index fb6842d4a9..7e00baf61f 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -53,46 +53,46 @@ def registration(email): def mongo_store_config(data_dir): return { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore', - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore', + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } } } -} def draft_mongo_store_config(data_dir): return { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore', - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore', + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } } } -} def xml_store_config(data_dir): return { - 'default': { - 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', - 'OPTIONS': { - 'data_dir': data_dir, - 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } } } -} TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) @@ -115,8 +115,7 @@ class ActivateLoginTestCase(TestCase): 'Response status code was {0} instead of 302'.format(response.status_code)) url = response['Location'] - e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit( - expected_url) + e_scheme, e_netloc, e_path, e_query, e_fragment = urlsplit(expected_url) if not (e_scheme or e_netloc): expected_url = urlunsplit(('http', 'testserver', e_path, e_query, e_fragment)) @@ -211,7 +210,7 @@ class PageLoader(ActivateLoginTestCase): resp = self.client.post('/change_enrollment', { 'enrollment_action': 'enroll', 'course_id': course.id, - }) + }) return parse_json(resp) def try_enroll(self, course): @@ -230,11 +229,10 @@ class PageLoader(ActivateLoginTestCase): resp = self.client.post('/change_enrollment', { 'enrollment_action': 'unenroll', 'course_id': course.id, - }) + }) data = parse_json(resp) self.assertTrue(data['success']) - def check_for_get_code(self, code, url): """ Check that we got the expected code when accessing url via GET. @@ -246,7 +244,6 @@ class PageLoader(ActivateLoginTestCase): .format(resp.status_code, url, code)) return resp - def check_for_post_code(self, code, url, data={}): """ Check that we got the expected code when accessing url via POST. @@ -258,12 +255,8 @@ class PageLoader(ActivateLoginTestCase): .format(resp.status_code, url, code)) return resp - - def check_pages_load(self, module_store): """Make all locations in course load""" - - # enroll in the course before trying to access pages courses = module_store.get_courses() self.assertEqual(len(courses), 1) @@ -316,7 +309,7 @@ class PageLoader(ActivateLoginTestCase): msg = str(resp.status_code) if resp.status_code != 200: - msg = "ERROR " + msg + ": " + descriptor.location.url() + msg = "ERROR " + msg + ": " + descriptor.location.url() all_ok = False num_bad += 1 elif resp.redirect_chain[0][1] != 302: @@ -344,7 +337,6 @@ class PageLoader(ActivateLoginTestCase): self.assertTrue(all_ok) - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCoursesLoadTestCase_XmlModulestore(PageLoader): '''Check that all pages in test courses load properly''' @@ -355,21 +347,21 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoader): def test_toy_course_loads(self): module_store = XMLModuleStore( - TEST_DATA_DIR, - default_class='xmodule.hidden_module.HiddenDescriptor', - course_dirs=['toy'], - load_error_modules=True, - ) + TEST_DATA_DIR, + default_class='xmodule.hidden_module.HiddenDescriptor', + course_dirs=['toy'], + load_error_modules=True, + ) self.check_pages_load(module_store) def test_full_course_loads(self): module_store = XMLModuleStore( - TEST_DATA_DIR, - default_class='xmodule.hidden_module.HiddenDescriptor', - course_dirs=['full'], - load_error_modules=True, - ) + TEST_DATA_DIR, + default_class='xmodule.hidden_module.HiddenDescriptor', + course_dirs=['full'], + load_error_modules=True, + ) self.check_pages_load(module_store) @@ -525,7 +517,6 @@ class TestViewAuth(PageLoader): print 'checking for 404 on {0}'.format(url) self.check_for_get_code(404, url) - # now also make the instructor staff u = user(self.instructor) u.is_staff = True @@ -536,7 +527,6 @@ class TestViewAuth(PageLoader): print 'checking for 200 on {0}'.format(url) self.check_for_get_code(200, url) - def run_wrapped(self, test): """ test.py turns off start dates. Enable them. @@ -552,7 +542,6 @@ class TestViewAuth(PageLoader): finally: settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD - def test_dark_launch(self): """Make sure that before course start, students can't access course pages, but instructors can""" @@ -646,7 +635,6 @@ class TestViewAuth(PageLoader): url = reverse_urls(['courseware'], course)[0] self.check_for_get_code(302, url) - # First, try with an enrolled student print '=== Testing student access....' self.login(self.student, self.password) @@ -761,7 +749,6 @@ class TestViewAuth(PageLoader): self.assertTrue(has_access(student_user, self.toy, 'load')) - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCourseGrader(PageLoader): """Check that a course gets graded properly""" @@ -832,13 +819,12 @@ class TestCourseGrader(PageLoader): kwargs={ 'course_id': self.graded_course.id, 'location': problem_location, - 'dispatch': 'problem_check', } - ) + 'dispatch': 'problem_check', }) resp = self.client.post(modx_url, { 'input_i4x-edX-graded-problem-{0}_2_1'.format(problem_url_name): responses[0], 'input_i4x-edX-graded-problem-{0}_2_2'.format(problem_url_name): responses[1], - }) + }) print "modx_url", modx_url, "responses", responses print "resp", resp @@ -854,8 +840,7 @@ class TestCourseGrader(PageLoader): kwargs={ 'course_id': self.graded_course.id, 'location': problem_location, - 'dispatch': 'problem_reset', } - ) + 'dispatch': 'problem_reset', }) resp = self.client.post(modx_url) return resp From 42043d2ccc4d0600b1610d4e3a195d7b6ba7e145 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 12:01:04 -0500 Subject: [PATCH 173/214] Added tests for capa module save_problem() --- .../xmodule/xmodule/tests/test_capa_module.py | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 803b91d139..2469431217 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -38,8 +38,12 @@ class CapaFactory(object): @staticmethod def input_key(): """ Return the input key to use when passing GET parameters """ - return ("input_" + - "-".join(['i4x', 'edX', 'capa_test', 'problem', + return ("input_" + CapaFactory.answer_key()) + + @staticmethod + def answer_key(): + """ Return the key stored in the capa problem answer dict """ + return ("-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % CapaFactory.num]) + "_2_1") @@ -555,16 +559,62 @@ class CapaModuleTest(unittest.TestCase): def test_save_problem(self): module = CapaFactory.create() - # Simulate + # Simulate that the problem is not done (not attempted or reset) + module.lcp.done = False + + # Save the problem + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that answers are saved to the problem + expected_answers = { CapaFactory.answer_key(): '3.14' } + self.assertEqual(module.lcp.student_answers, expected_answers) + + # Expect that the result is success + self.assertTrue('success' in result and result['success']) def test_save_problem_closed(self): - self.fail() + module = CapaFactory.create() + + # Simulate that the problem is NOT done (not attempted or reset) + module.lcp.done = False + + # Simulate that the problem is closed + with patch('xmodule.capa_module.CapaModule.closed') as mock_closed: + mock_closed.return_value = True + + # Try to save the problem + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that the result is failure + self.assertTrue('success' in result and not result['success']) def test_save_problem_submitted_with_randomize(self): - self.fail() + module = CapaFactory.create(rerandomize='always') + + # Simulate that the problem is completed + module.lcp.done = True + + # Try to save + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that we cannot save + self.assertTrue('success' in result and not result['success']) def test_save_problem_submitted_no_randomize(self): - self.fail() + module = CapaFactory.create(rerandomize='never') + + # Simulate that the problem is completed + module.lcp.done = True + + # Try to save + get_request_dict = { CapaFactory.input_key(): '3.14' } + result = module.save_problem(get_request_dict) + + # Expect that we succeed + self.assertTrue('success' in result and result['success']) From 5bbb70ef8ace8228f4ae4fddd120effd50eaebff Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Mar 2013 13:47:02 -0500 Subject: [PATCH 174/214] Advanced modules must now be specified individually in order to enable them on the 'advanced' component menu. --- cms/djangoapps/contentstore/views.py | 32 ++++++++++++---------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 92cced6106..d189e18216 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -69,10 +69,8 @@ log = logging.getLogger(__name__) COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] -ADVANCED_COMPONENT_TYPES = { - 'advanced' : ['annotatable','combinedopenended', 'peergrading'] -} - +ADVANCED_COMPONENT_TYPES = ['annotatable','combinedopenended', 'peergrading'] +ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' # cdodge: these are categories which should not be parented, they are detached from the hierarchy @@ -288,11 +286,13 @@ def edit_unit(request, location): component_templates = defaultdict(list) - # check if there are any advanced modules specified in the course policy + # Check if there are any advanced modules specified in the course policy. These modules + # should be specified as a list of strings, where the strings are the names of the modules + # in ADVANCED_COMPONENT_TYPES that should be enabled for the course. course_metadata = CourseMetadata.fetch(course.location) course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) - # check if the keys are in JSON format, or perhaps a literal python expression + # We expect the advanced keys to be a *list* of strings, but if it is a JSON-encoded string, attempt to parse it. if isinstance(course_advanced_keys, basestring): # Are you JSON? try: @@ -307,28 +307,24 @@ def edit_unit(request, location): log.error("Cannot parse course advanced policy at all: {0}".format(course_advanced_keys)) course_advanced_keys=[] - #Set component types according to course policy file - component_types = COMPONENT_TYPES + # Set component types according to course policy file + component_types = list(COMPONENT_TYPES) if isinstance(course_advanced_keys, list): - #Generate a subset of the dictionary for just needed keys course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES] - advanced_component_type_mappings = {k: ADVANCED_COMPONENT_TYPES.get(k,[]) for k in course_advanced_keys} - #Let course staff defined keys be valid - component_types+=course_advanced_keys + if len(course_advanced_keys) > 0: + component_types.append(ADVANCED_COMPONENT_CATEGORY) else: log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys)) templates = modulestore().get_items(Location('i4x', 'edx', 'templates')) for template in templates: category = template.location.category - #Map subcategory to upper level category - for key in course_advanced_keys: - if category in advanced_component_type_mappings[key]: - category = key - break + + if category in course_advanced_keys: + category = ADVANCED_COMPONENT_CATEGORY if category in component_types: - #This is a hack to create categories for different xmodules + #This is a hack to create categories for different xmodules component_templates[category].append(( template.display_name, template.location.url(), From 9475c6edacff878dbf3d56157dda6359b0d44312 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 14:41:08 -0500 Subject: [PATCH 175/214] Address review comments --- common/lib/xmodule/xmodule/peer_grading_module.py | 2 -- lms/djangoapps/open_ended_grading/views.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 067834a7a0..3eb18300c3 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -456,7 +456,6 @@ class PeerGradingModule(XModule): try: problem_list_json = self.peer_gs.get_problem_list(self.system.course_id, self.system.anonymous_student_id) problem_list_dict = problem_list_json - log.debug(problem_list_dict) success = problem_list_dict['success'] if 'error' in problem_list_dict: error_text = problem_list_dict['error'] @@ -593,7 +592,6 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor): 'task_xml': dictionary of xml strings, } """ - log.debug("In definition") expected_children = [] for child in expected_children: if len(xml_object.xpath(child)) == 0: diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index beae034105..55e8088c3f 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -89,18 +89,23 @@ def peer_grading(request, course_id): Show a peer grading interface ''' + #Get the current course course = get_course_with_access(request.user, course_id, 'load') course_id_parts = course.id.split("/") false_dict = [False,"False", "false", "FALSE"] + #Reverse the base course url base_course_url = reverse('courses') try: #TODO: This will not work with multiple runs of a course. Make it work. The last key in the Location passed #to get_items is called revision. Is this the same as run? + #Get the peer grading modules currently in the course items = modulestore().get_items(['i4x', None, course_id_parts[1], 'peergrading', None]) + #See if any of the modules are centralized modules (ie display info from multiple problems) items = [i for i in items if i.metadata.get("use_for_single_location", True) in false_dict] + #Get the first one item_location = items[0].location - item_location_url = item_location.url() + #Generate a url for the first module and redirect the user to it problem_url_parts = search.path_to_location(modulestore(), course.id, item_location) problem_url = generate_problem_url(problem_url_parts, base_course_url) From 102c594e7b9f24978c3854ac12971ed997713b19 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 15:01:12 -0500 Subject: [PATCH 176/214] Remove string and old import --- cms/djangoapps/contentstore/views.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index d189e18216..14d1ee83ed 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1,6 +1,5 @@ from util.json_request import expect_json import json -import ast import logging import os import sys @@ -292,21 +291,6 @@ def edit_unit(request, location): course_metadata = CourseMetadata.fetch(course.location) course_advanced_keys = course_metadata.get(ADVANCED_COMPONENT_POLICY_KEY, []) - # We expect the advanced keys to be a *list* of strings, but if it is a JSON-encoded string, attempt to parse it. - if isinstance(course_advanced_keys, basestring): - # Are you JSON? - try: - course_advanced_keys = json.loads(course_advanced_keys) - except: - log.error("Cannot JSON decode course advanced policy: {0}".format(course_advanced_keys)) - # Not JSON? How about Python? - try: - #This is a safe evaluation. See docs for ast - course_advanced_keys = ast.literal_eval(course_advanced_keys) - except: - log.error("Cannot parse course advanced policy at all: {0}".format(course_advanced_keys)) - course_advanced_keys=[] - # Set component types according to course policy file component_types = list(COMPONENT_TYPES) if isinstance(course_advanced_keys, list): From 608552429f5c5f456e3bd104cc43a6943c80061b Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 15:35:14 -0500 Subject: [PATCH 177/214] Refactored capa module HTML rendering, isolating logic for determining which buttons to show --- common/lib/xmodule/xmodule/capa_module.py | 130 ++++++++------ .../xmodule/xmodule/tests/test_capa_module.py | 162 ++++++++++++++++++ 2 files changed, 242 insertions(+), 50 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 5e0e2e9ac4..19bddd1a19 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -136,7 +136,7 @@ class CapaModule(XModule): self.close_date = self.display_due_date max_attempts = self.metadata.get('attempts', None) - if max_attempts: + if max_attempts is not None: self.max_attempts = int(max_attempts) else: self.max_attempts = None @@ -247,6 +247,78 @@ class CapaModule(XModule): 'progress': Progress.to_js_status_str(self.get_progress()) }) + def check_button_name(self): + """ + Determine the name for the "check" button. + Usually it is just "Check", but if this is the student's + final attempt, change the name to "Final Check" + """ + if self.max_attempts is not None: + final_check = (self.attempts >= self.max_attempts - 1) + else: + final_check = False + + return "Final Check" if final_check else "Check" + + def should_show_check_button(self): + """ + Return True/False to indicate whether to show the "Check" button. + """ + submitted_without_reset = (self.is_completed() and self.rerandomize == "always") + + # If the problem is closed (past due / too many attempts) + # then we do NOT show the "check" button + # Also, do not show the "check" button if we're waiting + # for the user to reset a randomized problem + if self.closed() or submitted_without_reset: + return False + else: + return True + + def should_show_reset_button(self): + """ + Return True/False to indicate whether to show the "Reset" button. + """ + survey_question = (self.max_attempts == 0) + + if self.rerandomize in ["always", "onreset"]: + + # If the problem is closed (and not a survey question with max_attempts==0), + # then do NOT show the reset button. + # If the problem hasn't been submitted yet, then do NOT show + # the reset button. + if (self.closed() and not survey_question) or not self.is_completed(): + return False + else: + return True + + # Only randomized problems need a "reset" button + else: + return False + + def should_show_save_button(self): + """ + Return True/False to indicate whether to show the "Save" button. + """ + + # If the user has forced the save button to display, + # then show it as long as the problem is not closed + # (past due / too many attempts) + if self.force_save_button == "true": + return not self.closed() + else: + survey_question = (self.max_attempts == 0) + needs_reset = self.is_completed() and self.rerandomize == "always" + + # If the problem is closed (and not a survey question with max_attempts==0), + # then do NOT show the reset button + # If we're waiting for the user to reset a randomized problem + # then do NOT show the reset button + if (self.closed() and not survey_question) or needs_reset: + return False + else: + return True + def get_problem_html(self, encapsulate=True): '''Return html for the problem. Adds check, reset, save buttons as necessary based on the problem config and state.''' @@ -313,57 +385,14 @@ class CapaModule(XModule): 'weight': self.descriptor.weight, } - # We using strings as truthy values, because the terminology of the - # check button is context-specific. - - # Put a "Check" button if unlimited attempts or still some left - if self.max_attempts is None or self.attempts < self.max_attempts - 1: - check_button = "Check" - else: - # Will be final check so let user know that - check_button = "Final Check" - - reset_button = True - save_button = True - - # If we're after deadline, or user has exhausted attempts, - # question is read-only. - if self.closed(): - check_button = False - reset_button = False - save_button = False - - # If attempts=0 then show just check and reset buttons; this is for survey questions using capa - if self.max_attempts==0: - check_button = False - reset_button = True - save_button = True - - # User submitted a problem, and hasn't reset. We don't want - # more submissions. - if self.lcp.done and self.rerandomize == "always": - check_button = False - save_button = False - - # Only show the reset button if pressing it will show different values - if self.rerandomize not in ["always", "onreset"]: - reset_button = False - - # User hasn't submitted an answer yet -- we don't want resets - if not self.lcp.done: - reset_button = False - - # We may not need a "save" button if infinite number of attempts and - # non-randomized. The problem author can force it. It's a bit weird for - # randomization to control this; should perhaps be cleaned up. - if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"): - save_button = False - context = {'problem': content, 'id': self.id, - 'check_button': check_button, - 'reset_button': reset_button, - 'save_button': save_button, + + # Pass in the name of the check button or False + # if we do not need a check button + 'check_button': self.check_button_name() if self.should_show_check_button() else False, + 'reset_button': self.should_show_reset_button(), + 'save_button': self.should_show_save_button(), 'answer_available': self.answer_available(), 'ajax_url': self.system.ajax_url, 'attempts_used': self.attempts, @@ -731,6 +760,7 @@ class CapaModule(XModule): # reset random number generator seed (note the self.lcp.get_state() # in next line) self.lcp.seed = None + self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), self.lcp.get_state(), diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 2469431217..1ac9c2e644 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -3,6 +3,7 @@ import json from mock import Mock, MagicMock, patch from pprint import pprint import unittest +import random import xmodule import capa @@ -618,3 +619,164 @@ class CapaModuleTest(unittest.TestCase): # Expect that we succeed self.assertTrue('success' in result and result['success']) + + def test_check_button_name(self): + + # If last attempt, button name changes to "Final Check" + # Just in case, we also check what happens if we have + # more attempts than allowed. + attempts = random.randint(1, 10) + module = CapaFactory.create(attempts=attempts-1, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Final Check") + + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Final Check") + + module = CapaFactory.create(attempts=attempts + 1, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Final Check") + + # Otherwise, button name is "Check" + module = CapaFactory.create(attempts=attempts-2, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Check") + + module = CapaFactory.create(attempts=attempts-3, max_attempts=attempts) + self.assertEqual(module.check_button_name(), "Check") + + # If no limit on attempts, then always show "Check" + module = CapaFactory.create(attempts=attempts-3) + self.assertEqual(module.check_button_name(), "Check") + + module = CapaFactory.create(attempts=0) + self.assertEqual(module.check_button_name(), "Check") + + def test_should_show_check_button(self): + + attempts = random.randint(1,10) + + # If we're after the deadline, do NOT show check button + module = CapaFactory.create(due=self.yesterday_str) + self.assertFalse(module.should_show_check_button()) + + # If user is out of attempts, do NOT show the check button + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + self.assertFalse(module.should_show_check_button()) + + # If survey question (max_attempts = 0), do NOT show the check button + module = CapaFactory.create(max_attempts=0) + self.assertFalse(module.should_show_check_button()) + + # If user submitted a problem but hasn't reset, + # do NOT show the check button + # Note: we can only reset when rerandomize="always" + module = CapaFactory.create(rerandomize="always") + module.lcp.done = True + self.assertFalse(module.should_show_check_button()) + + # Otherwise, DO show the check button + module = CapaFactory.create() + self.assertTrue(module.should_show_check_button()) + + # If the user has submitted the problem + # and we do NOT have a reset button, then we can show the check button + # Setting rerandomize to "never" ensures that the reset button + # is not shown + module = CapaFactory.create(rerandomize="never") + module.lcp.done = True + self.assertTrue(module.should_show_check_button()) + + + def test_should_show_reset_button(self): + + attempts = random.randint(1,10) + + # If we're after the deadline, do NOT show the reset button + module = CapaFactory.create(due=self.yesterday_str) + module.lcp.done = True + self.assertFalse(module.should_show_reset_button()) + + # If the user is out of attempts, do NOT show the reset button + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + module.lcp.done = True + self.assertFalse(module.should_show_reset_button()) + + # If we're NOT randomizing, then do NOT show the reset button + module = CapaFactory.create(rerandomize="never") + module.lcp.done = True + self.assertFalse(module.should_show_reset_button()) + + # If the user hasn't submitted an answer yet, + # then do NOT show the reset button + module = CapaFactory.create() + module.lcp.done = False + self.assertFalse(module.should_show_reset_button()) + + # Otherwise, DO show the reset button + module = CapaFactory.create() + module.lcp.done = True + self.assertTrue(module.should_show_reset_button()) + + # If survey question for capa (max_attempts = 0), + # DO show the reset button + module = CapaFactory.create(max_attempts=0) + module.lcp.done = True + self.assertTrue(module.should_show_reset_button()) + + + def test_should_show_save_button(self): + + attempts = random.randint(1,10) + + # If we're after the deadline, do NOT show the save button + module = CapaFactory.create(due=self.yesterday_str) + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # If the user is out of attempts, do NOT show the save button + module = CapaFactory.create(attempts=attempts, max_attempts=attempts) + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # If user submitted a problem but hasn't reset, do NOT show the save button + module = CapaFactory.create(rerandomize="always") + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # Otherwise, DO show the save button + module = CapaFactory.create() + module.lcp.done = False + self.assertTrue(module.should_show_save_button()) + + # If we're not randomizing, then we can re-save + module = CapaFactory.create(rerandomize="never") + module.lcp.done = True + self.assertTrue(module.should_show_save_button()) + + # If survey question for capa (max_attempts = 0), + # DO show the save button + module = CapaFactory.create(max_attempts=0) + module.lcp.done = False + self.assertTrue(module.should_show_save_button()) + + def test_should_show_save_button_force_save_button(self): + # If we're after the deadline, do NOT show the save button + # even though we're forcing a save + module = CapaFactory.create(due=self.yesterday_str, + force_save_button="true") + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # If the user is out of attempts, do NOT show the save button + attempts = random.randint(1,10) + module = CapaFactory.create(attempts=attempts, + max_attempts=attempts, + force_save_button="true") + module.lcp.done = True + self.assertFalse(module.should_show_save_button()) + + # Otherwise, if we force the save button, + # then show it even if we would ordinarily + # require a reset first + module = CapaFactory.create(force_save_button="true", + rerandomize="always") + module.lcp.done = True + self.assertTrue(module.should_show_save_button()) From 40d7e8addf0bbd3b12e14c853ad0fa0ae818fa25 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 15:43:47 -0500 Subject: [PATCH 178/214] Moved problem HTML error handling to its own function --- common/lib/xmodule/xmodule/capa_module.py | 122 ++++++++++++---------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 19bddd1a19..e6d26bf88e 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -319,66 +319,82 @@ class CapaModule(XModule): else: return True + def handle_problem_html_error(self, err): + """ + Change our problem to a dummy problem containing + a warning message to display to users. + + Returns the HTML to show to users + + *err* is the Exception encountered while rendering the problem HTML. + """ + log.exception(err) + + # TODO (vshnayder): another switch on DEBUG. + if self.system.DEBUG: + msg = ( + '[courseware.capa.capa_module] ' + 'Failed to generate HTML for problem %s' % + (self.location.url())) + msg += '

    Error:

    %s

    ' % str(err).replace('<', '<') + msg += '

    %s

    ' % traceback.format_exc().replace('<', '<') + html = msg + + # We're in non-debug mode, and possibly even in production. We want + # to avoid bricking of problem as much as possible + else: + + # Presumably, student submission has corrupted LoncapaProblem HTML. + # First, pull down all student answers + student_answers = self.lcp.student_answers + answer_ids = student_answers.keys() + + # Some inputtypes, such as dynamath, have additional "hidden" state that + # is not exposed to the student. Keep those hidden + # TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id + hidden_state_keywords = ['dynamath'] + for answer_id in answer_ids: + for hidden_state_keyword in hidden_state_keywords: + if answer_id.find(hidden_state_keyword) >= 0: + student_answers.pop(answer_id) + + # Next, generate a fresh LoncapaProblem + self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), + state=None, # Tabula rasa + seed=self.seed, system=self.system) + + # Prepend a scary warning to the student + warning = '
    '\ + '

    Warning: The problem has been reset to its initial state!

    '\ + 'The problem\'s state was corrupted by an invalid submission. ' \ + 'The submission consisted of:'\ + '
      ' + for student_answer in student_answers.values(): + if student_answer != '': + warning += '
    • ' + cgi.escape(student_answer) + '
    • ' + warning += '
    '\ + 'If this error persists, please contact the course staff.'\ + '
    ' + + html = warning + try: + html += self.lcp.get_html() + except Exception, err: # Couldn't do it. Give up + log.exception(err) + raise + + return html + + def get_problem_html(self, encapsulate=True): '''Return html for the problem. Adds check, reset, save buttons as necessary based on the problem config and state.''' try: html = self.lcp.get_html() + except Exception, err: - log.exception(err) - - # TODO (vshnayder): another switch on DEBUG. - if self.system.DEBUG: - msg = ( - '[courseware.capa.capa_module] ' - 'Failed to generate HTML for problem %s' % - (self.location.url())) - msg += '

    Error:

    %s

    ' % str(err).replace('<', '<') - msg += '

    %s

    ' % traceback.format_exc().replace('<', '<') - html = msg - else: - # We're in non-debug mode, and possibly even in production. We want - # to avoid bricking of problem as much as possible - - # Presumably, student submission has corrupted LoncapaProblem HTML. - # First, pull down all student answers - student_answers = self.lcp.student_answers - answer_ids = student_answers.keys() - - # Some inputtypes, such as dynamath, have additional "hidden" state that - # is not exposed to the student. Keep those hidden - # TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id - hidden_state_keywords = ['dynamath'] - for answer_id in answer_ids: - for hidden_state_keyword in hidden_state_keywords: - if answer_id.find(hidden_state_keyword) >= 0: - student_answers.pop(answer_id) - - # Next, generate a fresh LoncapaProblem - self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - state=None, # Tabula rasa - seed=self.seed, system=self.system) - - # Prepend a scary warning to the student - warning = '
    '\ - '

    Warning: The problem has been reset to its initial state!

    '\ - 'The problem\'s state was corrupted by an invalid submission. ' \ - 'The submission consisted of:'\ - '
      ' - for student_answer in student_answers.values(): - if student_answer != '': - warning += '
    • ' + cgi.escape(student_answer) + '
    • ' - warning += '
    '\ - 'If this error persists, please contact the course staff.'\ - '
    ' - - html = warning - try: - html += self.lcp.get_html() - except Exception, err: # Couldn't do it. Give up - log.exception(err) - raise + return self.handle_problem_html_error(err) content = {'name': self.display_name, 'html': html, From 73281a43c97406715d7bf4b51d6cc5c452aa7bb2 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 17:02:12 -0500 Subject: [PATCH 179/214] Added tests for get_problem_html, including error conditions --- common/lib/xmodule/xmodule/capa_module.py | 18 +++- common/lib/xmodule/xmodule/tests/__init__.py | 2 +- .../xmodule/xmodule/tests/test_capa_module.py | 92 +++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index e6d26bf88e..b42b7e4ccc 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -393,8 +393,19 @@ class CapaModule(XModule): try: html = self.lcp.get_html() + # If we cannot construct the problem HTML, + # then generate an error message instead. except Exception, err: - return self.handle_problem_html_error(err) + html = self.handle_problem_html_error(err) + + + # The convention is to pass the name of the check button + # if we want to show a check button, and False otherwise + # This works because non-empty strings evaluate to True + if self.should_show_check_button(): + check_button = self.check_button_name() + else: + check_button = False content = {'name': self.display_name, 'html': html, @@ -403,10 +414,7 @@ class CapaModule(XModule): context = {'problem': content, 'id': self.id, - - # Pass in the name of the check button or False - # if we do not need a check button - 'check_button': self.check_button_name() if self.should_show_check_button() else False, + 'check_button': check_button, 'reset_button': self.should_show_reset_button(), 'save_button': self.should_show_save_button(), 'answer_available': self.answer_available(), diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 9474717cb2..220f122e7a 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -34,7 +34,7 @@ test_system = ModuleSystem( get_module=Mock(), # "render" to just the context... render_template=lambda template, context: str(context), - replace_urls=Mock(), + replace_urls=lambda html: str(html), user=Mock(is_staff=False), filestore=Mock(), debug=True, diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 1ac9c2e644..a42143da82 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -780,3 +780,95 @@ class CapaModuleTest(unittest.TestCase): rerandomize="always") module.lcp.done = True self.assertTrue(module.should_show_save_button()) + + def test_get_problem_html(self): + module = CapaFactory.create() + + # We've tested the show/hide button logic in other tests, + # so here we hard-wire the values + show_check_button = bool(random.randint(0,1) % 2) + show_reset_button = bool(random.randint(0,1) % 2) + show_save_button = bool(random.randint(0,1) % 2) + + module.should_show_check_button = Mock(return_value=show_check_button) + module.should_show_reset_button = Mock(return_value=show_reset_button) + module.should_show_save_button = Mock(return_value=show_save_button) + + # Mock the system rendering function (reset when we're done) + old_render_func = test_system.render_template + test_system.render_template = Mock(return_value="
    Test Template HTML
    ") + + def cleanup_func(): + test_system.render_template = old_render_func + + self.addCleanup(cleanup_func) + + # Patch the capa problem's HTML rendering + with patch('capa.capa_problem.LoncapaProblem.get_html') as mock_html: + mock_html.return_value = "
    Test Problem HTML
    " + + # Render the problem HTML + html = module.get_problem_html(encapsulate=False) + + # Also render the problem encapsulated in a
    + html_encapsulated = module.get_problem_html(encapsulate=True) + + # Expect that we get the rendered template back + self.assertEqual(html, "
    Test Template HTML
    ") + + # Check the rendering context + render_args,_ = test_system.render_template.call_args + self.assertEqual(len(render_args), 2) + + template_name = render_args[0] + self.assertEqual(template_name, "problem.html") + + context = render_args[1] + self.assertEqual(context['problem']['html'], "
    Test Problem HTML
    ") + self.assertEqual(bool(context['check_button']), show_check_button) + self.assertEqual(bool(context['reset_button']), show_reset_button) + self.assertEqual(bool(context['save_button']), show_save_button) + + # Assert that the encapsulated html contains the original html + self.assertTrue(html in html_encapsulated) + + + def test_get_problem_html_error(self): + """ + In production, when an error occurs with the problem HTML + rendering, a "dummy" problem is created with an error + message to display to the user. + """ + module = CapaFactory.create() + + # Save the original problem so we can compare it later + original_problem = module.lcp + + # Simulate throwing an exception when the capa problem + # is asked to render itself as HTML + module.lcp.get_html = Mock(side_effect=Exception("Test")) + + # Stub out the test_system rendering function temporarily + old_render_func = test_system.render_template + test_system.render_template = Mock(return_value="
    Test Template HTML
    ") + + # Turn off DEBUG temporarily + old_debug = test_system.DEBUG + test_system.DEBUG = False + + def cleanup_func(): + test_system.render_template = old_render_func + test_system.DEBUG = old_debug + + self.addCleanup(cleanup_func) + + # Try to render the module with DEBUG turned off + html = module.get_problem_html() + + # Check the rendering context + render_args,_ = test_system.render_template.call_args + context = render_args[1] + self.assertTrue("error" in context['problem']['html']) + + # Expect that the module has created a new dummy problem with the error + self.assertNotEqual(original_problem, module.lcp) From 43d8574e920568f35f6847da9173a3ed4ec615d1 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 17:26:15 -0500 Subject: [PATCH 180/214] Refactored use of test_system in xmodule unit tests so that a new one is created for each test --- common/lib/xmodule/xmodule/tests/__init__.py | 44 ++++++++++++------- .../xmodule/xmodule/tests/test_capa_module.py | 32 ++++---------- .../xmodule/tests/test_combined_open_ended.py | 39 +++++++++------- .../xmodule/xmodule/tests/test_conditional.py | 9 ++-- .../xmodule/tests/test_self_assessment.py | 14 +++--- 5 files changed, 76 insertions(+), 62 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 220f122e7a..43c2bbe24d 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -28,21 +28,35 @@ open_ended_grading_interface = { 'grading_controller' : 'grading_controller' } -test_system = ModuleSystem( - ajax_url='courses/course_id/modx/a_location', - track_function=Mock(), - get_module=Mock(), - # "render" to just the context... - render_template=lambda template, context: str(context), - replace_urls=lambda html: str(html), - user=Mock(is_staff=False), - filestore=Mock(), - debug=True, - xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, - node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), - anonymous_student_id='student', - open_ended_grading_interface= open_ended_grading_interface -) + +def test_system(): + """ + Construct a test ModuleSystem instance. + + By default, the render_template() method simply returns + the context it is passed as a string. + You can override this behavior by monkey patching: + + system = test_system() + system.render_template = my_render_func + + where my_render_func is a function of the form + my_render_func(template, context) + """ + return ModuleSystem( + ajax_url='courses/course_id/modx/a_location', + track_function=Mock(), + get_module=Mock(), + render_template=lambda template, context: str(context), + replace_urls=lambda html: str(html), + user=Mock(is_staff=False), + filestore=Mock(), + debug=True, + xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, + node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), + anonymous_student_id='student', + open_ended_grading_interface= open_ended_grading_interface + ) class ModelsTest(unittest.TestCase): diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index a42143da82..0e64e740fd 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -108,7 +108,7 @@ class CapaFactory(object): else: instance_state = None - module = CapaModule(test_system, location, + module = CapaModule(test_system(), location, definition, descriptor, instance_state, None, metadata=metadata) @@ -794,14 +794,8 @@ class CapaModuleTest(unittest.TestCase): module.should_show_reset_button = Mock(return_value=show_reset_button) module.should_show_save_button = Mock(return_value=show_save_button) - # Mock the system rendering function (reset when we're done) - old_render_func = test_system.render_template - test_system.render_template = Mock(return_value="
    Test Template HTML
    ") - - def cleanup_func(): - test_system.render_template = old_render_func - - self.addCleanup(cleanup_func) + # Mock the system rendering function + module.system.render_template = Mock(return_value="
    Test Template HTML
    ") # Patch the capa problem's HTML rendering with patch('capa.capa_problem.LoncapaProblem.get_html') as mock_html: @@ -817,7 +811,7 @@ class CapaModuleTest(unittest.TestCase): self.assertEqual(html, "
    Test Template HTML
    ") # Check the rendering context - render_args,_ = test_system.render_template.call_args + render_args,_ = module.system.render_template.call_args self.assertEqual(len(render_args), 2) template_name = render_args[0] @@ -848,25 +842,17 @@ class CapaModuleTest(unittest.TestCase): # is asked to render itself as HTML module.lcp.get_html = Mock(side_effect=Exception("Test")) - # Stub out the test_system rendering function temporarily - old_render_func = test_system.render_template - test_system.render_template = Mock(return_value="
    Test Template HTML
    ") + # Stub out the test_system rendering function + module.system.render_template = Mock(return_value="
    Test Template HTML
    ") - # Turn off DEBUG temporarily - old_debug = test_system.DEBUG - test_system.DEBUG = False - - def cleanup_func(): - test_system.render_template = old_render_func - test_system.DEBUG = old_debug - - self.addCleanup(cleanup_func) + # Turn off DEBUG + module.system.DEBUG = False # Try to render the module with DEBUG turned off html = module.get_problem_html() # Check the rendering context - render_args,_ = test_system.render_template.call_args + render_args,_ = module.system.render_template.call_args context = render_args[1] self.assertTrue("error" in context['problem']['html']) 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 5f6496f823..a524ac2fd9 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -54,7 +54,8 @@ class OpenEndedChildTest(unittest.TestCase): descriptor = Mock() def setUp(self): - self.openendedchild = OpenEndedChild(test_system, self.location, + self.test_system = test_system() + self.openendedchild = OpenEndedChild(self.test_system, self.location, self.definition, self.descriptor, self.static_data, self.metadata) @@ -69,7 +70,7 @@ class OpenEndedChildTest(unittest.TestCase): def test_latest_post_assessment_empty(self): - answer = self.openendedchild.latest_post_assessment(test_system) + answer = self.openendedchild.latest_post_assessment(self.test_system) self.assertEqual(answer, "") @@ -106,7 +107,7 @@ class OpenEndedChildTest(unittest.TestCase): post_assessment = "Post assessment" self.openendedchild.record_latest_post_assessment(post_assessment) self.assertEqual(post_assessment, - self.openendedchild.latest_post_assessment(test_system)) + self.openendedchild.latest_post_assessment(self.test_system)) def test_get_score(self): new_answer = "New Answer" @@ -125,7 +126,7 @@ class OpenEndedChildTest(unittest.TestCase): def test_reset(self): - self.openendedchild.reset(test_system) + self.openendedchild.reset(self.test_system) state = json.loads(self.openendedchild.get_instance_state()) self.assertEqual(state['state'], OpenEndedChild.INITIAL) @@ -182,11 +183,13 @@ class OpenEndedModuleTest(unittest.TestCase): descriptor = Mock() def setUp(self): - test_system.location = self.location + self.test_system = test_system() + + self.test_system.location = self.location self.mock_xqueue = MagicMock() self.mock_xqueue.send_to_queue.return_value = (None, "Message") - test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1} - self.openendedmodule = OpenEndedModule(test_system, self.location, + self.test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1} + self.openendedmodule = OpenEndedModule(self.test_system, self.location, self.definition, self.descriptor, self.static_data, self.metadata) def test_message_post(self): @@ -195,7 +198,7 @@ class OpenEndedModuleTest(unittest.TestCase): 'grader_id': '1', 'score': 3} qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - student_info = {'anonymous_student_id': test_system.anonymous_student_id, + student_info = {'anonymous_student_id': self.test_system.anonymous_student_id, 'submission_time': qtime} contents = { 'feedback': get['feedback'], @@ -205,7 +208,7 @@ class OpenEndedModuleTest(unittest.TestCase): 'student_info': json.dumps(student_info) } - result = self.openendedmodule.message_post(get, test_system) + result = self.openendedmodule.message_post(get, self.test_system) self.assertTrue(result['success']) # make sure it's actually sending something we want to the queue self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY) @@ -216,7 +219,7 @@ class OpenEndedModuleTest(unittest.TestCase): def test_send_to_grader(self): submission = "This is a student submission" qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - student_info = {'anonymous_student_id': test_system.anonymous_student_id, + student_info = {'anonymous_student_id': self.test_system.anonymous_student_id, 'submission_time': qtime} contents = self.openendedmodule.payload.copy() contents.update({ @@ -224,7 +227,7 @@ class OpenEndedModuleTest(unittest.TestCase): 'student_response': submission, 'max_score': self.max_score }) - result = self.openendedmodule.send_to_grader(submission, test_system) + result = self.openendedmodule.send_to_grader(submission, self.test_system) self.assertTrue(result) self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY) @@ -238,7 +241,7 @@ class OpenEndedModuleTest(unittest.TestCase): } get = {'queuekey': "abcd", 'xqueue_body': score_msg} - self.openendedmodule.update_score(get, test_system) + self.openendedmodule.update_score(get, self.test_system) def update_score_single(self): self.openendedmodule.new_history_entry("New Entry") @@ -261,11 +264,11 @@ class OpenEndedModuleTest(unittest.TestCase): } get = {'queuekey': "abcd", 'xqueue_body': json.dumps(score_msg)} - self.openendedmodule.update_score(get, test_system) + self.openendedmodule.update_score(get, self.test_system) def test_latest_post_assessment(self): self.update_score_single() - assessment = self.openendedmodule.latest_post_assessment(test_system) + assessment = self.openendedmodule.latest_post_assessment(self.test_system) self.assertFalse(assessment == '') # check for errors self.assertFalse('errors' in assessment) @@ -336,7 +339,13 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): descriptor = Mock() def setUp(self): - self.combinedoe = CombinedOpenEndedV1Module(test_system, self.location, self.definition, self.descriptor, static_data = self.static_data, metadata=self.metadata) + self.test_system = test_system() + self.combinedoe = CombinedOpenEndedV1Module(self.test_system, + self.location, + self.definition, + self.descriptor, + static_data = self.static_data, + metadata=self.metadata) def test_get_tag_name(self): name = self.combinedoe.get_tag_name("Tag") diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 361a6ea785..16bd222b9e 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -56,6 +56,9 @@ class ConditionalModuleTest(unittest.TestCase): '''Get a dummy system''' return DummySystem(load_error_modules) + def setUp(self): + self.test_system = test_system() + def get_course(self, name): """Get a test course by directory name. If there's more than one, error.""" print "Importing {0}".format(name) @@ -85,14 +88,14 @@ class ConditionalModuleTest(unittest.TestCase): location = descriptor.location instance_state = instance_states.get(location.category, None) print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state) - return descriptor.xmodule_constructor(test_system)(instance_state, shared_state) + return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state) location = Location(["i4x", "edX", "cond_test", "conditional", "condone"]) def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): return text - test_system.replace_urls = replace_urls - test_system.get_module = inner_get_module + self.test_system.replace_urls = replace_urls + self.test_system.get_module = inner_get_module module = inner_get_module(location) print "module: ", module diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py index b9c3076b7c..362b73df67 100644 --- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py +++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py @@ -53,13 +53,13 @@ class SelfAssessmentTest(unittest.TestCase): 'skip_basic_checks' : False, } - self.module = SelfAssessmentModule(test_system, self.location, + self.module = SelfAssessmentModule(test_system(), self.location, self.definition, self.descriptor, static_data, state, metadata=self.metadata) def test_get_html(self): - html = self.module.get_html(test_system) + html = self.module.get_html(self.module.system) self.assertTrue("This is sample prompt text" in html) def test_self_assessment_flow(self): @@ -82,10 +82,11 @@ class SelfAssessmentTest(unittest.TestCase): self.assertEqual(self.module.get_score()['score'], 0) - self.module.save_answer({'student_answer': "I am an answer"}, test_system) + self.module.save_answer({'student_answer': "I am an answer"}, + self.module.system) self.assertEqual(self.module.state, self.module.ASSESSING) - self.module.save_assessment(mock_query_dict, test_system) + self.module.save_assessment(mock_query_dict, self.module.system) self.assertEqual(self.module.state, self.module.DONE) @@ -94,7 +95,8 @@ class SelfAssessmentTest(unittest.TestCase): self.assertEqual(self.module.state, self.module.INITIAL) # if we now assess as right, skip the REQUEST_HINT state - self.module.save_answer({'student_answer': 'answer 4'}, test_system) + self.module.save_answer({'student_answer': 'answer 4'}, + self.module.system) responses['assessment'] = '1' - self.module.save_assessment(mock_query_dict, test_system) + self.module.save_assessment(mock_query_dict, self.module.system) self.assertEqual(self.module.state, self.module.DONE) From c7d80a91eeff6875ba48096fa4cb92b06a3ebe40 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 5 Mar 2013 17:30:38 -0500 Subject: [PATCH 181/214] Changed name of survey_question to is_survey_question for clarity --- common/lib/xmodule/xmodule/capa_module.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index b42b7e4ccc..9ae6583c50 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -279,7 +279,7 @@ class CapaModule(XModule): """ Return True/False to indicate whether to show the "Reset" button. """ - survey_question = (self.max_attempts == 0) + is_survey_question = (self.max_attempts == 0) if self.rerandomize in ["always", "onreset"]: @@ -287,7 +287,7 @@ class CapaModule(XModule): # then do NOT show the reset button. # If the problem hasn't been submitted yet, then do NOT show # the reset button. - if (self.closed() and not survey_question) or not self.is_completed(): + if (self.closed() and not is_survey_question) or not self.is_completed(): return False else: return True @@ -307,14 +307,14 @@ class CapaModule(XModule): if self.force_save_button == "true": return not self.closed() else: - survey_question = (self.max_attempts == 0) + is_survey_question = (self.max_attempts == 0) needs_reset = self.is_completed() and self.rerandomize == "always" # If the problem is closed (and not a survey question with max_attempts==0), # then do NOT show the reset button # If we're waiting for the user to reset a randomized problem # then do NOT show the reset button - if (self.closed() and not survey_question) or needs_reset: + if (self.closed() and not is_survey_question) or needs_reset: return False else: return True From cde4cdf839ba7c04a74eadb4330ca08ea2a37a4f Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Tue, 5 Mar 2013 17:51:44 -0500 Subject: [PATCH 182/214] starting pdfbook doc --- doc/public/course_data_formats/course_xml.rst | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/doc/public/course_data_formats/course_xml.rst b/doc/public/course_data_formats/course_xml.rst index 51c5f2a872..fe25aa92f2 100644 --- a/doc/public/course_data_formats/course_xml.rst +++ b/doc/public/course_data_formats/course_xml.rst @@ -357,6 +357,8 @@ Supported fields at the course level * `cohorted_discussions`: list of discussions that should be cohorted. Any not specified in this list are not cohorted. * `auto_cohort`: Truthy. * `auto_cohort_groups`: `["group name 1", "group name 2", ...]` If `cohorted` and `auto_cohort` is true, automatically put each student into a random group from the `auto_cohort_groups` list, creating the group if needed. + * - `pdf_textbooks` + - have pdf-based textbooks on tabs in the courseware. See below for details on config. Available metadata @@ -508,13 +510,15 @@ If you want to customize the courseware tabs displayed for your course, specify "url_slug": "news", "name": "Exciting news" }, - {"type": "textbooks"} + {"type": "textbooks"}, + {"type": "pdf_textbooks"} ] * If you specify any tabs, you must specify all tabs. They will appear in the order given. * The first two tabs must have types `"courseware"` and `"course_info"`, in that order, or the course will not load. * The `courseware` tab never has a name attribute -- it's always rendered as "Courseware" for consistency between courses. * The `textbooks` tab will actually generate one tab per textbook, using the textbook titles as names. +* The `pdf_textbooks` tab will actually generate one tab per pdf_textbook. The tab name is found in the pdf textbook definition. * For static tabs, the `url_slug` will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist. * An Instructor tab will be automatically added at the end for course staff users. @@ -527,13 +531,15 @@ If you want to customize the courseware tabs displayed for your course, specify * - `course_info` - Parameter `name`. * - `wiki` - - arameter `name`. + - Parameter `name`. * - `discussion` - Parameter `name`. * - `external_link` - Parameters `name`, `link`. * - `textbooks` - No parameters--generates tab names from book titles. + * - `pdf_textbooks` + - No parameters--generates tab names from pdf book definition. (See discussion below for configuration.) * - `progress` - Parameter `name`. * - `static_tab` @@ -541,6 +547,39 @@ If you want to customize the courseware tabs displayed for your course, specify * - `staff_grading` - No parameters. If specified, displays the staff grading tab for instructors. +********* +Textbooks +********* +Support is currently provided for image-based and PDF-based textbooks. + +Image-based Textbooks +^^^^^^^^^^^^^^^^^^^^^ + +TBD. + +PDF-based Textbooks +^^^^^^^^^^^^^^^^^^^ + +PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view. + +.. code-block:: json + + "pdf_textbooks": [ + {"tab_title": "Textbook 1", + "url": "https://www.example.com/book1.pdf" }, + {"tab_title": "Textbook 2", + "chapters": [ + { "title": "Chapter 1", "url": "https://www.example.com/Chapter1.pdf" }, + { "title": "Chapter 2", "url": "https://www.example.com/Chapter2.pdf" }, + { "title": "Chapter 3", "url": "https://www.example.com/Chapter3.pdf" }, + { "title": "Chapter 4", "url": "https://www.example.com/Chapter4.pdf" }, + { "title": "Chapter 5", "url": "https://www.example.com/Chapter5.pdf" }, + { "title": "Chapter 6", "url": "https://www.example.com/Chapter6.pdf" }, + { "title": "Chapter 7", "url": "https://www.example.com/Chapter7.pdf" } + ] + } + ] + ************************************* Other file locations (info and about) ************************************* From 2ed7394f276e081502d346dc1acb051182919e4d Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Mar 2013 23:44:35 -0500 Subject: [PATCH 183/214] visually distinguish instructions from guided discussion section --- common/lib/xmodule/xmodule/css/annotatable/display.scss | 2 ++ lms/templates/annotatable.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 2db3b85e66..a8d7032e53 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -17,6 +17,8 @@ $body-font-size: em(14); border-radius: .5em; margin-bottom: .5em; + &.shaded { background-color: #EDEDED; } + .annotatable-section-title { font-weight: bold; a { font-weight: normal; } diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index af872b8c95..f010305744 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -6,7 +6,7 @@
    % if instructions_html is not UNDEFINED and instructions_html is not None: -
    +
    Instructions Collapse Instructions From 3393e6385dde8ca930754f39facf0c36b73ca81b Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Mar 2013 23:45:36 -0500 Subject: [PATCH 184/214] set a default height on the commentary textbox to show 5 lines of text --- common/lib/xmodule/xmodule/css/capa/display.scss | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 1b9261138b..f728401168 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -869,7 +869,14 @@ section.problem { .tag-status, .tag { padding: .25em .5em; } } } - textarea.comment { width: 100%; } + textarea.comment { + $num-lines-to-show: 5; + $line-height: 1.4em; + width: 100%; + padding: .375em .75em; + line-height: $line-height; + height: ($line-height * $num-lines-to-show) + .375em; + } .answer-annotation { display: block; margin: 0; } /* for debugging the input value field. enable the debug flag on the inputtype */ From 0424b53336bcfb1abc856549719633f1b784930a Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 5 Mar 2013 23:49:02 -0500 Subject: [PATCH 185/214] show the annotation callout at the span center rather than at the mouse --- .../xmodule/js/src/annotatable/display.coffee | 91 +++++++------------ 1 file changed, 31 insertions(+), 60 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 6a5aae81c5..2ad49ae6d7 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -1,7 +1,7 @@ class @Annotatable _debug: false - wrapperSelector: '.annotatable-wrapper' + # selectors for the annotatable xmodule toggleAnnotationsSelector: '.annotatable-toggle-annotations' toggleInstructionsSelector: '.annotatable-toggle-instructions' instructionsSelector: '.annotatable-instructions' @@ -9,6 +9,7 @@ class @Annotatable spanSelector: '.annotatable-span' replySelector: '.annotatable-reply' + # these selectors are for responding to events from the annotation capa problem type problemXModuleSelector: '.xmodule_CapaModule' problemSelector: 'section.problem' problemInputSelector: 'section.problem .annotation-input' @@ -17,6 +18,7 @@ class @Annotatable constructor: (el) -> console.log 'loaded Annotatable' if @_debug @el = el + @$el = $(el) @init() $: (selector) -> @@ -27,31 +29,30 @@ class @Annotatable @initTips() initEvents: () -> - # For handling hide/show of annotations and instructions - @annotationsHidden = false + # Initialize toggle handlers for the instructions and annotations sections + [@annotationsHidden, @instructionsHidden] = [false, false] @$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations - - @instructionsHidden = false @$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions - # For handling 'reply to annotation' events that scroll to the associated capa problem. - # These are contained in the tooltips, which should be rendered somewhere in the wrapper - # (see the qtip2 options, this must be set explicitly, otherwise they render in the body). - @$(@wrapperSelector).delegate @replySelector, 'click', @onClickReply + # Initialize handler for 'reply to annotation' events that scroll to + # the associated problem. The reply buttons are part of the tooltip + # content. It's important that the tooltips be configured to render + # as descendants of the annotation module and *not* the document.body. + @$el.delegate @replySelector, 'click', @onClickReply - # For handling 'return to annotation' events from capa problems. Assumes that: + # Initialize handler for 'return to annotation' events triggered from problems. # 1) There are annotationinput capa problems rendered on the page - # 2) Each one has an embedded "return to annotation" link (from the capa problem template). - # The capa problem's html is injected via AJAX so this just sets a listener on the body and - # handles the click event there. + # 2) Each one has an embedded return link (see annotation capa problem template). + # Since the capa problem injects HTML content via AJAX, the best we can do is + # is let the click events bubble up to the body and handle them there. $('body').delegate @problemReturnSelector, 'click', @onClickReturn initTips: () -> - @savedTips = [] + # tooltips are used to display annotations for highlighted text spans @$(@spanSelector).each (index, el) => - $(el).qtip(@getTipOptions el) + $(el).qtip(@getSpanTipOptions el) - getTipOptions: (el) -> + getSpanTipOptions: (el) -> content: title: text: @makeTipTitle(el) @@ -59,29 +60,21 @@ class @Annotatable position: my: 'bottom center' # of tooltip at: 'top center' # of target - target: 'mouse' - container: @$(@wrapperSelector) + target: $(el) # where the tooltip was triggered (i.e. the annotation span) + container: @$el adjust: - mouse: false # dont follow the mouse - y: -10 + y: -5 show: event: 'click mouseenter' solo: true hide: event: 'click mouseleave' - delay: 250, - fixed: true + delay: 500, + fixed: true # don't hide the tooltip if it is moused over style: classes: 'ui-tooltip-annotatable' events: show: @onShowTip - visible: @onVisibleTip - - onShowTip: (event, api) => - event.preventDefault() if @annotationsHidden - - onVisibleTip: (event, api) => - @constrainTipHorizontally(api.elements.tooltip, event.originalEvent.pageX) onClickToggleAnnotations: (e) => @toggleAnnotations() @@ -91,6 +84,9 @@ class @Annotatable onClickReturn: (e) => @returnFrom(e.currentTarget) + onShowTip: (event, api) => + event.preventDefault() if @annotationsHidden + getSpanForProblemReturn: (el) -> problem_id = $(@problemReturnSelector).index(el) @$(@spanSelector).filter("[data-problem-id='#{problem_id}']") @@ -109,7 +105,8 @@ class @Annotatable @toggleTips hide toggleTips: (hide) -> - if hide then @closeAndSaveTips() else @openSavedTips() + visible = @findVisibleTips() + @hideTips visible toggleAnnotationButtonText: (hide) -> buttonText = (if hide then 'Show' else 'Hide')+' Annotations' @@ -126,7 +123,8 @@ class @Annotatable @$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1]) toggleInstructionsText: (hide) -> - @$(@instructionsSelector)[if hide then 'slideUp' else 'slideDown']() + slideMethod = (if hide then 'slideUp' else 'slideDown') + @$(@instructionsSelector)[slideMethod]() toggleSpans: (hide) -> @$(@spanSelector).toggleClass 'hide', hide, 250 @@ -180,45 +178,18 @@ class @Annotatable createReplyLink: (problem_id) -> $("Reply to Annotation") - openSavedTips: () -> - @showTips @savedTips - - closeAndSaveTips: () -> - @savedTips = @findVisibleTips() - @hideTips @savedTips - findVisibleTips: () -> visible = [] @$(@spanSelector).each (index, el) -> api = $(el).qtip('api') tip = $(api?.elements.tooltip) if tip.is(':visible') - visible.push [el, tip.offset()] + visible.push el visible - hideTips: (pairs) -> - elements = (pair[0] for pair in pairs) + hideTips: (elements) -> $(elements).qtip('hide') - showTips: (pairs) -> - $.each pairs, (index, pair) -> - [el, offset] = pair - $(el).qtip('show') - api = $(el).qtip('api') - $(api?.elements.tooltip).offset(offset) - - constrainTipHorizontally: (tip, mouseX) -> - win_width = $(window).width() - tip_center = $(tip).width() / 2 # see position setting of tip - tip_offset = $(tip).offset() - - if (tip_center + mouseX) > win_width - adjust_left = '-=' + (tip_center + mouseX - win_width) - else if (mouseX - tip_center) < 0 - adjust_left = '+=' + (tip_center - mouseX) - - $(tip).animate({ left: adjust_left }) if adjust_left? - _once: (fn) -> done = false return => From 9545a82ab9402400908ee69de9334e132f0e998e Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 4 Mar 2013 15:48:20 -0500 Subject: [PATCH 186/214] Add the ability to make ajax calls on the input type of capa problems. --- common/lib/capa/capa/capa_problem.py | 27 ++++++++++++++++--- common/lib/capa/capa/inputtypes.py | 12 +++++++++ common/lib/xmodule/xmodule/capa_module.py | 2 ++ .../xmodule/js/src/capa/display.coffee | 8 ++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index fb0b63b83c..5e3b5627ab 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -146,6 +146,9 @@ class LoncapaProblem(object): if not self.student_answers: # True when student_answers is an empty dict self.set_initial_display() + self.extracted_tree = self._extract_html(self.tree) + + def do_reset(self): ''' Reset internal state to unfinished, with no answers @@ -324,7 +327,21 @@ class LoncapaProblem(object): ''' Main method called externally to get the HTML to be rendered for this capa Problem. ''' - return contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) + html = contextualize_text(etree.tostring(self.extracted_tree), self.context) + return html + + + def handle_input_ajax(self, get): + ''' + This passes any specialized input ajax onto the input class + + It also parses out the dispatch from the get so that it can be passed onto the input type nicely + ''' + if self.input: + dispatch = get['dispatch'] + return self.input.handle_ajax(dispatch, get) + return {} + # ======= Private Methods Below ======== @@ -458,6 +475,8 @@ class LoncapaProblem(object): finally: sys.path = original_path + + def _extract_html(self, problemtree): # private ''' Main (private) function which converts Problem XML tree to HTML. @@ -468,6 +487,7 @@ class LoncapaProblem(object): Used by get_html. ''' + if (problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type')): # leave javascript intact. @@ -505,8 +525,9 @@ class LoncapaProblem(object): 'hintmode': hintmode, }} input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) - the_input = input_type_cls(self.system, problemtree, state) - return the_input.get_html() + # save the input type so that we can make ajax calls on it if we need to + self.input = input_type_cls(self.system, problemtree, state) + return self.input.get_html() # let each Response render itself if problemtree in self.responders: diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 951104501a..1a141338b7 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -215,6 +215,18 @@ class InputTypeBase(object): """ pass + def handle_ajax(self, dispatch, get): + """ + InputTypes that need to handle specialized AJAX should override this. + + Input: + dispatch: a string that can be used to determine how to handle the data passed in + get: a dictionary containing the data that was sent with the ajax call + + Output: + a dictionary object that will then get sent back to the Javascript + """ + pass def _get_render_context(self): """ diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 9ae6583c50..7ab7b60239 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -412,6 +412,7 @@ class CapaModule(XModule): 'weight': self.descriptor.weight, } + context = {'problem': content, 'id': self.id, 'check_button': check_button, @@ -449,6 +450,7 @@ class CapaModule(XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, 'score_update': self.update_score, + 'input_ajax': self.lcp.handle_input_ajax } if dispatch not in handlers: diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 41c9b50891..07a96c8b02 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -76,6 +76,14 @@ class @Problem # TODO: Some logic to dynamically adjust polling rate based on queuelen window.queuePollerID = window.setTimeout(@poll, 1000) + + # Use this if you want to make an ajax call on the input type object + # static method so you don't have to instantiate a Problem in order to use it + @inputAjax: (url, dispatch, data, callback) -> + data['dispatch'] = dispatch + $.postWithPrefix "#{url}/input_ajax", data, callback + + render: (content) -> if content @el.html(content) From 6f535d9e0ba002126d9e5bb2270cf19be4a7da81 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 5 Mar 2013 09:20:40 -0500 Subject: [PATCH 187/214] Fix test rendering so we can parse the problem during tests without it breaking. --- common/lib/xmodule/xmodule/tests/__init__.py | 1 + common/lib/xmodule/xmodule/tests/test_conditional.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 43c2bbe24d..20f31315c4 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -18,6 +18,7 @@ import capa.calc as calc import xmodule from xmodule.x_module import ModuleSystem from mock import Mock +import xml.sax.saxutils as saxutils open_ended_grading_interface = { 'url': 'http://sandbox-grader-001.m.edx.org/peer_grading', diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 16bd222b9e..c3468905ad 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -106,7 +106,7 @@ class ConditionalModuleTest(unittest.TestCase): html = module.get_html() print "html type: ", type(html) print "html: ", html - html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}" + html_expect = "
    {'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}
    " self.assertEqual(html, html_expect) gdi = module.get_display_items() From 385a62d7d16e75b4125d717cf5360308f0b3d143 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 5 Mar 2013 11:11:55 -0500 Subject: [PATCH 188/214] Handle multiple inputs properly for ajax handling. --- common/lib/capa/capa/capa_problem.py | 18 +++++++++++++----- .../xmodule/xmodule/js/src/capa/display.coffee | 3 ++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 5e3b5627ab..a7a362d51e 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -146,6 +146,8 @@ class LoncapaProblem(object): if not self.student_answers: # True when student_answers is an empty dict self.set_initial_display() + self.inputs = {} + self.extracted_tree = self._extract_html(self.tree) @@ -337,10 +339,16 @@ class LoncapaProblem(object): It also parses out the dispatch from the get so that it can be passed onto the input type nicely ''' - if self.input: + + # pull out the id + problem_id = get['problem_id'] + if self.inputs[problem_id]: dispatch = get['dispatch'] - return self.input.handle_ajax(dispatch, get) - return {} + return self.inputs[problem_id].handle_ajax(dispatch, get) + else: + log.warning("Could not find matching input for id: %s" % problem_id) + return {} + # ======= Private Methods Below ======== @@ -526,8 +534,8 @@ class LoncapaProblem(object): input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) # save the input type so that we can make ajax calls on it if we need to - self.input = input_type_cls(self.system, problemtree, state) - return self.input.get_html() + self.inputs[problemid] = input_type_cls(self.system, problemtree, state) + return self.inputs[problemid].get_html() # let each Response render itself if problemtree in self.responders: diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 07a96c8b02..15211ca1bb 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -79,8 +79,9 @@ class @Problem # Use this if you want to make an ajax call on the input type object # static method so you don't have to instantiate a Problem in order to use it - @inputAjax: (url, dispatch, data, callback) -> + @inputAjax: (url, problem_id, dispatch, data, callback) -> data['dispatch'] = dispatch + data['problem_id'] = problem_id $.postWithPrefix "#{url}/input_ajax", data, callback From 9b640aa06b6b28cddfeebece6e116cec81e4dfb1 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 5 Mar 2013 13:24:38 -0500 Subject: [PATCH 189/214] Add more documentation and fix naming. --- common/lib/capa/capa/capa_problem.py | 20 +++++++++---------- common/lib/capa/capa/inputtypes.py | 2 +- .../xmodule/js/src/capa/display.coffee | 13 ++++++++++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index a7a362d51e..d6356c1585 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -335,16 +335,16 @@ class LoncapaProblem(object): def handle_input_ajax(self, get): ''' - This passes any specialized input ajax onto the input class + InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data - It also parses out the dispatch from the get so that it can be passed onto the input type nicely + Also, parse out the dispatch from the get so that it can be passed onto the input type nicely ''' # pull out the id - problem_id = get['problem_id'] - if self.inputs[problem_id]: + input_id = get['input_id'] + if self.inputs[input_id]: dispatch = get['dispatch'] - return self.inputs[problem_id].handle_ajax(dispatch, get) + return self.inputs[input_id].handle_ajax(dispatch, get) else: log.warning("Could not find matching input for id: %s" % problem_id) return {} @@ -512,8 +512,9 @@ class LoncapaProblem(object): msg = '' hint = '' hintmode = None + input_id = problemtree.get('id') if problemid in self.correct_map: - pid = problemtree.get('id') + pid = input_id status = self.correct_map.get_correctness(pid) msg = self.correct_map.get_msg(pid) hint = self.correct_map.get_hint(pid) @@ -524,18 +525,17 @@ class LoncapaProblem(object): value = self.student_answers[problemid] # do the rendering - state = {'value': value, 'status': status, - 'id': problemtree.get('id'), + 'id': input_id, 'feedback': {'message': msg, 'hint': hint, 'hintmode': hintmode, }} input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) # save the input type so that we can make ajax calls on it if we need to - self.inputs[problemid] = input_type_cls(self.system, problemtree, state) - return self.inputs[problemid].get_html() + self.inputs[input_id] = input_type_cls(self.system, problemtree, state) + return self.inputs[input_id].get_html() # let each Response render itself if problemtree in self.responders: diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 1a141338b7..1d6c340f37 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -224,7 +224,7 @@ class InputTypeBase(object): get: a dictionary containing the data that was sent with the ajax call Output: - a dictionary object that will then get sent back to the Javascript + a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. """ pass diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 15211ca1bb..158c2b98d0 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -79,9 +79,18 @@ class @Problem # Use this if you want to make an ajax call on the input type object # static method so you don't have to instantiate a Problem in order to use it - @inputAjax: (url, problem_id, dispatch, data, callback) -> + # Input: + # url: the AJAX url of the problem + # input_id: the input_id of the input you would like to make the call on + # NOTE: the id is the ${id} part of "input_${id}" during rendering + # If this function is passed the entire prefixed id, the backend may have trouble + # finding the correct input + # dispatch: string that indicates how this data should be handled by the inputtype + # callback: the function that will be called once the AJAX call has been completed. + # It will be passed a response object + @inputAjax: (url, input_id, dispatch, data, callback) -> data['dispatch'] = dispatch - data['problem_id'] = problem_id + data['input_id'] = input_id $.postWithPrefix "#{url}/input_ajax", data, callback From 17680a332a155374e4212cb8285203cc43064a4c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 5 Mar 2013 13:26:53 -0500 Subject: [PATCH 190/214] More comments. --- common/lib/capa/capa/capa_problem.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index d6356c1585..62751cd833 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -146,6 +146,8 @@ class LoncapaProblem(object): if not self.student_answers: # True when student_answers is an empty dict self.set_initial_display() + # dictionary of InputType objects associated with this problem + # input_id string -> InputType object self.inputs = {} self.extracted_tree = self._extract_html(self.tree) From 78424e167080918f993c449c7ac8009234f13447 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 6 Mar 2013 09:19:22 -0500 Subject: [PATCH 191/214] Fix some of the tests so that they work with the new changes to capa_problem --- common/lib/xmodule/xmodule/tests/test_capa_module.py | 5 ++++- common/lib/xmodule/xmodule/tests/test_conditional.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 0e64e740fd..a1e3d31d76 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -108,7 +108,9 @@ class CapaFactory(object): else: instance_state = None - module = CapaModule(test_system(), location, + system = test_system() + system.render_template = Mock(return_value="
    Test Template HTML
    ") + module = CapaModule(system, location, definition, descriptor, instance_state, None, metadata=metadata) @@ -185,6 +187,7 @@ class CapaModuleTest(unittest.TestCase): max_attempts="1", attempts="0", due=self.yesterday_str) + self.assertTrue(after_due_date.answer_available()) diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index c3468905ad..16bd222b9e 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -106,7 +106,7 @@ class ConditionalModuleTest(unittest.TestCase): html = module.get_html() print "html type: ", type(html) print "html: ", html - html_expect = "
    {'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}
    " + html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}" self.assertEqual(html, html_expect) gdi = module.get_display_items() From 455dea870f06657daba21787e412dca185a2e3a8 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 6 Mar 2013 09:21:37 -0500 Subject: [PATCH 192/214] Remove unnecessary import. --- common/lib/xmodule/xmodule/tests/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 20f31315c4..43c2bbe24d 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -18,7 +18,6 @@ import capa.calc as calc import xmodule from xmodule.x_module import ModuleSystem from mock import Mock -import xml.sax.saxutils as saxutils open_ended_grading_interface = { 'url': 'http://sandbox-grader-001.m.edx.org/peer_grading', From 72411a175fadbde84576c072fc76e67ef14f7cc4 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 6 Mar 2013 09:41:26 -0500 Subject: [PATCH 193/214] Added jnater's unit tests, cleaned up broken tests, ran pep8fix --- .../courseware/tests/test_grades.py | 161 +++++++++ lms/djangoapps/courseware/tests/test_tabs.py | 334 ++++++++++++++++++ .../tests/test_models.py | 55 +++ .../tests/test_mustache_helpers.py | 48 ++- .../tests/test_permissions.py | 130 +++++++ 5 files changed, 711 insertions(+), 17 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/test_grades.py create mode 100644 lms/djangoapps/courseware/tests/test_tabs.py create mode 100644 lms/djangoapps/django_comment_client/tests/test_models.py create mode 100644 lms/djangoapps/django_comment_client/tests/test_permissions.py diff --git a/lms/djangoapps/courseware/tests/test_grades.py b/lms/djangoapps/courseware/tests/test_grades.py new file mode 100644 index 0000000000..cc54064ac6 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_grades.py @@ -0,0 +1,161 @@ +from django.test import TestCase +from courseware import grades +from mock import MagicMock + +# from __future__ import division + +# import random +# import logging + +from collections import defaultdict +from django.conf import settings +from django.contrib.auth.models import User + +# from models import StudentModuleCache +from courseware.module_render import get_module as get_module +from courseware.module_render import get_instance_module as get_instance_module +# from xmodule import graders +from xmodule.capa_module import CapaModule +# from xmodule.course_module import CourseDescriptor +# from xmodule.graders import Score +# from models import StudentModule + + +class test_grades(TestCase): + + def test_yield_module_descendents(self): + mock_module = MagicMock() + a = MagicMock() + b = MagicMock() + c = MagicMock() + z = MagicMock() + y = MagicMock() + mock_module.get_display_items.return_value = [a, b, c] + a.get_display_items.return_value = [y, z] + b.get_display_items.return_value = [] + c.get_display_items.return_value = [] + z.get_display_items.return_value = [] + y.get_display_items.return_value = [] + dummy = list(grades.yield_module_descendents(mock_module)) + self.assertEqual(dummy, [a,z,y,b, c]) + + def test_yield_dynamic_descriptor_descendents(self): + descriptor_true_mock = MagicMock() + a = MagicMock() + b = MagicMock() + b.has_dynamic_children.return_value = False + b.get_children.return_value = 'b' + c = MagicMock() + c.has_dynamic_children.return_value = False + c.get_children.return_value = 'c' + e = MagicMock() + e.has_dynamic_children.return_value = False + e.get_children.return_value = None + + descriptor_true_mock.return_value = a + descriptor_true_mock.has_dynamic_children.return_value = True + module_creator_mock = MagicMock() + module_mock = MagicMock() + module_creator_mock(descriptor_true_mock).return_value = module_mock + child_locations_mock = MagicMock() + module_mock.get_children_locations.__iter__.return_value = [b, c] + print descriptor_true_mock.system.load_item(b) + + + descriptor_true_mock.system.load_item(b).return_value = b + descriptor_true_mock.system.load_item(c).return_value = c + + descriptor_false_mock = MagicMock() + descriptor_false_mock.has_dynamic_children.return_value = False + descriptor_false_mock.get_children.return_value = e + + true_descriptor_children_list = [descriptor_true_mock] + self.assertEqual(list(grades.yield_dynamic_descriptor_descendents(descriptor_true_mock, module_creator_mock)),true_descriptor_children_list) + self.assertEqual(list(grades.yield_dynamic_descriptor_descendents(descriptor_false_mock, module_creator_mock)),[descriptor_false_mock]) + + def test_yield_problems(self): + course_mock = MagicMock() + # course = course_mock + grading_context_mock = MagicMock() + # mock for grading context + course_mock.grading_context.return_value = grading_context_mock + + # mock for course.id + course_id_mock = MagicMock() + course_mock.id.return_value = course_id_mock + + # mock for student + student_mock = MagicMock() + student = student_mock() + + grading_context_mock['all_descriptors'] = MagicMock() + sec_form1 = MagicMock() + sec_form2 = MagicMock() + sec1 = MagicMock() + sec1['section_descriptor'].return_value = "sec1 descriptor" + sec2 = MagicMock() + sec2['section_descriptor'].return_value = "sec2 descriptor" + sec3 = MagicMock() + sec3['section_descriptor'].return_value = "sec3 descriptor" + sec4 = MagicMock() + sec4['section_descriptor'].return_value = "sec4 descriptor" + grading_context_mock['all_descriptors'].__iter__.return_value = [(sec_form1, [sec1, sec2]), (sec_form2, [sec3, sec4])] + StudentModuleCache_mock = MagicMock() + student_module_cache_mock = MagicMock() + StudentModuleCache_mock(course_id_mock, student_mock, grading_context_mock['all_descriptors']).return_value = student_module_cache_mock + + sec1_xmod = MagicMock() + sec2_xmod = MagicMock() + sec3_xmod = MagicMock() + sec4_xmod = MagicMock() + sec1['xmoduledescriptors'].return_value = [sec1_xmod] + sec2['xmoduledescriptors'].return_value = [sec2_xmod] + sec3['xmoduledescriptors'].return_value = [sec3_xmod] + sec4['xmoduledescriptors'].return_value = [sec4_xmod] + sec1_xmod_category = MagicMock() + sec2_xmod_category = MagicMock() + sec3_xmod_category = MagicMock() + sec4_xmod_category = MagicMock() + sec1_xmod.category.return_value = sec1_xmod_category + sec2_xmod.category.return_value = sec2_xmod_category + sec3_xmod.category.return_value = sec3_xmod_category + sec4_xmod.category.return_value = sec4_xmod_category + sec1_xmod_location_url = MagicMock() + sec2_xmod_location_url = MagicMock() + sec3_xmod_location_url = MagicMock() + sec4_xmod_location_url = MagicMock() + sec1_xmod.location.url.return_value = sec1_xmod_location_url + sec2_xmod.location.url.return_value = sec2_xmod_location_url + sec3_xmod.location.url.return_value = sec3_xmod_location_url + sec4_xmod.location.url.return_value = sec4_xmod_location_url + student_module_cache_mock.lookup(course_id_mock, sec1_xmod, sec1_xmod.location.url()).return_value = True + student_module_cache_mock.lookup(course_id_mock, sec2_xmod, sec2_xmod.location.url()).return_value = True + student_module_cache_mock.lookup(course_id_mock, sec3_xmod, sec3_xmod.location.url()).return_value = False + student_module_cache_mock.lookup(course_id_mock, sec4_xmod, sec4_xmod.location.url()).return_value = False + + student_mock = MagicMock() + request_mock = MagicMock() + sec1_module_mock = MagicMock() + sec2_module_mock = MagicMock() + sec3_module_mock = MagicMock() + sec4_module_mock = MagicMock() + get_module_mock = MagicMock() + get_module_mock(student_mock, request_mock, sec1_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec1_module_mock + get_module_mock(student_mock, request_mock, sec2_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec2_module_mock + get_module_mock(student_mock, request_mock, sec3_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec3_module_mock + get_module_mock(student_mock, request_mock, sec4_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec4_module_mock + prob1 = MagicMock() + prob2 = MagicMock() + prob3 = MagicMock() + prob4 = MagicMock() + prob5 = MagicMock() + prob6 = MagicMock() + prob7 = MagicMock() + prob8 = MagicMock() + yield_module_descendents_mock = MagicMock() + yield_module_descendents_mock(sec1_module_mock).return_value = [prob1, prob2] + yield_module_descendents_mock(sec2_module_mock).return_value = [prob3, prob4] + yield_module_descendents_mock(sec3_module_mock).return_value = [prob5, prob6] + yield_module_descendents_mock(sec4_module_mock).return_value = [prob7, prob8] + + self.assertEqual(list(grades.yield_problems(request_mock, course_mock, student_mock)), []) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py new file mode 100644 index 0000000000..e005ff4d35 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -0,0 +1,334 @@ +from django.test import TestCase +from mock import patch, MagicMock + +import courseware.tabs as tabs +from courseware.access import has_access + +from django.test.utils import override_settings + +from collections import namedtuple +import logging + +from django.conf import settings +from django.core.urlresolvers import reverse + +from fs.errors import ResourceNotFoundError + +from courseware.access import has_access +from xmodule.error_module import ErrorDescriptor +from xmodule.x_module import XModule, XModuleDescriptor +from xmodule.modulestore import Location + +############################################################################### + + +class ProgressTestCase(TestCase): + + def setUp(self): + + self.mockuser1 = MagicMock() + self.mockuser0 = MagicMock() + self.course = MagicMock() + self.mockuser1.is_authenticated.return_value = True + self.mockuser0.is_authenticated.return_value = False + self.course.id = 'edX/full/6.002_Spring_2012' + self.tab = {'name': 'same'} + self.active_page1 = 'progress' + self.active_page0 = 'stagnation' + + def test_progress(self): + + self.assertEqual(tabs._progress(self.tab, self.mockuser0, self.course, + self.active_page0), + []) + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page1)[0].name, + 'same') + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page1)[0].link, + reverse('progress', args = [self.course.id])) + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page0)[0].is_active, + False) + + self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, + self.active_page1)[0].is_active, + True) + +############################################################################### + + +class WikiTestCase(TestCase): + + def setUp(self): + + self.user = MagicMock() + self.course = MagicMock() + self.course.id = 'edX/full/6.002_Spring_2012' + self.tab = {'name': 'same'} + self.active_page1 = 'wiki' + self.active_page0 = 'miki' + + @override_settings(WIKI_ENABLED=True) + def test_wiki_enabled(self): + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1)[0].name, + 'same') + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1)[0].link, + reverse('course_wiki', args=[self.course.id])) + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1)[0].is_active, + True) + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page0)[0].is_active, + False) + + @override_settings(WIKI_ENABLED=False) + def test_wiki_enabled_false(self): + + self.assertEqual(tabs._wiki(self.tab, self.user, + self.course, self.active_page1), + []) + +############################################################################### + + +class ExternalLinkTestCase(TestCase): + + def setUp(self): + + self.user = MagicMock() + self.course = MagicMock() + self.tabby = {'name': 'same', 'link': 'blink'} + self.active_page0 = None + self.active_page00 = True + + def test_external_link(self): + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page0)[0].name, + 'same') + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page0)[0].link, + 'blink') + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page0)[0].is_active, + False) + + self.assertEqual(tabs._external_link(self.tabby, self.user, + self.course, self.active_page00)[0].is_active, + False) + +############################################################################### + + +class StaticTabTestCase(TestCase): + + def setUp(self): + + self.user = MagicMock() + self.course = MagicMock() + self.tabby = {'name': 'same', 'url_slug': 'schmug'} + self.course.id = 'edX/full/6.002_Spring_2012' + self.active_page1 = 'static_tab_schmug' + self.active_page0 = 'static_tab_schlug' + + def test_static_tab(self): + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page1)[0].name, + 'same') + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page1)[0].link, + reverse('static_tab', args = [self.course.id, + self.tabby['url_slug']])) + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page1)[0].is_active, + True) + + + self.assertEqual(tabs._static_tab(self.tabby, self.user, + self.course, self.active_page0)[0].is_active, + False) + +############################################################################### + + +class TextbooksTestCase(TestCase): + + def setUp(self): + + self.mockuser1 = MagicMock() + self.mockuser0 = MagicMock() + self.course = MagicMock() + self.tab = MagicMock() + A = MagicMock() + T = MagicMock() + self.mockuser1.is_authenticated.return_value = True + self.mockuser0.is_authenticated.return_value = False + self.course.id = 'edX/full/6.002_Spring_2012' + self.active_page0 = 'textbook/0' + self.active_page1 = 'textbook/1' + self.active_pageX = 'you_shouldnt_be_seein_this' + A.title = 'Algebra' + T.title = 'Topology' + self.course.textbooks = [A, T] + + @override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': True}) + def test_textbooks1(self): + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page0)[0].name, + 'Algebra') + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page0)[0].link, + reverse('book', args=[self.course.id, 0])) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page0)[0].is_active, + True) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_pageX)[0].is_active, + False) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page1)[1].name, + 'Topology') + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page1)[1].link, + reverse('book', args=[self.course.id, 1])) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_page1)[1].is_active, + True) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_pageX)[1].is_active, + False) + + @override_settings(MITX_FEATURES={'ENABLE_TEXTBOOK': False}) + def test_textbooks0(self): + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser1, + self.course, self.active_pageX), []) + + self.assertEqual(tabs._textbooks(self.tab, self.mockuser0, + self.course, self.active_pageX), []) + +############################################################################### +# +#class StaffGradingTestCase(TestCase): +# +# def setUp(self): +# +# self.tab = MagicMock() +# self.user = MagicMock() +# self.course = MagicMock() +# self.active_page1 = 'staff_grading' +# self.activate_page0 = 'shadowfax' +# self.course.id = 'edX/full/6.002_Spring_2012' +# self.link = reverse('staff_grading', args = [self.course.id]) +# +# +# def test_staff_grading(self): +# +# self.assertEqual(tabs._staff_grading(self.tab, self.user, +# self.course, self.active_page1)[0].name, +# 'Staff grading') +# +# self.assertEqual(tabs._staff_grading(self.tab, self.user, +# self.course, self.active_page1)[0].link, +# self.link) +# +# self.assertEqual(tabs._staff_grading(self.tab, self.user, +# self.course, self.active_page1)[0].is_active, +# True) +# +# +############################################################################### + + +class KeyCheckerTestCase(TestCase): + + def setUp(self): + + self.expected_keys1 = ['a', 'b'] + self.expected_keys0 = ['a', 'v', 'g'] + self.dictio = {'a': 1, 'b': 2, 'c': 3} + + def test_key_checker(self): + + self.assertIsNone(tabs.key_checker(self.expected_keys1)(self.dictio)) + self.assertRaises(tabs.InvalidTabsException, + tabs.key_checker(self.expected_keys0), self.dictio) + + +############################################################################### + +class NullValidatorTestCase(TestCase): + + def setUp(self): + + self.d = {} + + def test_null_validator(self): + + self.assertIsNone(tabs.null_validator(self.d)) + +############################################################################### + + +class ValidateTabsTestCase(TestCase): + + def setUp(self): + + self.course0 = MagicMock() + self.course1 = MagicMock() + self.course2 = MagicMock() + self.course3 = MagicMock() + self.course4 = MagicMock() + self.course5 = MagicMock() + self.course0.tabs = None + self.course1.tabs = [{'type':'courseware'}, {'type': 'fax'}] + self.course2.tabs = [{'type':'shadow'}, {'type': 'course_info'}] + self.course3.tabs = [{'type': 'set'}] + self.course4.tabs = [{'type':'courseware'},{'type':'course_info', 'name': 'alice'}, + {'type': 'wiki', 'name':'alice'}, {'type':'discussion', 'name': 'alice'}, + {'type':'external_link', 'name': 'alice', 'link':'blink'}, + {'type':'textbooks'}, {'type':'progress', 'name': 'alice'}, + {'type':'static_tab', 'name':'alice', 'url_slug':'schlug'}, + {'type': 'staff_grading'}] + + self.course5.tabs = [{'type':'courseware'},{'type': 'course_info'}, {'type': 'flying'}] + + + def test_validate_tabs(self): + + self.assertIsNone(tabs.validate_tabs(self.course0)) + + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course3) + + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course2) + + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course1) + + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course5) + + self.assertIsNone(tabs.validate_tabs(self.course4)) + + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course5) diff --git a/lms/djangoapps/django_comment_client/tests/test_models.py b/lms/djangoapps/django_comment_client/tests/test_models.py new file mode 100644 index 0000000000..6f90b3c4b8 --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/test_models.py @@ -0,0 +1,55 @@ +import django_comment_client.models as models +import django_comment_client.permissions as permissions +from django.test import TestCase + + +class RoleClassTestCase(TestCase): + def setUp(self): + # For course ID, syntax edx/classname/classdate is important + # because xmodel.course_module.id_to_location looks for a string to split + + self.course_id = "edX/toy/2012_Fall" + self.student_role = models.Role.objects.get_or_create(name="Student", \ + course_id=self.course_id)[0] + self.student_role.add_permission("delete_thread") + self.student_2_role = models.Role.objects.get_or_create(name="Student", \ + course_id=self.course_id)[0] + self.TA_role = models.Role.objects.get_or_create(name="Community TA",\ + course_id=self.course_id)[0] + self.course_id_2 = "edx/6.002x/2012_Fall" + self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",\ + course_id=self.course_id_2)[0] + class Dummy(): + def render_template(): + pass + d = {"data": { + "textbooks": [], + 'wiki_slug': True, + } + } + + def testHasPermission(self): + # Whenever you add a permission to student_role, + # Roles with the same FORUM_ROLE in same class also receives the same + # permission. + # Is this desirable behavior? + self.assertTrue(self.student_role.has_permission("delete_thread")) + self.assertTrue(self.student_2_role.has_permission("delete_thread")) + self.assertFalse(self.TA_role.has_permission("delete_thread")) + + def testInheritPermissions(self): + + self.TA_role.inherit_permissions(self.student_role) + self.assertTrue(self.TA_role.has_permission("delete_thread")) + # Despite being from 2 different courses, TA_role_2 can still inherit + # permissions from TA_role without error + self.TA_role_2.inherit_permissions(self.TA_role) + + +class PermissionClassTestCase(TestCase): + + def setUp(self): + self.permission = permissions.Permission.objects.get_or_create(name="test")[0] + + def testUnicode(self): + self.assertEqual(str(self.permission), "test") diff --git a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py index 5b788b3cc4..7db3ba6e86 100644 --- a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py +++ b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py @@ -3,26 +3,40 @@ import random import collections from django.test import TestCase +from mock import MagicMock +from django.test.utils import override_settings +import django.core.urlresolvers as urlresolvers import django_comment_client.mustache_helpers as mustache_helpers - -class PluralizeTestCase(TestCase): - - def test_pluralize(self): - self.text1 = '0 goat' - self.text2 = '1 goat' - self.text3 = '7 goat' - self.content = 'unused argument' - self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats') - self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat') - self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats') +######################################################################################### -class CloseThreadTextTestCase(TestCase): +class PluralizeTest(TestCase): + + def setUp(self): + self.text1 = '0 goat' + self.text2 = '1 goat' + self.text3 = '7 goat' + self.content = 'unused argument' + + def test_pluralize(self): + self.assertEqual(mustache_helpers.pluralize(self.content, self.text1), 'goats') + self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat') + self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats') + +######################################################################################### + + +class CloseThreadTextTest(TestCase): + + def setUp(self): + self.contentClosed = {'closed': True} + self.contentOpen = {'closed': False} + + def test_close_thread_text(self): + self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread') + self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') + +######################################################################################### - def test_close_thread_text(self): - self.contentClosed = {'closed': True} - self.contentOpen = {'closed': False} - self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread') - self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') diff --git a/lms/djangoapps/django_comment_client/tests/test_permissions.py b/lms/djangoapps/django_comment_client/tests/test_permissions.py new file mode 100644 index 0000000000..b79d3a5194 --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/test_permissions.py @@ -0,0 +1,130 @@ +import string +import random +import collections + +from django.contrib.auth.models import User +from django.test import TestCase + +import django_comment_client.models as models + +import student.models + +import django_comment_client.permissions as permissions + +############################################################################### + + +class PermissionsTestCase(TestCase): + def random_str(self, length=15, chars=string.ascii_uppercase + string.digits): + return ''.join(random.choice(chars) for x in range(length)) + + def setUp(self): + + self.course_id = "edX/toy/2012_Fall" + + self.moderator_role = models.Role.objects.get_or_create(name="Moderator", + course_id=self.course_id)[0] + self.student_role = models.Role.objects.get_or_create(name="Student", + course_id=self.course_id)[0] + + self.student = User.objects.create(username=self.random_str(), + password="123456", email="john@yahoo.com") + self.moderator = User.objects.create(username=self.random_str(), + password="123456", email="staff@edx.org") + self.moderator.is_staff = True + self.moderator.save() + self.student_enrollment = student.models.CourseEnrollment.objects.create(user=self.student, + course_id=self.course_id) + self.moderator_enrollment = student.models.CourseEnrollment.objects.create(user=self.moderator, + course_id=self.course_id) + #Fake json files + self.empty_data = {"content": { + } + } + self.open_data = {"content": { + "closed": False, + "user_id": str(self.student.id) + } + } + self.closed_data = {"content": { + "closed": True, + "user_id": str(self.student.id) + } + } + + def tearDown(self): + self.student_enrollment.delete() + self.moderator_enrollment.delete() + +# Do we need to have this? We shouldn't be deleting students, ever +# self.student.delete() +# self.moderator.delete() + + + def testDefaultRoles(self): + self.assertTrue(self.student_role in self.student.roles.all()) + self.assertTrue(self.moderator_role in self.moderator.roles.all()) + + def testPermission(self): + name = self.random_str() + self.moderator_role.add_permission(name) + self.assertTrue(permissions.has_permission(self.moderator, name, self.course_id)) + # Moderators do not have student priveleges unless explicitly added + + self.student_role.add_permission(name) + self.assertTrue(permissions.has_permission(self.student, name, self.course_id)) + + # Students don't have moderator priveleges + name2 = self.random_str() + self.student_role.add_permission(name2) + self.assertFalse(permissions.has_permission(self.moderator, name2, self.course_id)) + + def testCachedPermission(self): + # Cache miss returns None + # Don't really understand how this works? What's in Cache? + self.assertFalse(permissions.cached_has_permission(self.student, self.moderator, + course_id=None)) + self.assertFalse(permissions.cached_has_permission(self.student, "update_thread", + course_id=None)) + + def testCheckCondition(self): + # Checks whether something? is open, or whether the author is user + self.assertFalse(permissions.check_condition(self.student, 'is_open', + self.course_id, self.empty_data)) + self.assertFalse(permissions.check_condition(self.student, 'is_author', + self.course_id, self.empty_data)) + self.assertTrue(permissions.check_condition(self.student, 'is_open', + self.course_id, self.open_data)) + self.assertTrue(permissions.check_condition(self.student, 'is_author', + self.course_id, self.open_data)) + self.assertFalse(permissions.check_condition(self.student,'is_open', + self.course_id, self.closed_data)) + + def testCheckConditionsPermissions(self): + #Function does not seem to return True + self.assertFalse(permissions.check_conditions_permissions(self.student, 'is_open', + self.course_id, + data=self.open_data)) + self.assertFalse(permissions.check_conditions_permissions(self.student, 'is_open', + self.course_id, + data=self.empty_data)) + + self.assertFalse(permissions.check_conditions_permissions(self.student, + ['is_open', 'is_author'], + self.course_id, + data=self.open_data)) + self.assertFalse(permissions.check_conditions_permissions(self.student, + ['is_open', 'is_author'], + self.course_id, + data=self.open_data, + operator='and')) + self.assertFalse(permissions.check_conditions_permissions(self.student, 'update_thread', + self.course_id, data=self.open_data)) + + def testCheckPermissionsByView(self): + # kwargs is the data entered in check_condition, which is json? + self.assertRaises(UnboundLocalError, permissions.check_permissions_by_view, + self.student, self.course_id, self.empty_data, + "nonexistant") + self.assertFalse(permissions.check_permissions_by_view(self.student,self.course_id, + self.empty_data, 'update_thread')) From 0d1b3800089406f405c2856daadf74a7e231091a Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 6 Mar 2013 09:44:53 -0500 Subject: [PATCH 194/214] Removed test_grades.py in courseware django app. --- .../courseware/tests/test_grades.py | 161 ------------------ 1 file changed, 161 deletions(-) delete mode 100644 lms/djangoapps/courseware/tests/test_grades.py diff --git a/lms/djangoapps/courseware/tests/test_grades.py b/lms/djangoapps/courseware/tests/test_grades.py deleted file mode 100644 index cc54064ac6..0000000000 --- a/lms/djangoapps/courseware/tests/test_grades.py +++ /dev/null @@ -1,161 +0,0 @@ -from django.test import TestCase -from courseware import grades -from mock import MagicMock - -# from __future__ import division - -# import random -# import logging - -from collections import defaultdict -from django.conf import settings -from django.contrib.auth.models import User - -# from models import StudentModuleCache -from courseware.module_render import get_module as get_module -from courseware.module_render import get_instance_module as get_instance_module -# from xmodule import graders -from xmodule.capa_module import CapaModule -# from xmodule.course_module import CourseDescriptor -# from xmodule.graders import Score -# from models import StudentModule - - -class test_grades(TestCase): - - def test_yield_module_descendents(self): - mock_module = MagicMock() - a = MagicMock() - b = MagicMock() - c = MagicMock() - z = MagicMock() - y = MagicMock() - mock_module.get_display_items.return_value = [a, b, c] - a.get_display_items.return_value = [y, z] - b.get_display_items.return_value = [] - c.get_display_items.return_value = [] - z.get_display_items.return_value = [] - y.get_display_items.return_value = [] - dummy = list(grades.yield_module_descendents(mock_module)) - self.assertEqual(dummy, [a,z,y,b, c]) - - def test_yield_dynamic_descriptor_descendents(self): - descriptor_true_mock = MagicMock() - a = MagicMock() - b = MagicMock() - b.has_dynamic_children.return_value = False - b.get_children.return_value = 'b' - c = MagicMock() - c.has_dynamic_children.return_value = False - c.get_children.return_value = 'c' - e = MagicMock() - e.has_dynamic_children.return_value = False - e.get_children.return_value = None - - descriptor_true_mock.return_value = a - descriptor_true_mock.has_dynamic_children.return_value = True - module_creator_mock = MagicMock() - module_mock = MagicMock() - module_creator_mock(descriptor_true_mock).return_value = module_mock - child_locations_mock = MagicMock() - module_mock.get_children_locations.__iter__.return_value = [b, c] - print descriptor_true_mock.system.load_item(b) - - - descriptor_true_mock.system.load_item(b).return_value = b - descriptor_true_mock.system.load_item(c).return_value = c - - descriptor_false_mock = MagicMock() - descriptor_false_mock.has_dynamic_children.return_value = False - descriptor_false_mock.get_children.return_value = e - - true_descriptor_children_list = [descriptor_true_mock] - self.assertEqual(list(grades.yield_dynamic_descriptor_descendents(descriptor_true_mock, module_creator_mock)),true_descriptor_children_list) - self.assertEqual(list(grades.yield_dynamic_descriptor_descendents(descriptor_false_mock, module_creator_mock)),[descriptor_false_mock]) - - def test_yield_problems(self): - course_mock = MagicMock() - # course = course_mock - grading_context_mock = MagicMock() - # mock for grading context - course_mock.grading_context.return_value = grading_context_mock - - # mock for course.id - course_id_mock = MagicMock() - course_mock.id.return_value = course_id_mock - - # mock for student - student_mock = MagicMock() - student = student_mock() - - grading_context_mock['all_descriptors'] = MagicMock() - sec_form1 = MagicMock() - sec_form2 = MagicMock() - sec1 = MagicMock() - sec1['section_descriptor'].return_value = "sec1 descriptor" - sec2 = MagicMock() - sec2['section_descriptor'].return_value = "sec2 descriptor" - sec3 = MagicMock() - sec3['section_descriptor'].return_value = "sec3 descriptor" - sec4 = MagicMock() - sec4['section_descriptor'].return_value = "sec4 descriptor" - grading_context_mock['all_descriptors'].__iter__.return_value = [(sec_form1, [sec1, sec2]), (sec_form2, [sec3, sec4])] - StudentModuleCache_mock = MagicMock() - student_module_cache_mock = MagicMock() - StudentModuleCache_mock(course_id_mock, student_mock, grading_context_mock['all_descriptors']).return_value = student_module_cache_mock - - sec1_xmod = MagicMock() - sec2_xmod = MagicMock() - sec3_xmod = MagicMock() - sec4_xmod = MagicMock() - sec1['xmoduledescriptors'].return_value = [sec1_xmod] - sec2['xmoduledescriptors'].return_value = [sec2_xmod] - sec3['xmoduledescriptors'].return_value = [sec3_xmod] - sec4['xmoduledescriptors'].return_value = [sec4_xmod] - sec1_xmod_category = MagicMock() - sec2_xmod_category = MagicMock() - sec3_xmod_category = MagicMock() - sec4_xmod_category = MagicMock() - sec1_xmod.category.return_value = sec1_xmod_category - sec2_xmod.category.return_value = sec2_xmod_category - sec3_xmod.category.return_value = sec3_xmod_category - sec4_xmod.category.return_value = sec4_xmod_category - sec1_xmod_location_url = MagicMock() - sec2_xmod_location_url = MagicMock() - sec3_xmod_location_url = MagicMock() - sec4_xmod_location_url = MagicMock() - sec1_xmod.location.url.return_value = sec1_xmod_location_url - sec2_xmod.location.url.return_value = sec2_xmod_location_url - sec3_xmod.location.url.return_value = sec3_xmod_location_url - sec4_xmod.location.url.return_value = sec4_xmod_location_url - student_module_cache_mock.lookup(course_id_mock, sec1_xmod, sec1_xmod.location.url()).return_value = True - student_module_cache_mock.lookup(course_id_mock, sec2_xmod, sec2_xmod.location.url()).return_value = True - student_module_cache_mock.lookup(course_id_mock, sec3_xmod, sec3_xmod.location.url()).return_value = False - student_module_cache_mock.lookup(course_id_mock, sec4_xmod, sec4_xmod.location.url()).return_value = False - - student_mock = MagicMock() - request_mock = MagicMock() - sec1_module_mock = MagicMock() - sec2_module_mock = MagicMock() - sec3_module_mock = MagicMock() - sec4_module_mock = MagicMock() - get_module_mock = MagicMock() - get_module_mock(student_mock, request_mock, sec1_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec1_module_mock - get_module_mock(student_mock, request_mock, sec2_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec2_module_mock - get_module_mock(student_mock, request_mock, sec3_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec3_module_mock - get_module_mock(student_mock, request_mock, sec4_xmod.location, student_module_cache_mock, course_id_mock).return_value = sec4_module_mock - prob1 = MagicMock() - prob2 = MagicMock() - prob3 = MagicMock() - prob4 = MagicMock() - prob5 = MagicMock() - prob6 = MagicMock() - prob7 = MagicMock() - prob8 = MagicMock() - yield_module_descendents_mock = MagicMock() - yield_module_descendents_mock(sec1_module_mock).return_value = [prob1, prob2] - yield_module_descendents_mock(sec2_module_mock).return_value = [prob3, prob4] - yield_module_descendents_mock(sec3_module_mock).return_value = [prob5, prob6] - yield_module_descendents_mock(sec4_module_mock).return_value = [prob7, prob8] - - self.assertEqual(list(grades.yield_problems(request_mock, course_mock, student_mock)), []) From 2f5ea630e7adcb854b0abb341741797cd55d9ddf Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 6 Mar 2013 10:16:43 -0500 Subject: [PATCH 195/214] Cleaned up courseware test_tabs unittests --- lms/djangoapps/courseware/tests/test_tabs.py | 117 ++++--------------- 1 file changed, 21 insertions(+), 96 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index e005ff4d35..928b9ae0df 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -1,26 +1,11 @@ from django.test import TestCase -from mock import patch, MagicMock +from mock import MagicMock import courseware.tabs as tabs -from courseware.access import has_access from django.test.utils import override_settings - -from collections import namedtuple -import logging - -from django.conf import settings from django.core.urlresolvers import reverse -from fs.errors import ResourceNotFoundError - -from courseware.access import has_access -from xmodule.error_module import ErrorDescriptor -from xmodule.x_module import XModule, XModuleDescriptor -from xmodule.modulestore import Location - -############################################################################### - class ProgressTestCase(TestCase): @@ -39,26 +24,20 @@ class ProgressTestCase(TestCase): def test_progress(self): self.assertEqual(tabs._progress(self.tab, self.mockuser0, self.course, - self.active_page0), - []) + self.active_page0), []) self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, - self.active_page1)[0].name, - 'same') + self.active_page1)[0].name, 'same') self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, self.active_page1)[0].link, reverse('progress', args = [self.course.id])) self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, - self.active_page0)[0].is_active, - False) + self.active_page0)[0].is_active, False) self.assertEqual(tabs._progress(self.tab, self.mockuser1, self.course, - self.active_page1)[0].is_active, - True) - -############################################################################### + self.active_page1)[0].is_active, True) class WikiTestCase(TestCase): @@ -95,10 +74,7 @@ class WikiTestCase(TestCase): def test_wiki_enabled_false(self): self.assertEqual(tabs._wiki(self.tab, self.user, - self.course, self.active_page1), - []) - -############################################################################### + self.course, self.active_page1), []) class ExternalLinkTestCase(TestCase): @@ -129,8 +105,6 @@ class ExternalLinkTestCase(TestCase): self.course, self.active_page00)[0].is_active, False) -############################################################################### - class StaticTabTestCase(TestCase): @@ -163,8 +137,6 @@ class StaticTabTestCase(TestCase): self.course, self.active_page0)[0].is_active, False) -############################################################################### - class TextbooksTestCase(TestCase): @@ -230,39 +202,6 @@ class TextbooksTestCase(TestCase): self.assertEqual(tabs._textbooks(self.tab, self.mockuser0, self.course, self.active_pageX), []) -############################################################################### -# -#class StaffGradingTestCase(TestCase): -# -# def setUp(self): -# -# self.tab = MagicMock() -# self.user = MagicMock() -# self.course = MagicMock() -# self.active_page1 = 'staff_grading' -# self.activate_page0 = 'shadowfax' -# self.course.id = 'edX/full/6.002_Spring_2012' -# self.link = reverse('staff_grading', args = [self.course.id]) -# -# -# def test_staff_grading(self): -# -# self.assertEqual(tabs._staff_grading(self.tab, self.user, -# self.course, self.active_page1)[0].name, -# 'Staff grading') -# -# self.assertEqual(tabs._staff_grading(self.tab, self.user, -# self.course, self.active_page1)[0].link, -# self.link) -# -# self.assertEqual(tabs._staff_grading(self.tab, self.user, -# self.course, self.active_page1)[0].is_active, -# True) -# -# -############################################################################### - - class KeyCheckerTestCase(TestCase): def setUp(self): @@ -278,8 +217,6 @@ class KeyCheckerTestCase(TestCase): tabs.key_checker(self.expected_keys0), self.dictio) -############################################################################### - class NullValidatorTestCase(TestCase): def setUp(self): @@ -290,45 +227,33 @@ class NullValidatorTestCase(TestCase): self.assertIsNone(tabs.null_validator(self.d)) -############################################################################### - class ValidateTabsTestCase(TestCase): def setUp(self): - self.course0 = MagicMock() - self.course1 = MagicMock() - self.course2 = MagicMock() - self.course3 = MagicMock() - self.course4 = MagicMock() - self.course5 = MagicMock() - self.course0.tabs = None - self.course1.tabs = [{'type':'courseware'}, {'type': 'fax'}] - self.course2.tabs = [{'type':'shadow'}, {'type': 'course_info'}] - self.course3.tabs = [{'type': 'set'}] - self.course4.tabs = [{'type':'courseware'},{'type':'course_info', 'name': 'alice'}, + self.courses = [MagicMock() for i in range(0,5)] + + self.courses[0].tabs = None + + self.courses[1].tabs = [{'type':'courseware'}, {'type': 'fax'}] + + self.courses[2].tabs = [{'type':'shadow'}, {'type': 'course_info'}] + + self.courses[3].tabs = [{'type':'courseware'},{'type':'course_info', 'name': 'alice'}, {'type': 'wiki', 'name':'alice'}, {'type':'discussion', 'name': 'alice'}, {'type':'external_link', 'name': 'alice', 'link':'blink'}, {'type':'textbooks'}, {'type':'progress', 'name': 'alice'}, {'type':'static_tab', 'name':'alice', 'url_slug':'schlug'}, {'type': 'staff_grading'}] - self.course5.tabs = [{'type':'courseware'},{'type': 'course_info'}, {'type': 'flying'}] + self.courses[4].tabs = [{'type':'courseware'},{'type': 'course_info'}, {'type': 'flying'}] def test_validate_tabs(self): - self.assertIsNone(tabs.validate_tabs(self.course0)) - - self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course3) - - self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course2) - - self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course1) - - self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course5) - - self.assertIsNone(tabs.validate_tabs(self.course4)) - - self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.course5) + self.assertIsNone(tabs.validate_tabs(self.courses[0])) + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[1]) + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2]) + self.assertIsNone(tabs.validate_tabs(self.courses[3])) + self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4]) From e1b99186ef4e24a89349eea718d2d6a64c40a891 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 6 Mar 2013 10:20:02 -0500 Subject: [PATCH 196/214] Removed test_permissions.py --- .../tests/test_permissions.py | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 lms/djangoapps/django_comment_client/tests/test_permissions.py diff --git a/lms/djangoapps/django_comment_client/tests/test_permissions.py b/lms/djangoapps/django_comment_client/tests/test_permissions.py deleted file mode 100644 index b79d3a5194..0000000000 --- a/lms/djangoapps/django_comment_client/tests/test_permissions.py +++ /dev/null @@ -1,130 +0,0 @@ -import string -import random -import collections - -from django.contrib.auth.models import User -from django.test import TestCase - -import django_comment_client.models as models - -import student.models - -import django_comment_client.permissions as permissions - -############################################################################### - - -class PermissionsTestCase(TestCase): - def random_str(self, length=15, chars=string.ascii_uppercase + string.digits): - return ''.join(random.choice(chars) for x in range(length)) - - def setUp(self): - - self.course_id = "edX/toy/2012_Fall" - - self.moderator_role = models.Role.objects.get_or_create(name="Moderator", - course_id=self.course_id)[0] - self.student_role = models.Role.objects.get_or_create(name="Student", - course_id=self.course_id)[0] - - self.student = User.objects.create(username=self.random_str(), - password="123456", email="john@yahoo.com") - self.moderator = User.objects.create(username=self.random_str(), - password="123456", email="staff@edx.org") - self.moderator.is_staff = True - self.moderator.save() - self.student_enrollment = student.models.CourseEnrollment.objects.create(user=self.student, - course_id=self.course_id) - self.moderator_enrollment = student.models.CourseEnrollment.objects.create(user=self.moderator, - course_id=self.course_id) - #Fake json files - self.empty_data = {"content": { - } - } - self.open_data = {"content": { - "closed": False, - "user_id": str(self.student.id) - } - } - self.closed_data = {"content": { - "closed": True, - "user_id": str(self.student.id) - } - } - - def tearDown(self): - self.student_enrollment.delete() - self.moderator_enrollment.delete() - -# Do we need to have this? We shouldn't be deleting students, ever -# self.student.delete() -# self.moderator.delete() - - - def testDefaultRoles(self): - self.assertTrue(self.student_role in self.student.roles.all()) - self.assertTrue(self.moderator_role in self.moderator.roles.all()) - - def testPermission(self): - name = self.random_str() - self.moderator_role.add_permission(name) - self.assertTrue(permissions.has_permission(self.moderator, name, self.course_id)) - # Moderators do not have student priveleges unless explicitly added - - self.student_role.add_permission(name) - self.assertTrue(permissions.has_permission(self.student, name, self.course_id)) - - # Students don't have moderator priveleges - name2 = self.random_str() - self.student_role.add_permission(name2) - self.assertFalse(permissions.has_permission(self.moderator, name2, self.course_id)) - - def testCachedPermission(self): - # Cache miss returns None - # Don't really understand how this works? What's in Cache? - self.assertFalse(permissions.cached_has_permission(self.student, self.moderator, - course_id=None)) - self.assertFalse(permissions.cached_has_permission(self.student, "update_thread", - course_id=None)) - - def testCheckCondition(self): - # Checks whether something? is open, or whether the author is user - self.assertFalse(permissions.check_condition(self.student, 'is_open', - self.course_id, self.empty_data)) - self.assertFalse(permissions.check_condition(self.student, 'is_author', - self.course_id, self.empty_data)) - self.assertTrue(permissions.check_condition(self.student, 'is_open', - self.course_id, self.open_data)) - self.assertTrue(permissions.check_condition(self.student, 'is_author', - self.course_id, self.open_data)) - self.assertFalse(permissions.check_condition(self.student,'is_open', - self.course_id, self.closed_data)) - - def testCheckConditionsPermissions(self): - #Function does not seem to return True - self.assertFalse(permissions.check_conditions_permissions(self.student, 'is_open', - self.course_id, - data=self.open_data)) - self.assertFalse(permissions.check_conditions_permissions(self.student, 'is_open', - self.course_id, - data=self.empty_data)) - - self.assertFalse(permissions.check_conditions_permissions(self.student, - ['is_open', 'is_author'], - self.course_id, - data=self.open_data)) - self.assertFalse(permissions.check_conditions_permissions(self.student, - ['is_open', 'is_author'], - self.course_id, - data=self.open_data, - operator='and')) - self.assertFalse(permissions.check_conditions_permissions(self.student, 'update_thread', - self.course_id, data=self.open_data)) - - def testCheckPermissionsByView(self): - # kwargs is the data entered in check_condition, which is json? - self.assertRaises(UnboundLocalError, permissions.check_permissions_by_view, - self.student, self.course_id, self.empty_data, - "nonexistant") - self.assertFalse(permissions.check_permissions_by_view(self.student,self.course_id, - self.empty_data, 'update_thread')) From 4b7d1deb253f55d348e849498a5500bdea3ceade Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 6 Mar 2013 10:34:57 -0500 Subject: [PATCH 197/214] Fix a bug in extracting HTML Update tests to reflect new behavior. --- common/lib/capa/capa/capa_problem.py | 2 +- common/lib/capa/capa/tests/test_html_render.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 62751cd833..14c590a660 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -331,7 +331,7 @@ class LoncapaProblem(object): ''' Main method called externally to get the HTML to be rendered for this capa Problem. ''' - html = contextualize_text(etree.tostring(self.extracted_tree), self.context) + html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) return html diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 64f031ea59..e4c54edca0 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -125,6 +125,8 @@ class CapaHtmlRenderTest(unittest.TestCase): expected_solution_context = {'id': '1_solution_1'} expected_calls = [mock.call('textline.html', expected_textline_context), + mock.call('solutionspan.html', expected_solution_context), + mock.call('textline.html', expected_textline_context), mock.call('solutionspan.html', expected_solution_context)] self.assertEqual(test_system.render_template.call_args_list, From 5682e02f53cc3fca266defeee050e19c484754b4 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 6 Mar 2013 11:05:10 -0500 Subject: [PATCH 198/214] Fix indendation that was causing only first submission to be saved --- common/lib/xmodule/xmodule/foldit_module.py | 14 ++++++++++++-- lms/djangoapps/foldit/views.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 3990a61183..893a86f03f 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -96,8 +96,18 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) - showbasic = (self.metadata.get("show_basic_score").lower() == "true") - showleader = (self.metadata.get("show_leaderboard").lower() == "true") + # Wrap these gets around try-except since calling lower() on NoneType + # (e.g. there is no attribute "show_basic_score" to the tag) will raise + # an exception + try: + showbasic = (self.metadata.get("show_basic_score").lower() == "true") + except: + showbasic = False + try: + showleader = (self.metadata.get("show_leaderboard").lower() == "true") + except: + showleader = False + context = { 'due': self.due_str, 'success': self.is_complete(), diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 988c113d23..da361a2a82 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -130,7 +130,7 @@ def save_scores(user, puzzle_scores): current_score=current_score, best_score=best_score, score_version=score_version) - obj.save() + obj.save() score_responses.append({'PuzzleID': puzzle_id, 'Status': 'Success'}) From dfa590e71ca0557c737b4c315a32e10610723361 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 6 Mar 2013 11:10:13 -0500 Subject: [PATCH 199/214] Make exception a little more specific --- common/lib/xmodule/xmodule/foldit_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 893a86f03f..cc5df16585 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -101,11 +101,11 @@ class FolditModule(XModule): # an exception try: showbasic = (self.metadata.get("show_basic_score").lower() == "true") - except: + except Exception: showbasic = False try: showleader = (self.metadata.get("show_leaderboard").lower() == "true") - except: + except Exception: showleader = False context = { From b706d4a1f500702d7e53804a52776d593786e1f1 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 6 Mar 2013 11:50:23 -0500 Subject: [PATCH 200/214] Use readonly attribute instead of disabled. Disabled text fields are not selectable on FireFox. #225 --- cms/templates/asset_index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 5ace98df56..a5a9144b07 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -28,7 +28,7 @@ {{uploadDate}} - + @@ -84,7 +84,7 @@ ${asset['uploadDate']} - + % endfor @@ -115,7 +115,7 @@
    - +
    From 9b32400b3ac8f277c191078cfd30411c18519f6a Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 6 Mar 2013 13:18:52 -0500 Subject: [PATCH 201/214] tweaked commentary textarea size so it looks correct in ff --- common/lib/xmodule/xmodule/css/capa/display.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index f728401168..4bd59790b9 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -872,10 +872,11 @@ section.problem { textarea.comment { $num-lines-to-show: 5; $line-height: 1.4em; + $padding: .2em; width: 100%; - padding: .375em .75em; + padding: $padding (2 * $padding); line-height: $line-height; - height: ($line-height * $num-lines-to-show) + .375em; + height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2); } .answer-annotation { display: block; margin: 0; } From fd49aceb290add64bba92de07a5efa7b9d70cc37 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 6 Mar 2013 13:36:11 -0500 Subject: [PATCH 202/214] fixed highlight style for cascade. --- common/lib/xmodule/xmodule/css/annotatable/display.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index a8d7032e53..e3316c0c4a 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -44,9 +44,9 @@ $body-font-size: em(14); cursor: pointer; @each $highlight in ( + (yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)), (red rgba(178,19,16,0.3) rgba(178,19,16,0.9)), (orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)), - (yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)), (green rgba(25,255,132,0.3) rgba(25,255,132,0.9)), (blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)), (purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) { From de225e66231e6d423ba91316df5a35a6540fc368 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 6 Mar 2013 14:08:54 -0500 Subject: [PATCH 203/214] Enable Django debug toolbar on Studio (including Mongo panel). --- cms/envs/dev.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 3dee93a398..d3e22bcd37 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -4,9 +4,6 @@ This config file runs the simplest dev environment""" from .common import * from logsettings import get_logger_config -import logging -import sys - DEBUG = True TEMPLATE_DEBUG = DEBUG LOGGING = get_logger_config(ENV_ROOT / "log", @@ -107,3 +104,35 @@ CACHE_TIMEOUT = 0 # Dummy secret key for dev SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' + +################################ DEBUG TOOLBAR ################################# +INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo') +MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware', + 'debug_toolbar.middleware.DebugToolbarMiddleware',) +INTERNAL_IPS = ('127.0.0.1',) + +DEBUG_TOOLBAR_PANELS = ( + 'debug_toolbar.panels.version.VersionDebugPanel', + 'debug_toolbar.panels.timer.TimerDebugPanel', + 'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel', + 'debug_toolbar.panels.headers.HeaderDebugPanel', + 'debug_toolbar.panels.request_vars.RequestVarsDebugPanel', + 'debug_toolbar.panels.sql.SQLDebugPanel', + 'debug_toolbar.panels.signals.SignalDebugPanel', + 'debug_toolbar.panels.logger.LoggingPanel', + 'debug_toolbar_mongo.panel.MongoDebugPanel', + + # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and + # Django=1.3.1/1.4 where requests to views get duplicated (your method gets + # hit twice). So you can uncomment when you need to diagnose performance + # problems, but you shouldn't leave it on. + # 'debug_toolbar.panels.profiling.ProfilingDebugPanel', + ) + +DEBUG_TOOLBAR_CONFIG = { + 'INTERCEPT_REDIRECTS': False +} + +# To see stacktraces for MongoDB queries, set this to True. +# Stacktraces slow down page loads drastically (for pages with lots of queries). +DEBUG_TOOLBAR_MONGO_STACKTRACES = False From ddbab364390462255d69669f5b5f4d36349fb04e Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 6 Mar 2013 14:33:04 -0500 Subject: [PATCH 204/214] Added instructions/documentation to the studio template for content authors. --- .../xmodule/xmodule/css/annotatable/display.scss | 12 ++++++++++++ .../xmodule/templates/annotatable/default.yaml | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index e3316c0c4a..308b379ec1 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -29,6 +29,18 @@ $body-font-size: em(14); padding-top: .5em; @include clearfix; } + + ul.instructions-template { + list-style: disc; + margin-left: 4em; + b { font-weight: bold; } + i { font-style: italic; } + code { + display: inline; + white-space: pre; + font-family: Courier New, monospace; + } + } } .annotatable-toggle { diff --git a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml index 27de411412..cc95cca46d 100644 --- a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml +++ b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml @@ -3,9 +3,18 @@ metadata: display_name: 'Annotation' data: | -

    Add instructions for the exercise here.

    + +

    Enter your (optional) instructions for the exercise in HTML format.

    +

    Annotations are specified by by an <annotation> tag which may may have the following attributes:

    +
      +
    • title (optional). Title of the annotation. Defaults to Commentary if omitted.
    • +
    • body (required). Text of the annotation.
    • +
    • problem (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have problem="0".
    • +
    • highlight (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.
    • +
    +

    Add your HTML with annotation spans here.

    -

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sodales laoreet est, egestas gravida felis egestas nec. Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.

    -

    Nulla facilisi. Pellentesque id vestibulum libero. Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.

    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut sodales laoreet est, egestas gravida felis egestas nec. Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.

    +

    Nulla facilisi. Pellentesque id vestibulum libero. Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.

    children: [] From dfc9176f6b927bb095f06e03076dd5c954abd523 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Wed, 6 Mar 2013 14:40:35 -0500 Subject: [PATCH 205/214] fixed typo --- common/lib/xmodule/xmodule/templates/annotatable/default.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml index cc95cca46d..31dd489fb4 100644 --- a/common/lib/xmodule/xmodule/templates/annotatable/default.yaml +++ b/common/lib/xmodule/xmodule/templates/annotatable/default.yaml @@ -5,7 +5,7 @@ data: |

    Enter your (optional) instructions for the exercise in HTML format.

    -

    Annotations are specified by by an <annotation> tag which may may have the following attributes:

    +

    Annotations are specified by an <annotation> tag which may may have the following attributes:

    • title (optional). Title of the annotation. Defaults to Commentary if omitted.
    • body (required). Text of the annotation.
    • From 1aa3580dcbff1647ba611e13ebc0775cc5ec79c9 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 5 Mar 2013 17:04:23 -0500 Subject: [PATCH 206/214] Switch over to using the inputtype ajax handler for chemical input. --- common/lib/capa/capa/inputtypes.py | 41 +++++++++++++++++++ .../capa/templates/chemicalequationinput.html | 2 +- .../js/capa/chemical_equation_preview.js | 7 +++- lms/djangoapps/courseware/module_render.py | 37 ----------------- lms/urls.py | 8 ---- 5 files changed, 47 insertions(+), 48 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 1d6c340f37..8f6f0b8c09 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -45,8 +45,10 @@ import re import shlex # for splitting quoted strings import sys import os +import pyparsing from registry import TagRegistry +from capa.chem import chemcalc log = logging.getLogger('mitx.' + __name__) @@ -752,6 +754,45 @@ class ChemicalEquationInput(InputTypeBase): """ return {'previewer': '/static/js/capa/chemical_equation_preview.js', } + def handle_ajax(self, dispatch, get): + ''' + Since we only have one ajax handler for this input, check to see if it + matches the corresponding dispatch and send it through if it does + ''' + if dispatch == 'preview_chemcalc': + return self.preview_chemcalc(get) + return {} + + def preview_chemcalc(self, get): + """ + Render an html preview of a chemical formula or equation. get should + contain a key 'formula' and value 'some formula string'. + + Returns a json dictionary: + { + 'preview' : 'the-preview-html' or '' + 'error' : 'the-error' or '' + } + """ + + result = {'preview': '', + 'error': ''} + formula = get['formula'] + if formula is None: + result['error'] = "No formula specified." + return result + + try: + result['preview'] = chemcalc.render_to_html(formula) + except pyparsing.ParseException as p: + result['error'] = "Couldn't parse formula: {0}".format(p) + except Exception: + # this is unexpected, so log + log.warning("Error while previewing chemical formula", exc_info=True) + result['error'] = "Error while rendering preview" + + return result + registry.register(ChemicalEquationInput) #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html index dd177dc920..17c84114e5 100644 --- a/common/lib/capa/capa/templates/chemicalequationinput.html +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -11,7 +11,7 @@
      % endif - Date: Wed, 6 Mar 2013 14:49:36 -0500 Subject: [PATCH 207/214] Add 'get' default string --- common/lib/xmodule/xmodule/foldit_module.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index cc5df16585..920a5aed6d 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -96,17 +96,8 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) - # Wrap these gets around try-except since calling lower() on NoneType - # (e.g. there is no attribute "show_basic_score" to the tag) will raise - # an exception - try: - showbasic = (self.metadata.get("show_basic_score").lower() == "true") - except Exception: - showbasic = False - try: - showleader = (self.metadata.get("show_leaderboard").lower() == "true") - except Exception: - showleader = False + showbasic = (self.metadata.get("show_basic_score", "").lower() == "true") + showleader = (self.metadata.get("show_leaderboard", "").lower() == "true") context = { 'due': self.due_str, From 42e711e7d2c452513fd63f98158258476847fbc8 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 6 Mar 2013 14:53:13 -0500 Subject: [PATCH 208/214] Update tests and documentation. --- common/lib/capa/capa/inputtypes.py | 2 +- common/lib/capa/capa/tests/test_inputtypes.py | 30 ++++++++++++++----- .../js/capa/chemical_equation_preview.js | 4 ++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 8f6f0b8c09..70770c63ff 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -756,7 +756,7 @@ class ChemicalEquationInput(InputTypeBase): def handle_ajax(self, dispatch, get): ''' - Since we only have one ajax handler for this input, check to see if it + Since we only have chemcalc preview this input, check to see if it matches the corresponding dispatch and send it through if it does ''' if dispatch == 'preview_chemcalc': diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 4a5ea5c429..54736cfd3c 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -482,27 +482,43 @@ class ChemicalEquationTest(unittest.TestCase): ''' Check that chemical equation inputs work. ''' - - def test_rendering(self): - size = "42" - xml_str = """""".format(size=size) + def setUp(self): + self.size = "42" + xml_str = """""".format(size=self.size) element = etree.fromstring(xml_str) state = {'value': 'H2OYeah', } - the_input = lookup_tag('chemicalequationinput')(test_system, element, state) + self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state) - context = the_input._get_render_context() + + def test_rendering(self): + ''' Verify that the render context matches the expected render context''' + context = self.the_input._get_render_context() expected = {'id': 'prob_1_2', 'value': 'H2OYeah', 'status': 'unanswered', 'msg': '', - 'size': size, + 'size': self.size, 'previewer': '/static/js/capa/chemical_equation_preview.js', } self.assertEqual(context, expected) + + def test_chemcalc_ajax_sucess(self): + ''' Verify that using the correct dispatch and valid data produces a valid response''' + + data = {'formula': "H"} + response = self.the_input.handle_ajax("preview_chemcalc", data) + + self.assertTrue('preview' in response) + self.assertNotEqual(response['preview'], '') + self.assertEqual(response['error'], "") + + + + class DragAndDropTest(unittest.TestCase): ''' diff --git a/common/static/js/capa/chemical_equation_preview.js b/common/static/js/capa/chemical_equation_preview.js index 73c06972b1..10a6b54655 100644 --- a/common/static/js/capa/chemical_equation_preview.js +++ b/common/static/js/capa/chemical_equation_preview.js @@ -13,7 +13,9 @@ prev_id = "#" + this.id + "_preview"; preview_div = $(prev_id); - url = $(this).parents('.problems-wrapper').data('url'); + // find the closest parent problems-wrapper and use that url + url = $(this).closest('.problems-wrapper').data('url'); + // grab the input id from the input input_id = $(this).data('input-id') Problem.inputAjax(url, input_id, 'preview_chemcalc', {"formula" : this.value}, create_handler(preview_div)); From 5c3dfd928095ace1c0021bf4107579e2865621ed Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 6 Mar 2013 16:43:12 -0500 Subject: [PATCH 209/214] Update correctmap to handle partially correct answers, and fix annotation tests so that they use the new test_system functionality --- common/lib/capa/capa/correctmap.py | 2 +- common/lib/xmodule/xmodule/tests/test_annotatable_module.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 9e76fc20bf..f246b406d5 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -95,7 +95,7 @@ class CorrectMap(object): def is_correct(self, answer_id): if answer_id in self.cmap: - return self.cmap[answer_id]['correctness'] == 'correct' + return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct'] return None def is_queued(self, answer_id): diff --git a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py index 3f9fe349a0..30f9c9ff92 100644 --- a/common/lib/xmodule/xmodule/tests/test_annotatable_module.py +++ b/common/lib/xmodule/xmodule/tests/test_annotatable_module.py @@ -34,7 +34,7 @@ class AnnotatableModuleTestCase(unittest.TestCase): shared_state = None def setUp(self): - self.annotatable = AnnotatableModule(test_system, self.location, self.definition, self.descriptor, self.instance_state, self.shared_state) + self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state) def test_annotation_data_attr(self): el = etree.fromstring('test') @@ -126,4 +126,4 @@ class AnnotatableModuleTestCase(unittest.TestCase): xmltree = etree.fromstring('foo') actual = self.annotatable._extract_instructions(xmltree) - self.assertIsNone(actual) \ No newline at end of file + self.assertIsNone(actual) From 9c92c92fdaf031bd76cfc71fcd8809df4d2256e1 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 6 Mar 2013 17:35:32 -0500 Subject: [PATCH 210/214] add textbook and pdf textbook documentation --- doc/public/course_data_formats/course_xml.rst | 124 ++++++++++++++++-- 1 file changed, 112 insertions(+), 12 deletions(-) diff --git a/doc/public/course_data_formats/course_xml.rst b/doc/public/course_data_formats/course_xml.rst index fe25aa92f2..56d831d972 100644 --- a/doc/public/course_data_formats/course_xml.rst +++ b/doc/public/course_data_formats/course_xml.rst @@ -550,15 +550,84 @@ If you want to customize the courseware tabs displayed for your course, specify ********* Textbooks ********* -Support is currently provided for image-based and PDF-based textbooks. +Support is currently provided for image-based and PDF-based textbooks. In addition to enabling the display of textbooks in tabs (see above), specific information about the location of textbook content must be configured. Image-based Textbooks -^^^^^^^^^^^^^^^^^^^^^ +===================== + +Configuration +------------- + +Image-based textbooks are configured at the course level in the XML markup. Here is an example: + +.. code-block:: xml + + + + + + + + + +Each `textbook` element is displayed on a different tab. The `title` attribute is used as the tab's name, and the `book_url` attribute points to the remote directory that contains the images of the text. Note the trailing slash on the end of the `book_url` attribute. + +The images must be stored in the same directory as the `book_url`, with filenames matching `pXXX.png`, where `XXX` is a three-digit number representing the page number (with leading zeroes as necessary). Pages start at `p001.png`. + +Each textbook must also have its own table of contents. This is read from the `book_url` location, by appending `toc.xml`. This file contains a `table_of_contents` parent element, with `entry` elements nested below it. Each `entry` has attributes for `name`, `page_label`, and `page`, as well as an optional `chapter` attribute. An arbitrary number of levels of nesting of `entry` elements within other `entry` elements is supported, but you're likely to only want two levels. The `page` represents the actual page to link to, while the `page_label` matches the displayed page number on that page. Here's an example: + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Linking from Content +-------------------- + +It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook and the page number. The URL is of the form `/course/book/${bookindex}/$page}`. If the page is omitted from the URL, the first page is assumed. + +You can use a `customtag` to create a template for such links. For example, you can create a `book` template in the `customtag` directory, containing: + +.. code-block:: xml + + More information given in the text. + +The course content can then link to page 25 using the `customtag` element: + +.. code-block:: xml + + -TBD. PDF-based Textbooks -^^^^^^^^^^^^^^^^^^^ +=================== + +Configuration +------------- PDF-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting PDF-based material. The first way is as a single PDF on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple PDFs that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular PDF to view. @@ -566,20 +635,51 @@ PDF-based textbooks are configured at the course level in the policy file. The "pdf_textbooks": [ {"tab_title": "Textbook 1", - "url": "https://www.example.com/book1.pdf" }, + "url": "https://www.example.com/thiscourse/book1/book1.pdf" }, {"tab_title": "Textbook 2", "chapters": [ - { "title": "Chapter 1", "url": "https://www.example.com/Chapter1.pdf" }, - { "title": "Chapter 2", "url": "https://www.example.com/Chapter2.pdf" }, - { "title": "Chapter 3", "url": "https://www.example.com/Chapter3.pdf" }, - { "title": "Chapter 4", "url": "https://www.example.com/Chapter4.pdf" }, - { "title": "Chapter 5", "url": "https://www.example.com/Chapter5.pdf" }, - { "title": "Chapter 6", "url": "https://www.example.com/Chapter6.pdf" }, - { "title": "Chapter 7", "url": "https://www.example.com/Chapter7.pdf" } + { "title": "Chapter 1", "url": "https://www.example.com/thiscourse/book2/Chapter1.pdf" }, + { "title": "Chapter 2", "url": "https://www.example.com/thiscourse/book2/Chapter2.pdf" }, + { "title": "Chapter 3", "url": "https://www.example.com/thiscourse/book2/Chapter3.pdf" }, + { "title": "Chapter 4", "url": "https://www.example.com/thiscourse/book2/Chapter4.pdf" }, + { "title": "Chapter 5", "url": "https://www.example.com/thiscourse/book2/Chapter5.pdf" }, + { "title": "Chapter 6", "url": "https://www.example.com/thiscourse/book2/Chapter6.pdf" }, + { "title": "Chapter 7", "url": "https://www.example.com/thiscourse/book2/Chapter7.pdf" } ] } ] +Some notes: + +* It is not a good idea to include a top-level URL and chapter-level URLs in the same textbook configuration. + +Linking from Content +-------------------- + +It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook, the chapter (if chapters are used), and the page number. For a book with no chapters, the URL is of the form `/course/pdfbook/${bookindex}/$page}`. For a book with chapters, use `/course/pdfbook/${bookindex}/chapter/${chapter}/${page}`. If the page is omitted from the URL, the first page is assumed. + +For example, for the book with no chapters configured above, page 25 can be reached using the URL `/course/pdfbook/0/25`. Reaching page 19 in the third chapter of the second book is accomplished with `/course/pdfbook/1/chapter/3/19`. + +You can use a `customtag` to create a template for such links. For example, you can create a `pdfbook` template in the `customtag` directory, containing: + +.. code-block:: xml + + More information given in the text. + +And a `pdfchapter` template containing: + +.. code-block:: xml + + More information given in the text. + +The example pages can then be linked using the `customtag` element: + +.. code-block:: xml + + + + + ************************************* Other file locations (info and about) ************************************* From 57700a56d4b1b52e76801a25777a2cd301e45855 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Wed, 6 Mar 2013 19:11:36 -0500 Subject: [PATCH 211/214] remove links to missing image files. We can add them if needed later. --- common/static/js/capa/genex/genex.css | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/common/static/js/capa/genex/genex.css b/common/static/js/capa/genex/genex.css index 459c854f92..a05f31110b 100644 --- a/common/static/js/capa/genex/genex.css +++ b/common/static/js/capa/genex/genex.css @@ -57,15 +57,10 @@ pre, #dna-strand { background: white; } .gwt-DialogBox .dialogBottomCenter { - background: url(images/hborder.png) repeat-x 0px -2945px; - -background: url(images/hborder_ie6.png) repeat-x 0px -2144px; } .gwt-DialogBox .dialogMiddleLeft { - background: url(images/vborder.png) repeat-y -31px 0px; } .gwt-DialogBox .dialogMiddleRight { - background: url(images/vborder.png) repeat-y -32px 0px; - -background: url(images/vborder_ie6.png) repeat-y -32px 0px; } .gwt-DialogBox .dialogTopLeftInner { width: 10px; @@ -87,20 +82,12 @@ pre, #dna-strand { zoom: 1; } .gwt-DialogBox .dialogTopLeft { - background: url(images/circles.png) no-repeat -20px 0px; - -background: url(images/circles_ie6.png) no-repeat -20px 0px; } .gwt-DialogBox .dialogTopRight { - background: url(images/circles.png) no-repeat -28px 0px; - -background: url(images/circles_ie6.png) no-repeat -28px 0px; } .gwt-DialogBox .dialogBottomLeft { - background: url(images/circles.png) no-repeat 0px -36px; - -background: url(images/circles_ie6.png) no-repeat 0px -36px; } .gwt-DialogBox .dialogBottomRight { - background: url(images/circles.png) no-repeat -8px -36px; - -background: url(images/circles_ie6.png) no-repeat -8px -36px; } * html .gwt-DialogBox .dialogTopLeftInner { width: 10px; From b6a6e10bb55930c071160fbc27eadc5fda6e9ba8 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Wed, 6 Mar 2013 20:16:24 -0500 Subject: [PATCH 212/214] Fix max_attempts='' That's what studio defaults to, and recent changes made it break. Added a few tests to make sure it doesn't happen again. --- .../lib/capa/capa/tests/test_html_render.py | 42 ++++++++++++------- common/lib/xmodule/xmodule/capa_module.py | 4 +- .../xmodule/xmodule/tests/test_capa_module.py | 34 +++++++++------ 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index e4c54edca0..ca2a3c2e2c 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -11,6 +11,20 @@ from . import test_system class CapaHtmlRenderTest(unittest.TestCase): + def test_blank_problem(self): + """ + It's important that blank problems don't break, since that's + what you start with in studio. + """ + xml_str = " " + + # Create the problem + problem = LoncapaProblem(xml_str, '1', system=test_system) + + # Render the HTML + rendered_html = etree.XML(problem.get_html()) + # expect that we made it here without blowing up + def test_include_html(self): # Create a test file to include self._create_test_file('test_include.xml', @@ -25,7 +39,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -45,7 +59,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -64,7 +78,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Render the HTML rendered_html = etree.XML(problem.get_html()) @@ -99,11 +113,11 @@ class CapaHtmlRenderTest(unittest.TestCase): response_element = rendered_html.find("span") self.assertEqual(response_element.tag, "span") - # Expect that the response + # Expect that the response # that contains a
      for the textline textline_element = response_element.find("div") self.assertEqual(textline_element.text, 'Input Template Render') - + # Expect a child
      for the solution # with the rendered template solution_element = rendered_html.find("div") @@ -112,14 +126,14 @@ class CapaHtmlRenderTest(unittest.TestCase): # Expect that the template renderer was called with the correct # arguments, once for the textline input and once for # the solution - expected_textline_context = {'status': 'unsubmitted', - 'value': '', - 'preprocessor': None, - 'msg': '', - 'inline': False, - 'hidden': False, - 'do_math': False, - 'id': '1_2_1', + expected_textline_context = {'status': 'unsubmitted', + 'value': '', + 'preprocessor': None, + 'msg': '', + 'inline': False, + 'hidden': False, + 'do_math': False, + 'id': '1_2_1', 'size': None} expected_solution_context = {'id': '1_solution_1'} @@ -148,7 +162,7 @@ class CapaHtmlRenderTest(unittest.TestCase): # Create the problem and render the html problem = LoncapaProblem(xml_str, '1', system=test_system) - + # Grade the problem correctmap = problem.grade_answers({'1_2_1': 'test'}) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 7ab7b60239..b0d3950f06 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -135,8 +135,8 @@ class CapaModule(XModule): self.grace_period = None self.close_date = self.display_due_date - max_attempts = self.metadata.get('attempts', None) - if max_attempts is not None: + max_attempts = self.metadata.get('attempts') + if max_attempts is not None and max_attempts != '': self.max_attempts = int(max_attempts) else: self.max_attempts = None diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index a1e3d31d76..6330511fc5 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -44,7 +44,7 @@ class CapaFactory(object): @staticmethod def answer_key(): """ Return the key stored in the capa problem answer dict """ - return ("-".join(['i4x', 'edX', 'capa_test', 'problem', + return ("-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % CapaFactory.num]) + "_2_1") @@ -144,6 +144,8 @@ class CapaModuleTest(unittest.TestCase): "Factory should be creating unique names for each problem") + + def test_correct(self): """ Check that the factory creates correct and incorrect problems properly. @@ -332,7 +334,7 @@ class CapaModuleTest(unittest.TestCase): 'input_4': None, 'input_5': [], 'input_6': 5} - + result = CapaModule.make_dict_of_responses(valid_get_dict) # Expect that we get a dict with "input" stripped from key names @@ -475,7 +477,7 @@ class CapaModuleTest(unittest.TestCase): mock_is_queued.return_value = True mock_get_queuetime.return_value = datetime.datetime.now() - + get_request_dict = { CapaFactory.input_key(): '3.14' } result = module.check_problem(get_request_dict) @@ -506,7 +508,7 @@ class CapaModuleTest(unittest.TestCase): def test_reset_problem(self): module = CapaFactory.create() - # Mock the module's capa problem + # Mock the module's capa problem # to simulate that the problem is done mock_problem = MagicMock(capa.capa_problem.LoncapaProblem) mock_problem.done = True @@ -668,7 +670,7 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(max_attempts=0) self.assertFalse(module.should_show_check_button()) - # If user submitted a problem but hasn't reset, + # If user submitted a problem but hasn't reset, # do NOT show the check button # Note: we can only reset when rerandomize="always" module = CapaFactory.create(rerandomize="always") @@ -707,7 +709,7 @@ class CapaModuleTest(unittest.TestCase): module.lcp.done = True self.assertFalse(module.should_show_reset_button()) - # If the user hasn't submitted an answer yet, + # If the user hasn't submitted an answer yet, # then do NOT show the reset button module = CapaFactory.create() module.lcp.done = False @@ -770,7 +772,7 @@ class CapaModuleTest(unittest.TestCase): # If the user is out of attempts, do NOT show the save button attempts = random.randint(1,10) - module = CapaFactory.create(attempts=attempts, + module = CapaFactory.create(attempts=attempts, max_attempts=attempts, force_save_button="true") module.lcp.done = True @@ -784,6 +786,12 @@ class CapaModuleTest(unittest.TestCase): module.lcp.done = True self.assertTrue(module.should_show_save_button()) + def test_no_max_attempts(self): + module = CapaFactory.create(max_attempts='') + html = module.get_problem_html() + # assert that we got here without exploding + + def test_get_problem_html(self): module = CapaFactory.create() @@ -797,7 +805,7 @@ class CapaModuleTest(unittest.TestCase): module.should_show_reset_button = Mock(return_value=show_reset_button) module.should_show_save_button = Mock(return_value=show_save_button) - # Mock the system rendering function + # Mock the system rendering function module.system.render_template = Mock(return_value="
      Test Template HTML
      ") # Patch the capa problem's HTML rendering @@ -809,7 +817,7 @@ class CapaModuleTest(unittest.TestCase): # Also render the problem encapsulated in a
      html_encapsulated = module.get_problem_html(encapsulate=True) - + # Expect that we get the rendered template back self.assertEqual(html, "
      Test Template HTML
      ") @@ -831,7 +839,7 @@ class CapaModuleTest(unittest.TestCase): def test_get_problem_html_error(self): - """ + """ In production, when an error occurs with the problem HTML rendering, a "dummy" problem is created with an error message to display to the user. @@ -845,10 +853,10 @@ class CapaModuleTest(unittest.TestCase): # is asked to render itself as HTML module.lcp.get_html = Mock(side_effect=Exception("Test")) - # Stub out the test_system rendering function + # Stub out the test_system rendering function module.system.render_template = Mock(return_value="
      Test Template HTML
      ") - # Turn off DEBUG + # Turn off DEBUG module.system.DEBUG = False # Try to render the module with DEBUG turned off @@ -860,4 +868,4 @@ class CapaModuleTest(unittest.TestCase): self.assertTrue("error" in context['problem']['html']) # Expect that the module has created a new dummy problem with the error - self.assertNotEqual(original_problem, module.lcp) + self.assertNotEqual(original_problem, module.lcp) From 9ba759802cb35aa2bb1c640c4e773c6a03edeb36 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 7 Mar 2013 10:43:36 -0500 Subject: [PATCH 213/214] make export a bit more resilient. If there's a piece of metadata that we can't serialize - i.e. someone wrote a new bool attribute and didn't put it in the type mapping table - then we log the exception and continue. --- .../contentstore/tests/test_contentstore.py | 26 +++++++++++++++++++ common/lib/xmodule/xmodule/xml_module.py | 6 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 8e4a016a0f..c0ab9ec60e 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -263,7 +263,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # note, we know the link it should be because that's what in the 'full' course in the test data self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + def test_export_course_with_unknown_metadata(self): + ms = modulestore('direct') + cs = contentstore() + import_from_xml(ms, 'common/test/data/', ['full']) + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + root_dir = path(mkdtemp_clean()) + + course = ms.get_item(location) + + # add a bool piece of unknown metadata so we can verify we don't throw an exception + course.metadata['new_metadata'] = True + + ms.update_metadata(location, course.metadata) + + print 'Exporting to tempdir = {0}'.format(root_dir) + + # export out to a tempdir + bExported = False + try: + export_to_xml(ms, cs, location, root_dir, 'test_export') + bExported = True + except Exception: + pass + + self.assertTrue(bExported) class ContentStoreTest(ModuleStoreTestCase): """ diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 773531c528..7087a03759 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -379,7 +379,11 @@ class XmlDescriptor(XModuleDescriptor): if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy: val = val_for_xml(attr) #logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr)) - xml_object.set(attr, val) + try: + xml_object.set(attr, val) + except Exception, e: + logging.exception('Failed to serialize metadata attribute {0} with value {1}. This could mean data loss!!! Exception: {2}'.format(attr, val, e)) + pass if self.export_to_file(): # Write the definition to a file From 4434ca632e783b935ace08677df421852692dcbb Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 7 Mar 2013 11:30:00 -0500 Subject: [PATCH 214/214] Disable Django toolbar-- breaking mongo updates. --- cms/envs/dev.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index d3e22bcd37..9164c02e3f 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -120,7 +120,8 @@ DEBUG_TOOLBAR_PANELS = ( 'debug_toolbar.panels.sql.SQLDebugPanel', 'debug_toolbar.panels.signals.SignalDebugPanel', 'debug_toolbar.panels.logger.LoggingPanel', - 'debug_toolbar_mongo.panel.MongoDebugPanel', +# This is breaking Mongo updates-- Christina is investigating. +# 'debug_toolbar_mongo.panel.MongoDebugPanel', # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and # Django=1.3.1/1.4 where requests to views get duplicated (your method gets @@ -135,4 +136,4 @@ DEBUG_TOOLBAR_CONFIG = { # To see stacktraces for MongoDB queries, set this to True. # Stacktraces slow down page loads drastically (for pages with lots of queries). -DEBUG_TOOLBAR_MONGO_STACKTRACES = False +# DEBUG_TOOLBAR_MONGO_STACKTRACES = False