From a25a0d71aca9892e6f67f77917f72fc1552528ba Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Wed, 12 Jun 2013 10:42:52 -0400 Subject: [PATCH 001/161] refactored tests in courseware to draw course info from mongo instead of xmlmodulestore --- .../xmodule/xmodule/modulestore/__init__.py | 4 +- lms/djangoapps/courseware/tests/tests.py | 278 ++++++++++++++---- 2 files changed, 216 insertions(+), 66 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 33c7b61251..737d83ad7c 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -52,8 +52,8 @@ class Location(_LocationBase): Locations representations of URLs of the form {tag}://{org}/{course}/{category}/{name}[@{revision}] - However, they can also be represented a dictionaries (specifying each component), - tuples or list (specified in order), or as strings of the url + However, they can also be represented as dictionaries (specifying each component), + tuples or lists (specified in order), or as strings of the url ''' __slots__ = () diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index ec3e55b1b8..97bff38341 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -31,6 +31,10 @@ from xmodule.modulestore import Location from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml import XMLModuleStore +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + log = logging.getLogger("mitx." + __name__) @@ -117,8 +121,121 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) +class MongoLoginHelpers(ModuleStoreTestCase): + + def assertRedirectsNoFollow(self, response, expected_url): + """ + http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ + + Don't check that the redirected-to page loads--there should be other tests for that. + + Some of the code taken from django.test.testcases.py + """ + self.assertEqual(response.status_code, 302, + 'Response status code was %d instead of 302' + % (response.status_code)) + url = response['Location'] + + 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)) + + self.assertEqual(url, expected_url, + "Response redirected to '%s', expected '%s'" % + (url, expected_url)) + + def _login(self, email, password): + '''Login. View should always return 200. The success/fail is in the + returned json''' + resp = self.client.post(reverse('login'), + {'email': email, 'password': password}) + self.assertEqual(resp.status_code, 200) + return resp + + def login(self, email, password): + '''Login, check that it worked.''' + resp = self._login(email, password) + data = parse_json(resp) + self.assertTrue(data['success']) + return resp + + def logout(self): + '''Logout, check that it worked.''' + resp = self.client.get(reverse('logout'), {}) + # should redirect + self.assertEqual(resp.status_code, 302) + return resp + + def _create_account(self, username, email, password): + '''Try to create an account. No error checking''' + resp = self.client.post('/create_account', { + 'username': username, + 'email': email, + 'password': password, + 'name': 'Fred Weasley', + 'terms_of_service': 'true', + 'honor_code': 'true', + }) + return resp + + def create_account(self, username, email, password): + '''Create the account and check that it worked''' + resp = self._create_account(username, email, password) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['success'], True) + + # Check both that the user is created, and inactive + self.assertFalse(get_user(email).is_active) + + return resp + + def _activate_user(self, email): + '''Look up the activation key for the user, then hit the activate view. + No error checking''' + activation_key = get_registration(email).activation_key + + # and now we try to activate + url = reverse('activate', kwargs={'key': activation_key}) + resp = self.client.get(url) + return resp + + def activate_user(self, email): + resp = self._activate_user(email) + self.assertEqual(resp.status_code, 200) + # Now make sure that the user is now actually activated + self.assertTrue(get_user(email).is_active) + + def enroll(self, course): + """Enroll the currently logged-in user, and check that it worked.""" + result = self.try_enroll(course) + self.assertTrue(result) + + def try_enroll(self, course): + """Try to enroll. Return bool success instead of asserting it.""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'enroll', + 'course_id': course.id, + }) + print ('Enrollment in %s result status code: %s' + % (course.location.url(), str(resp.status_code))) + return resp.status_code == 200 + + def check_for_get_code(self, code, url): + """ + Check that we got the expected code when accessing url via GET. + Returns the response. + """ + resp = self.client.get(url) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp + class LoginEnrollmentTestCase(TestCase): + ''' Base TestCase providing support for user creation, activation, login, and course enrollment @@ -403,19 +520,35 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): self.assertGreater(len(course.textbooks), 0) - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestNavigation(LoginEnrollmentTestCase): +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +#@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestNavigation(MongoLoginHelpers): +#class TestNavigation(LoginEnrollmentTestCase): """Check that navigation state is saved properly""" def setUp(self): xmodule.modulestore.django._MODULESTORES = {} # Assume courses are there - self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - self.toy = modulestore().get_course("edX/toy/2012_Fall") + #self.full = modulestore().get_course("edX/full/6.002_Spring_2012") + #self.toy = modulestore().get_course("edX/toy/2012_Fall") + self.course = CourseFactory.create() + self.full = CourseFactory.create(display_name = 'RoboboboboBOT') - # Create two accounts + self.chapter0 = ItemFactory.create(parent_location=self.course.location, + display_name='Overview') + + self.chapter9 = ItemFactory.create(parent_location=self.course.location, + display_name='factory_chapter') + + self.section0 = ItemFactory.create(parent_location=self.chapter0.location, + display_name='Welcome') + + self.section9 = ItemFactory.create(parent_location=self.chapter9.location, + display_name='factory_section') + + + #Create two accounts self.student = 'view@test.com' self.student2 = 'view2@test.com' self.password = 'foo' @@ -427,42 +560,43 @@ class TestNavigation(LoginEnrollmentTestCase): def test_accordion_state(self): """Make sure that the accordion remembers where you were properly""" self.login(self.student, self.password) - self.enroll(self.toy) + self.enroll(self.course) self.enroll(self.full) # First request should redirect to ToyVideos + resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.course.id})) # Don't use no-follow, because state should # only be saved once we actually hit the section self.assertRedirects(resp, reverse( - 'courseware_section', kwargs={'course_id': self.toy.id, + 'courseware_section', kwargs={'course_id': self.course.id, 'chapter': 'Overview', - 'section': 'Toy_Videos'})) + 'section': 'Welcome'})) # Hitting the couseware tab again should # redirect to the first chapter: 'Overview' resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.course.id})) self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, + kwargs={'course_id': self.course.id, 'chapter': 'Overview'})) # Now we directly navigate to a section in a different chapter self.check_for_get_code(200, reverse('courseware_section', - kwargs={'course_id': self.toy.id, - 'chapter': 'secret:magic', - 'section': 'toyvideo'})) + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter', + 'section': 'factory_section'})) # And now hitting the courseware tab should redirect to 'secret:magic' resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.course.id})) self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, - 'chapter': 'secret:magic'})) + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter'})) @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) @@ -478,17 +612,31 @@ class TestDraftModuleStore(TestCase): # The bug was that 'course_id' argument was # not allowed to be passed in (i.e. was throwing exception) - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestViewAuth(LoginEnrollmentTestCase): +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestViewAuth(MongoLoginHelpers): """Check that view authentication works properly""" def setUp(self): xmodule.modulestore.django._MODULESTORES = {} - self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - self.toy = modulestore().get_course("edX/toy/2012_Fall") + self.full = CourseFactory.create(display_name='Robot_Sub_Course') + + self.course = CourseFactory.create() + self.overview_chapter = ItemFactory.create(display_name='Overview') + + self.progress_chapter = ItemFactory.create(parent_location=self.course.location, + display_name='progress') + + self.info_chapter = ItemFactory.create(parent_location=self.course.location, + display_name='info') + + self.welcome_section = ItemFactory.create(parent_location=self.overview_chapter.location, + display_name='Welcome') + + self.somewhere_in_progress = ItemFactory.create(parent_location=self.progress_chapter.location, + display_name='1') + # Create two accounts self.student = 'view@test.com' self.instructor = 'view2@test.com' @@ -507,21 +655,22 @@ class TestViewAuth(LoginEnrollmentTestCase): self.login(self.student, self.password) # shouldn't work before enroll response = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.course.id})) self.assertRedirectsNoFollow(response, - reverse('about_course', - args=[self.toy.id])) - self.enroll(self.toy) + reverse('about_course', + args=[self.course.id])) + self.enroll(self.course) self.enroll(self.full) # should work now -- redirect to first page response = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) + kwargs={'course_id': self.course.id})) self.assertRedirectsNoFollow(response, reverse('courseware_section', - kwargs={'course_id': self.toy.id, + kwargs={'course_id': self.course.id, 'chapter': 'Overview', - 'section': 'Toy_Videos'})) + 'section': 'Welcome'})) + def instructor_urls(course): "list of urls that only instructors/staff should be able to see" @@ -536,15 +685,15 @@ class TestViewAuth(LoginEnrollmentTestCase): return urls # Randomly sample an instructor page - url = random.choice(instructor_urls(self.toy) + - instructor_urls(self.full)) + url = random.choice(instructor_urls(self.course) + + instructor_urls(self.full)) # Shouldn't be able to get to the instructor pages print 'checking for 404 on {0}'.format(url) self.check_for_get_code(404, url) # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.toy.location) + group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) group.user_set.add(get_user(self.instructor)) @@ -552,7 +701,7 @@ class TestViewAuth(LoginEnrollmentTestCase): self.login(self.instructor, self.password) # Now should be able to get to the toy course, but not the full course - url = random.choice(instructor_urls(self.toy)) + url = random.choice(instructor_urls(self.course)) print 'checking for 200 on {0}'.format(url) self.check_for_get_code(200, url) @@ -566,8 +715,9 @@ class TestViewAuth(LoginEnrollmentTestCase): instructor.save() # and now should be able to load both - url = random.choice(instructor_urls(self.toy) + - instructor_urls(self.full)) + url = random.choice(instructor_urls(self.course) + + instructor_urls(self.full)) + print 'checking for 200 on {0}'.format(url) self.check_for_get_code(200, url) @@ -580,11 +730,11 @@ class TestViewAuth(LoginEnrollmentTestCase): """ oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] - try: - settings.MITX_FEATURES['DISABLE_START_DATES'] = False - test() - finally: - settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD + # try: + # settings.MITX_FEATURES['DISABLE_START_DATES'] = False + # test() + # finally: + settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD def test_dark_launch(self): """Make sure that before course start, students can't access course @@ -604,10 +754,10 @@ class TestViewAuth(LoginEnrollmentTestCase): # Make courses start in the future tomorrow = time.time() + 24 * 3600 - self.toy.lms.start = time.gmtime(tomorrow) + self.course.lms.start = time.gmtime(tomorrow) self.full.lms.start = time.gmtime(tomorrow) - self.assertFalse(self.toy.has_started()) + self.assertFalse(self.course.has_started()) self.assertFalse(self.full.has_started()) self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) @@ -691,28 +841,28 @@ class TestViewAuth(LoginEnrollmentTestCase): # First, try with an enrolled student print '=== Testing student access....' self.login(self.student, self.password) - self.enroll(self.toy) + self.enroll(self.course) self.enroll(self.full) # shouldn't be able to get to anything except the light pages - check_non_staff(self.toy) + check_non_staff(self.course) check_non_staff(self.full) print '=== Testing course instructor access....' # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.toy.location) + group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) group.user_set.add(get_user(self.instructor)) self.logout() self.login(self.instructor, self.password) # Enroll in the classes---can't see courseware otherwise. - self.enroll(self.toy) + self.enroll(self.course) self.enroll(self.full) - # should now be able to get to everything for toy course + # should now be able to get to everything for self.course check_non_staff(self.full) - check_staff(self.toy) + check_staff(self.course) print '=== Testing staff access....' # now also make the instructor staff @@ -722,7 +872,7 @@ class TestViewAuth(LoginEnrollmentTestCase): # and now should be able to load both check_staff(self.toy) - check_staff(self.full) + #check_staff(self.full) def _do_test_enrollment_period(self): """Actually do the test, relying on settings to be right.""" @@ -733,9 +883,9 @@ class TestViewAuth(LoginEnrollmentTestCase): yesterday = time.time() - 24 * 3600 print "changing" - # toy course's enrollment period hasn't started - self.toy.enrollment_start = time.gmtime(tomorrow) - self.toy.enrollment_end = time.gmtime(nextday) + # self.course's enrollment period hasn't started + self.course.enrollment_start = time.gmtime(tomorrow) + self.course.enrollment_end = time.gmtime(nextday) # full course's has self.full.enrollment_start = time.gmtime(yesterday) @@ -745,12 +895,12 @@ class TestViewAuth(LoginEnrollmentTestCase): # First, try with an enrolled student print '=== Testing student access....' self.login(self.student, self.password) - self.assertFalse(self.try_enroll(self.toy)) + self.assertFalse(self.try_enroll(self.course)) self.assertTrue(self.try_enroll(self.full)) print '=== Testing course instructor access....' # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.toy.location) + group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) group.user_set.add(get_user(self.instructor)) @@ -758,7 +908,7 @@ class TestViewAuth(LoginEnrollmentTestCase): self.logout() self.login(self.instructor, self.password) print "Instructor should be able to enroll in toy course" - self.assertTrue(self.try_enroll(self.toy)) + self.assertTrue(self.try_enroll(self.course)) print '=== Testing staff access....' # now make the instructor global staff, but not in the instructor group @@ -768,8 +918,8 @@ class TestViewAuth(LoginEnrollmentTestCase): instructor.save() # unenroll and try again - self.unenroll(self.toy) - self.assertTrue(self.try_enroll(self.toy)) + self.unenroll(self.course) + self.assertTrue(self.try_enroll(self.course)) def _do_test_beta_period(self): """Actually test beta periods, relying on settings to be right.""" @@ -783,23 +933,23 @@ class TestViewAuth(LoginEnrollmentTestCase): # yesterday = time.time() - 24 * 3600 # toy course's hasn't started - self.toy.lms.start = time.gmtime(tomorrow) - self.assertFalse(self.toy.has_started()) + self.course.lms.start = time.gmtime(tomorrow) + self.assertFalse(self.course.has_started()) # but should be accessible for beta testers - self.toy.lms.days_early_for_beta = 2 + self.course.lms.days_early_for_beta = 2 # student user shouldn't see it student_user = get_user(self.student) - self.assertFalse(has_access(student_user, self.toy, 'load')) + self.assertFalse(has_access(student_user, self.course, 'load')) # now add the student to the beta test group - group_name = course_beta_test_group_name(self.toy.location) + group_name = course_beta_test_group_name(self.course.location) group = Group.objects.create(name=group_name) group.user_set.add(student_user) # now the student should see it - self.assertTrue(has_access(student_user, self.toy, 'load')) + self.assertTrue(has_access(student_user, self.course, 'load')) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) From 07486a4dbc971511f742f35e5fb69866bcf87d03 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 17 Jun 2013 11:54:16 -0400 Subject: [PATCH 002/161] Refactoring the code in tests.py to remove unnecessary dependencies on the XML Modulestore. --- common/djangoapps/student/views.py | 2 +- .../xmodule/modulestore/tests/factories.py | 7 +- .../courseware/tests/mongo_login_helpers.py | 191 +++++++ .../courseware/tests/test_navigation.py | 440 +++++++++++++++ .../tests/test_view_authentication.py | 418 ++++++++++++++ lms/djangoapps/courseware/tests/tests.py | 530 +----------------- 6 files changed, 1057 insertions(+), 531 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/mongo_login_helpers.py create mode 100644 lms/djangoapps/courseware/tests/test_navigation.py create mode 100644 lms/djangoapps/courseware/tests/test_view_authentication.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 474581c688..4ab5d95833 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -349,6 +349,7 @@ def change_enrollment(request): return HttpResponseBadRequest("Course id not specified") if action == "enroll": + # Make sure the course exists # We don't do this check on unenroll, or a bad course id can't be unenrolled from try: @@ -357,7 +358,6 @@ def change_enrollment(request): log.warning("User {0} tried to enroll in non-existent course {1}" .format(user.username, course_id)) return HttpResponseBadRequest("Course id is invalid") - if not has_access(user, course, 'enroll'): return HttpResponseBadRequest("Enrollment is closed") diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 8cf148f742..0d7c91cca6 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -1,5 +1,5 @@ from factory import Factory, lazy_attribute_sequence, lazy_attribute -from time import gmtime +from time import gmtime, time from uuid import uuid4 from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -35,7 +35,10 @@ class XModuleCourseFactory(Factory): if display_name is not None: new_course.display_name = display_name + tomorrow = time() + 24 * 3600 + new_course.lms.start = gmtime() + new_course.enrollment_start = gmtime(tomorrow) new_course.tabs = kwargs.get( 'tabs', [ @@ -55,6 +58,8 @@ class XModuleCourseFactory(Factory): if data is not None: store.update_item(new_course.location, data) + new_course = store.get_instance(new_course.id, new_course.location) + return new_course diff --git a/lms/djangoapps/courseware/tests/mongo_login_helpers.py b/lms/djangoapps/courseware/tests/mongo_login_helpers.py new file mode 100644 index 0000000000..a9eb822516 --- /dev/null +++ b/lms/djangoapps/courseware/tests/mongo_login_helpers.py @@ -0,0 +1,191 @@ +import logging +import json + +from urlparse import urlsplit, urlunsplit + +from django.contrib.auth.models import User, Group +from django.test import TestCase +from django.test.client import RequestFactory +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test.utils import override_settings + +import xmodule.modulestore.django + +# Need access to internal func to put users in the right group +from courseware import grades +from courseware.model_data import ModelDataCache +from courseware.access import (has_access, _course_staff_group_name, + course_beta_test_group_name) + +from student.models import Registration +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location +from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml import XMLModuleStore + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from xmodule.modulestore.mongo import MongoModuleStore +log = logging.getLogger("mitx." + __name__) + + +def parse_json(response): + """Parse response, which is assumed to be json""" + return json.loads(response.content) + + +def get_user(email): + '''look up a user by email''' + return User.objects.get(email=email) + + +def get_registration(email): + '''look up registration object by email''' + return Registration.objects.get(user__email=email) + + +class MongoLoginHelpers(ModuleStoreTestCase): + + def assertRedirectsNoFollow(self, response, expected_url): + """ + http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ + + Don't check that the redirected-to page loads--there should be other tests for that. + + Some of the code taken from django.test.testcases.py + """ + self.assertEqual(response.status_code, 302, + 'Response status code was %d instead of 302' + % (response.status_code)) + url = response['Location'] + + 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)) + + self.assertEqual(url, expected_url, + "Response redirected to '%s', expected '%s'" % + (url, expected_url)) + + def setup_viewtest_user(self): + '''create a user account, activate, and log in''' + self.viewtest_email = 'view@test.com' + self.viewtest_password = 'foo' + self.viewtest_username = 'viewtest' + self.create_account(self.viewtest_username, + self.viewtest_email, self.viewtest_password) + self.activate_user(self.viewtest_email) + self.login(self.viewtest_email, self.viewtest_password) + + # ============ User creation and login ============== + + def _login(self, email, password): + '''Login. View should always return 200. The success/fail is in the + returned json''' + resp = self.client.post(reverse('login'), + {'email': email, 'password': password}) + self.assertEqual(resp.status_code, 200) + return resp + + def login(self, email, password): + '''Login, check that it worked.''' + resp = self._login(email, password) + data = parse_json(resp) + self.assertTrue(data['success']) + return resp + + def logout(self): + '''Logout, check that it worked.''' + resp = self.client.get(reverse('logout'), {}) + # should redirect + self.assertEqual(resp.status_code, 302) + return resp + + def _create_account(self, username, email, password): + '''Try to create an account. No error checking''' + resp = self.client.post('/create_account', { + 'username': username, + 'email': email, + 'password': password, + 'name': 'Fred Weasley', + 'terms_of_service': 'true', + 'honor_code': 'true', + }) + return resp + + def create_account(self, username, email, password): + '''Create the account and check that it worked''' + resp = self._create_account(username, email, password) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['success'], True) + + # Check both that the user is created, and inactive + self.assertFalse(get_user(email).is_active) + + return resp + + def _activate_user(self, email): + '''Look up the activation key for the user, then hit the activate view. + No error checking''' + activation_key = get_registration(email).activation_key + + # and now we try to activate + url = reverse('activate', kwargs={'key': activation_key}) + resp = self.client.get(url) + return resp + + def activate_user(self, email): + resp = self._activate_user(email) + self.assertEqual(resp.status_code, 200) + # Now make sure that the user is now actually activated + self.assertTrue(get_user(email).is_active) + + def try_enroll(self, course): + """Try to enroll. Return bool success instead of asserting it.""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'enroll', + 'course_id': course.id, + }) + print ('Enrollment in %s result status code: %s' + % (course.location.url(), str(resp.status_code))) + return resp.status_code == 200 + + def enroll(self, course): + """Enroll the currently logged-in user, and check that it worked.""" + result = self.try_enroll(course) + self.assertTrue(result) + + def unenroll(self, course): + """Unenroll the currently logged-in user, and check that it worked.""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'unenroll', + 'course_id': course.id, + }) + self.assertTrue(resp.status_code == 200) + + def check_for_get_code(self, code, url): + """ + Check that we got the expected code when accessing url via GET. + Returns the response. + """ + resp = self.client.get(url) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (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. + Returns the response. + """ + resp = self.client.post(url, data) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py new file mode 100644 index 0000000000..7d7406f30c --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -0,0 +1,440 @@ +import logging +import json +import random + +from urlparse import urlsplit, urlunsplit +from uuid import uuid4 + +from django.contrib.auth.models import User +from django.test import TestCase +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test.utils import override_settings + +import xmodule.modulestore.django + +from student.models import Registration +from xmodule.error_module import ErrorDescriptor +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location +from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.xml import XMLModuleStore + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from mongo_login_helpers import * + +log = logging.getLogger("mitx." + __name__) + + +def parse_json(response): + """Parse response, which is assumed to be json""" + return json.loads(response.content) + + +def get_user(email): + '''look up a user by email''' + return User.objects.get(email=email) + + +def get_registration(email): + '''look up registration object by email''' + return Registration.objects.get(user__email=email) + + +def mongo_store_config(data_dir): + ''' + Defines default module store using MongoModuleStore + + Use of this config requires mongo to be running + ''' + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + } + } + store['direct'] = store['default'] + return store + + +def xml_store_config(data_dir): + '''Defines default module store using XMLModuleStore''' + 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) +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +# TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) + + +class LoginEnrollmentTestCase(TestCase): + + ''' + Base TestCase providing support for user creation, + activation, login, and course enrollment + ''' + + def assertRedirectsNoFollow(self, response, expected_url): + """ + http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ + + Don't check that the redirected-to page loads--there should be other tests for that. + + Some of the code taken from django.test.testcases.py + """ + self.assertEqual(response.status_code, 302, + 'Response status code was %d instead of 302' + % (response.status_code)) + url = response['Location'] + + 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)) + + self.assertEqual(url, expected_url, + "Response redirected to '%s', expected '%s'" % + (url, expected_url)) + + def setup_viewtest_user(self): + '''create a user account, activate, and log in''' + self.viewtest_email = 'view@test.com' + self.viewtest_password = 'foo' + self.viewtest_username = 'viewtest' + self.create_account(self.viewtest_username, + self.viewtest_email, self.viewtest_password) + self.activate_user(self.viewtest_email) + self.login(self.viewtest_email, self.viewtest_password) + + # ============ User creation and login ============== + + def _login(self, email, password): + '''Login. View should always return 200. The success/fail is in the + returned json''' + resp = self.client.post(reverse('login'), + {'email': email, 'password': password}) + self.assertEqual(resp.status_code, 200) + return resp + + def login(self, email, password): + '''Login, check that it worked.''' + resp = self._login(email, password) + data = parse_json(resp) + self.assertTrue(data['success']) + return resp + + def logout(self): + '''Logout, check that it worked.''' + resp = self.client.get(reverse('logout'), {}) + # should redirect + self.assertEqual(resp.status_code, 302) + return resp + + def _create_account(self, username, email, password): + '''Try to create an account. No error checking''' + resp = self.client.post('/create_account', { + 'username': username, + 'email': email, + 'password': password, + 'name': 'Fred Weasley', + 'terms_of_service': 'true', + 'honor_code': 'true', + }) + return resp + + def create_account(self, username, email, password): + '''Create the account and check that it worked''' + resp = self._create_account(username, email, password) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['success'], True) + + # Check both that the user is created, and inactive + self.assertFalse(get_user(email).is_active) + + return resp + + def _activate_user(self, email): + '''Look up the activation key for the user, then hit the activate view. + No error checking''' + activation_key = get_registration(email).activation_key + + # and now we try to activate + url = reverse('activate', kwargs={'key': activation_key}) + resp = self.client.get(url) + return resp + + def activate_user(self, email): + resp = self._activate_user(email) + self.assertEqual(resp.status_code, 200) + # Now make sure that the user is now actually activated + self.assertTrue(get_user(email).is_active) + + def try_enroll(self, course): + """Try to enroll. Return bool success instead of asserting it.""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'enroll', + 'course_id': course.id, + }) + print ('Enrollment in %s result status code: %s' + % (course.location.url(), str(resp.status_code))) + return resp.status_code == 200 + + def enroll(self, course): + """Enroll the currently logged-in user, and check that it worked.""" + result = self.try_enroll(course) + self.assertTrue(result) + + def unenroll(self, course): + """Unenroll the currently logged-in user, and check that it worked.""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'unenroll', + 'course_id': course.id, + }) + self.assertTrue(resp.status_code == 200) + + def check_for_get_code(self, code, url): + """ + Check that we got the expected code when accessing url via GET. + Returns the response. + """ + resp = self.client.get(url) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (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. + Returns the response. + """ + resp = self.client.post(url, data) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp + + +class ActivateLoginTest(LoginEnrollmentTestCase): + '''Test logging in and logging out''' + def setUp(self): + self.setup_viewtest_user() + + def test_activate_login(self): + '''Test login -- the setup function does all the work''' + pass + + def test_logout(self): + '''Test logout -- setup function does login''' + self.logout() + + +class PageLoaderTestCase(LoginEnrollmentTestCase): + ''' Base class that adds a function to load all pages in a modulestore ''' + + def check_random_page_loads(self, module_store): + ''' + Choose a page in the course randomly, and assert that it loads + ''' + # enroll in the course before trying to access pages + courses = module_store.get_courses() + self.assertEqual(len(courses), 1) + course = courses[0] + self.enroll(course) + course_id = course.id + + # Search for items in the course + # None is treated as a wildcard + course_loc = course.location + location_query = Location(course_loc.tag, course_loc.org, + course_loc.course, None, None, None) + + items = module_store.get_items(location_query) + + if len(items) < 1: + self.fail('Could not retrieve any items from course') + else: + descriptor = random.choice(items) + + # We have ancillary course information now as modules + # and we can't simply use 'jump_to' to view them + if descriptor.location.category == 'about': + self._assert_loads('about_course', + {'course_id': course_id}, + descriptor) + + elif descriptor.location.category == 'static_tab': + kwargs = {'course_id': course_id, + 'tab_slug': descriptor.location.name} + self._assert_loads('static_tab', kwargs, descriptor) + + elif descriptor.location.category == 'course_info': + self._assert_loads('info', {'course_id': course_id}, + descriptor) + + elif descriptor.location.category == 'custom_tag_template': + pass + + else: + + kwargs = {'course_id': course_id, + 'location': descriptor.location.url()} + + self._assert_loads('jump_to', kwargs, descriptor, + expect_redirect=True, + check_content=True) + + def _assert_loads(self, django_url, kwargs, descriptor, + expect_redirect=False, + check_content=False): + ''' + Assert that the url loads correctly. + If expect_redirect, then also check that we were redirected. + If check_content, then check that we don't get + an error message about unavailable modules. + ''' + + url = reverse(django_url, kwargs=kwargs) + response = self.client.get(url, follow=True) + + if response.status_code != 200: + self.fail('Status %d for page %s' % + (response.status_code, descriptor.location.url())) + + if expect_redirect: + self.assertEqual(response.redirect_chain[0][1], 302) + + if check_content: + unavailable_msg = "this module is temporarily unavailable" + self.assertEqual(response.content.find(unavailable_msg), -1) + self.assertFalse(isinstance(descriptor, ErrorDescriptor)) + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): + '''Check that all pages in test courses load properly from XML''' + + def setUp(self): + super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() + self.setup_viewtest_user() + xmodule.modulestore.django._MODULESTORES = {} + + def test_toy_course_loads(self): + module_class = 'xmodule.hidden_module.HiddenDescriptor' + module_store = XMLModuleStore(TEST_DATA_DIR, + default_class=module_class, + course_dirs=['toy'], + load_error_modules=True) + + self.check_random_page_loads(module_store) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): + '''Check that all pages in test courses load properly from Mongo''' + + def setUp(self): + super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() + self.setup_viewtest_user() + xmodule.modulestore.django._MODULESTORES = {} + modulestore().collection.drop() + + def test_toy_course_loads(self): + module_store = modulestore() + import_from_xml(module_store, TEST_DATA_DIR, ['toy']) + self.check_random_page_loads(module_store) + + def test_full_textbooks_loads(self): + module_store = modulestore() + import_from_xml(module_store, TEST_DATA_DIR, ['full']) + + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestNavigation(MongoLoginHelpers): + + """Check that navigation state is saved properly""" + + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + + self.course = CourseFactory.create() + self.full = CourseFactory.create(display_name='Robot_Sub_Course') + self.chapter0 = ItemFactory.create(parent_location=self.course.location, + display_name='Overview') + self.chapter9 = ItemFactory.create(parent_location=self.course.location, + display_name='factory_chapter') + self.section0 = ItemFactory.create(parent_location=self.chapter0.location, + display_name='Welcome') + self.section9 = ItemFactory.create(parent_location=self.chapter9.location, + display_name='factory_section') + + #Create two accounts + self.student = 'view@test.com' + self.student2 = 'view2@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.create_account('u2', self.student2, self.password) + self.activate_user(self.student) + self.activate_user(self.student2) + + def test_accordion_state(self): + """Make sure that the accordion remembers where you were properly""" + self.login(self.student, self.password) + self.enroll(self.course) + self.enroll(self.full) + + # First request should redirect to ToyVideos + + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + + # Don't use no-follow, because state should + # only be saved once we actually hit the section + self.assertRedirects(resp, reverse( + 'courseware_section', kwargs={'course_id': self.course.id, + 'chapter': 'Overview', + 'section': 'Welcome'})) + + # Hitting the couseware tab again should + # redirect to the first chapter: 'Overview' + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + + self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', + kwargs={'course_id': self.course.id, + 'chapter': 'Overview'})) + + # Now we directly navigate to a section in a different chapter + self.check_for_get_code(200, reverse('courseware_section', + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter', + 'section': 'factory_section'})) + + # And now hitting the courseware tab should redirect to 'secret:magic' + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + + self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter'})) diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py new file mode 100644 index 0000000000..842641f14f --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -0,0 +1,418 @@ +import logging +import time +import datetime +import pytz +import random + +from uuid import uuid4 + +from django.contrib.auth.models import User, Group +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test.utils import override_settings + +import xmodule.modulestore.django + +# Need access to internal func to put users in the right group +from courseware.access import (has_access, _course_staff_group_name, + course_beta_test_group_name) + +from mongo_login_helpers import MongoLoginHelpers + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +log = logging.getLogger("mitx." + __name__) + + +def get_user(email): + '''look up a user by email''' + return User.objects.get(email=email) + + +def update_course(course, data): + """ + Updates the version of course in the mongo modulestore + with the metadata in data and returns the updated version. + """ + + store = xmodule.modulestore.django.modulestore() + + store.update_item(course.location, data) + + store.update_metadata(course.location, data) + + updated_course = store.get_instance(course.id, course.location) + + return updated_course + + +def mongo_store_config(data_dir): + ''' + Defines default module store using MongoModuleStore + + Use of this config requires mongo to be running + ''' + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + } + } + store['direct'] = store['default'] + return store + + +TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestViewAuth(MongoLoginHelpers): + """Check that view authentication works properly""" + + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + + self.full = CourseFactory.create(display_name='Robot_Sub_Course') + self.course = CourseFactory.create() + + self.overview_chapter = ItemFactory.create(display_name='Overview') + self.progress_chapter = ItemFactory.create(parent_location=self.course.location, + display_name='progress') + self.info_chapter = ItemFactory.create(parent_location=self.course.location, + display_name='info') + self.welcome_section = ItemFactory.create(parent_location=self.overview_chapter.location, + display_name='Welcome') + self.somewhere_in_progress = ItemFactory.create(parent_location=self.progress_chapter.location, + display_name='1') + + # Create two accounts + self.student = 'view@test.com' + self.instructor = 'view2@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.create_account('u2', self.instructor, self.password) + self.activate_user(self.student) + self.activate_user(self.instructor) + + def test_instructor_pages(self): + """Make sure only instructors for the course + or staff can load the instructor + dashboard, the grade views, and student profile pages""" + + # First, try with an enrolled student + self.login(self.student, self.password) + # shouldn't work before enroll + response = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + + self.assertRedirectsNoFollow(response, + reverse('about_course', + args=[self.course.id])) + self.enroll(self.course) + self.enroll(self.full) + # should work now -- redirect to first page + response = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + self.assertRedirectsNoFollow(response, + reverse('courseware_section', + kwargs={'course_id': self.course.id, + 'chapter': 'Overview', + 'section': 'Welcome'})) + + def instructor_urls(course): + "list of urls that only instructors/staff should be able to see" + urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( + 'instructor_dashboard', + 'gradebook', + 'grade_summary',)] + + urls.append(reverse('student_progress', + kwargs={'course_id': course.id, + 'student_id': get_user(self.student).id})) + return urls + + # Randomly sample an instructor page + url = random.choice(instructor_urls(self.course) + + instructor_urls(self.full)) + + # Shouldn't be able to get to the instructor pages + print 'checking for 404 on {0}'.format(url) + self.check_for_get_code(404, url) + + # Make the instructor staff in the toy course + group_name = _course_staff_group_name(self.course.location) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) + + self.logout() + self.login(self.instructor, self.password) + + # Now should be able to get to the toy course, but not the full course + url = random.choice(instructor_urls(self.course)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) + + url = random.choice(instructor_urls(self.full)) + print 'checking for 404 on {0}'.format(url) + self.check_for_get_code(404, url) + + # now also make the instructor staff + instructor = get_user(self.instructor) + instructor.is_staff = True + instructor.save() + + # and now should be able to load both + url = random.choice(instructor_urls(self.course) + + instructor_urls(self.full)) + + 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. + Because settings is global, be careful not to mess it up for other tests + (Can't use override_settings because we're only changing part of the + MITX_FEATURES dict) + """ + oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] + + try: + settings.MITX_FEATURES['DISABLE_START_DATES'] = False + test() + 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""" + self.run_wrapped(self._do_test_dark_launch) + + def test_enrollment_period(self): + """Check that enrollment periods work""" + self.run_wrapped(self._do_test_enrollment_period) + + def test_beta_period(self): + """Check that beta-test access works""" + self.run_wrapped(self._do_test_beta_period) + + def _do_test_dark_launch(self): + """Actually do the test, relying on settings to be right.""" + + # Make courses start in the future + tomorrow = time.time() + 24 * 3600 + self.course.start = time.gmtime(tomorrow) + self.full.start = time.gmtime(tomorrow) + + self.assertFalse(self.course.has_started()) + self.assertFalse(self.full.has_started()) + self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) + + def reverse_urls(names, course): + """Reverse a list of course urls""" + return [reverse(name, kwargs={'course_id': course.id}) + for name in names] + + def dark_student_urls(course): + """ + list of urls that students should be able to see only + after launch, but staff should see before + """ + urls = reverse_urls(['info', 'progress'], course) + urls.extend([ + reverse('book', kwargs={'course_id': course.id, + 'book_index': index}) + for index, book in enumerate(course.textbooks) + ]) + return urls + + def light_student_urls(course): + """ + list of urls that students should be able to see before + launch. + """ + urls = reverse_urls(['about_course'], course) + urls.append(reverse('courses')) + + return urls + + def instructor_urls(course): + """list of urls that only instructors/staff should be able to see""" + urls = reverse_urls(['instructor_dashboard', + 'gradebook', 'grade_summary'], course) + return urls + + def check_non_staff(course): + """Check that access is right for non-staff in course""" + print '=== Checking non-staff access for {0}'.format(course.id) + + # Randomly sample a dark url + url = random.choice(instructor_urls(course) + + dark_student_urls(course) + + reverse_urls(['courseware'], course)) + print 'checking for 404 on {0}'.format(url) + self.check_for_get_code(404, url) + + # Randomly sample a light url + url = random.choice(light_student_urls(course)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) + + def check_staff(course): + """Check that access is right for staff in course""" + print '=== Checking staff access for {0}'.format(course.id) + + # Randomly sample a url + url = random.choice(instructor_urls(course) + + dark_student_urls(course) + + light_student_urls(course)) + print 'checking for 200 on {0}'.format(url) + self.check_for_get_code(200, url) + + # The student progress tab is not accessible to a student + # before launch, so the instructor view-as-student feature + # should return a 404 as well. + # TODO (vshnayder): If this is not the behavior we want, will need + # to make access checking smarter and understand both the effective + # user (the student), and the requesting user (the prof) + url = reverse('student_progress', + kwargs={'course_id': course.id, + 'student_id': get_user(self.student).id}) + print 'checking for 404 on view-as-student: {0}'.format(url) + self.check_for_get_code(404, url) + + # The courseware url should redirect, not 200 + 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) + self.enroll(self.course) + self.enroll(self.full) + + # shouldn't be able to get to anything except the light pages + check_non_staff(self.course) + check_non_staff(self.full) + + print '=== Testing course instructor access....' + # Make the instructor staff in the toy course + group_name = _course_staff_group_name(self.course.location) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) + + self.logout() + self.login(self.instructor, self.password) + # Enroll in the classes---can't see courseware otherwise. + self.enroll(self.course) + self.enroll(self.full) + + # should now be able to get to everything for self.course + check_non_staff(self.full) + check_staff(self.course) + + print '=== Testing staff access....' + # now also make the instructor staff + instructor = get_user(self.instructor) + instructor.is_staff = True + instructor.save() + + # and now should be able to load both + check_staff(self.course) + check_staff(self.full) + + def _do_test_enrollment_period(self): + """Actually do the test, relying on settings to be right.""" + + # Make courses start in the future + now = datetime.datetime.now(pytz.UTC) + tomorrow = now + datetime.timedelta(days=1) + nextday = tomorrow + datetime.timedelta(days=1) + yesterday = now - datetime.timedelta(days=1) + + course_data = {'enrollment_start': tomorrow, 'enrollment_end': nextday} + full_data = {'enrollment_start': yesterday, 'enrollment_end': tomorrow} + + print "changing" + # self.course's enrollment period hasn't started + print self.course.enrollment_start + self.course = update_course(self.course, course_data) + print 'FLAG' + print self.course.enrollment_start + print 'FLAG' + #self.course.enrollment_start = time.gmtime(tomorrow) + #self.course.enrollment_end = time.gmtime(nextday) + # full course's has + print self.full.enrollment_start + self.full = update_course(self.full, full_data) + print self.full.enrollment_start + #self.full.enrollment_start = time.gmtime(yesterday) + #self.full.enrollment_end = time.gmtime(tomorrow) + + print "login" + # First, try with an enrolled student + print '=== Testing student access....' + self.login(self.student, self.password) + self.assertFalse(self.try_enroll(self.course)) + self.assertTrue(self.try_enroll(self.full)) + + print '=== Testing course instructor access....' + # Make the instructor staff in the toy course + group_name = _course_staff_group_name(self.course.location) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) + + print "logout/login" + self.logout() + self.login(self.instructor, self.password) + print "Instructor should be able to enroll in toy course" + self.assertTrue(self.try_enroll(self.course)) + + print '=== Testing staff access....' + # now make the instructor global staff, but not in the instructor group + group.user_set.remove(get_user(self.instructor)) + instructor = get_user(self.instructor) + instructor.is_staff = True + instructor.save() + + # unenroll and try again + self.unenroll(self.course) + self.assertTrue(self.try_enroll(self.course)) + + def _do_test_beta_period(self): + """Actually test beta periods, relying on settings to be right.""" + + # trust, but verify :) + self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) + + # Make courses start in the future + tomorrow = time.time() + 24 * 3600 + # nextday = tomorrow + 24 * 3600 + # yesterday = time.time() - 24 * 3600 + + # toy course's hasn't started + self.course.lms.start = time.gmtime(tomorrow) + self.assertFalse(self.course.has_started()) + + # but should be accessible for beta testers + self.course.lms.days_early_for_beta = 2 + + # student user shouldn't see it + student_user = get_user(self.student) + self.assertFalse(has_access(student_user, self.course, 'load')) + + # now add the student to the beta test group + group_name = course_beta_test_group_name(self.course.location) + group = Group.objects.create(name=group_name) + group.user_set.add(student_user) + + # now the student should see it + self.assertTrue(has_access(student_user, self.course, 'load')) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 97bff38341..2c570e6966 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -121,118 +121,6 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) -class MongoLoginHelpers(ModuleStoreTestCase): - - def assertRedirectsNoFollow(self, response, expected_url): - """ - http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ - - Don't check that the redirected-to page loads--there should be other tests for that. - - Some of the code taken from django.test.testcases.py - """ - self.assertEqual(response.status_code, 302, - 'Response status code was %d instead of 302' - % (response.status_code)) - url = response['Location'] - - 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)) - - self.assertEqual(url, expected_url, - "Response redirected to '%s', expected '%s'" % - (url, expected_url)) - - def _login(self, email, password): - '''Login. View should always return 200. The success/fail is in the - returned json''' - resp = self.client.post(reverse('login'), - {'email': email, 'password': password}) - self.assertEqual(resp.status_code, 200) - return resp - - def login(self, email, password): - '''Login, check that it worked.''' - resp = self._login(email, password) - data = parse_json(resp) - self.assertTrue(data['success']) - return resp - - def logout(self): - '''Logout, check that it worked.''' - resp = self.client.get(reverse('logout'), {}) - # should redirect - self.assertEqual(resp.status_code, 302) - return resp - - def _create_account(self, username, email, password): - '''Try to create an account. No error checking''' - resp = self.client.post('/create_account', { - 'username': username, - 'email': email, - 'password': password, - 'name': 'Fred Weasley', - 'terms_of_service': 'true', - 'honor_code': 'true', - }) - return resp - - def create_account(self, username, email, password): - '''Create the account and check that it worked''' - resp = self._create_account(username, email, password) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['success'], True) - - # Check both that the user is created, and inactive - self.assertFalse(get_user(email).is_active) - - return resp - - def _activate_user(self, email): - '''Look up the activation key for the user, then hit the activate view. - No error checking''' - activation_key = get_registration(email).activation_key - - # and now we try to activate - url = reverse('activate', kwargs={'key': activation_key}) - resp = self.client.get(url) - return resp - - def activate_user(self, email): - resp = self._activate_user(email) - self.assertEqual(resp.status_code, 200) - # Now make sure that the user is now actually activated - self.assertTrue(get_user(email).is_active) - - def enroll(self, course): - """Enroll the currently logged-in user, and check that it worked.""" - result = self.try_enroll(course) - self.assertTrue(result) - - def try_enroll(self, course): - """Try to enroll. Return bool success instead of asserting it.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'enroll', - 'course_id': course.id, - }) - print ('Enrollment in %s result status code: %s' - % (course.location.url(), str(resp.status_code))) - return resp.status_code == 200 - - def check_for_get_code(self, code, url): - """ - Check that we got the expected code when accessing url via GET. - Returns the response. - """ - resp = self.client.get(url) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp - class LoginEnrollmentTestCase(TestCase): @@ -520,84 +408,6 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): self.assertGreater(len(course.textbooks), 0) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -#@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestNavigation(MongoLoginHelpers): -#class TestNavigation(LoginEnrollmentTestCase): - """Check that navigation state is saved properly""" - - def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - - # Assume courses are there - #self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - #self.toy = modulestore().get_course("edX/toy/2012_Fall") - self.course = CourseFactory.create() - self.full = CourseFactory.create(display_name = 'RoboboboboBOT') - - self.chapter0 = ItemFactory.create(parent_location=self.course.location, - display_name='Overview') - - self.chapter9 = ItemFactory.create(parent_location=self.course.location, - display_name='factory_chapter') - - self.section0 = ItemFactory.create(parent_location=self.chapter0.location, - display_name='Welcome') - - self.section9 = ItemFactory.create(parent_location=self.chapter9.location, - display_name='factory_section') - - - #Create two accounts - self.student = 'view@test.com' - self.student2 = 'view2@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.create_account('u2', self.student2, self.password) - self.activate_user(self.student) - self.activate_user(self.student2) - - def test_accordion_state(self): - """Make sure that the accordion remembers where you were properly""" - self.login(self.student, self.password) - self.enroll(self.course) - self.enroll(self.full) - - # First request should redirect to ToyVideos - - resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.course.id})) - - # Don't use no-follow, because state should - # only be saved once we actually hit the section - self.assertRedirects(resp, reverse( - 'courseware_section', kwargs={'course_id': self.course.id, - 'chapter': 'Overview', - 'section': 'Welcome'})) - - # Hitting the couseware tab again should - # redirect to the first chapter: 'Overview' - resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.course.id})) - - self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.course.id, - 'chapter': 'Overview'})) - - # Now we directly navigate to a section in a different chapter - self.check_for_get_code(200, reverse('courseware_section', - kwargs={'course_id': self.course.id, - 'chapter': 'factory_chapter', - 'section': 'factory_section'})) - - # And now hitting the courseware tab should redirect to 'secret:magic' - resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.course.id})) - - self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.course.id, - 'chapter': 'factory_chapter'})) - @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) class TestDraftModuleStore(TestCase): @@ -612,345 +422,6 @@ class TestDraftModuleStore(TestCase): # The bug was that 'course_id' argument was # not allowed to be passed in (i.e. was throwing exception) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestViewAuth(MongoLoginHelpers): - """Check that view authentication works properly""" - - def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - - self.full = CourseFactory.create(display_name='Robot_Sub_Course') - - self.course = CourseFactory.create() - - self.overview_chapter = ItemFactory.create(display_name='Overview') - - self.progress_chapter = ItemFactory.create(parent_location=self.course.location, - display_name='progress') - - self.info_chapter = ItemFactory.create(parent_location=self.course.location, - display_name='info') - - self.welcome_section = ItemFactory.create(parent_location=self.overview_chapter.location, - display_name='Welcome') - - self.somewhere_in_progress = ItemFactory.create(parent_location=self.progress_chapter.location, - display_name='1') - - # Create two accounts - self.student = 'view@test.com' - self.instructor = 'view2@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.create_account('u2', self.instructor, self.password) - self.activate_user(self.student) - self.activate_user(self.instructor) - - def test_instructor_pages(self): - """Make sure only instructors for the course - or staff can load the instructor - dashboard, the grade views, and student profile pages""" - - # First, try with an enrolled student - self.login(self.student, self.password) - # shouldn't work before enroll - response = self.client.get(reverse('courseware', - kwargs={'course_id': self.course.id})) - - self.assertRedirectsNoFollow(response, - reverse('about_course', - args=[self.course.id])) - self.enroll(self.course) - self.enroll(self.full) - # should work now -- redirect to first page - response = self.client.get(reverse('courseware', - kwargs={'course_id': self.course.id})) - self.assertRedirectsNoFollow(response, - reverse('courseware_section', - kwargs={'course_id': self.course.id, - 'chapter': 'Overview', - 'section': 'Welcome'})) - - - def instructor_urls(course): - "list of urls that only instructors/staff should be able to see" - urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( - 'instructor_dashboard', - 'gradebook', - 'grade_summary',)] - - urls.append(reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id})) - return urls - - # Randomly sample an instructor page - url = random.choice(instructor_urls(self.course) + - instructor_urls(self.full)) - - # Shouldn't be able to get to the instructor pages - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.course.location) - group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) - - self.logout() - self.login(self.instructor, self.password) - - # Now should be able to get to the toy course, but not the full course - url = random.choice(instructor_urls(self.course)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) - - url = random.choice(instructor_urls(self.full)) - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # now also make the instructor staff - instructor = get_user(self.instructor) - instructor.is_staff = True - instructor.save() - - # and now should be able to load both - url = random.choice(instructor_urls(self.course) + - instructor_urls(self.full)) - - 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. - Because settings is global, be careful not to mess it up for other tests - (Can't use override_settings because we're only changing part of the - MITX_FEATURES dict) - """ - oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] - - # try: - # settings.MITX_FEATURES['DISABLE_START_DATES'] = False - # test() - # 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""" - self.run_wrapped(self._do_test_dark_launch) - - def test_enrollment_period(self): - """Check that enrollment periods work""" - self.run_wrapped(self._do_test_enrollment_period) - - def test_beta_period(self): - """Check that beta-test access works""" - self.run_wrapped(self._do_test_beta_period) - - def _do_test_dark_launch(self): - """Actually do the test, relying on settings to be right.""" - - # Make courses start in the future - tomorrow = time.time() + 24 * 3600 - self.course.lms.start = time.gmtime(tomorrow) - self.full.lms.start = time.gmtime(tomorrow) - - self.assertFalse(self.course.has_started()) - self.assertFalse(self.full.has_started()) - self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - - def reverse_urls(names, course): - """Reverse a list of course urls""" - return [reverse(name, kwargs={'course_id': course.id}) - for name in names] - - def dark_student_urls(course): - """ - list of urls that students should be able to see only - after launch, but staff should see before - """ - urls = reverse_urls(['info', 'progress'], course) - urls.extend([ - reverse('book', kwargs={'course_id': course.id, - 'book_index': index}) - for index, book in enumerate(course.textbooks) - ]) - return urls - - def light_student_urls(course): - """ - list of urls that students should be able to see before - launch. - """ - urls = reverse_urls(['about_course'], course) - urls.append(reverse('courses')) - - return urls - - def instructor_urls(course): - """list of urls that only instructors/staff should be able to see""" - urls = reverse_urls(['instructor_dashboard', - 'gradebook', 'grade_summary'], course) - return urls - - def check_non_staff(course): - """Check that access is right for non-staff in course""" - print '=== Checking non-staff access for {0}'.format(course.id) - - # Randomly sample a dark url - url = random.choice(instructor_urls(course) + - dark_student_urls(course) + - reverse_urls(['courseware'], course)) - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # Randomly sample a light url - url = random.choice(light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) - - def check_staff(course): - """Check that access is right for staff in course""" - print '=== Checking staff access for {0}'.format(course.id) - - # Randomly sample a url - url = random.choice(instructor_urls(course) + - dark_student_urls(course) + - light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) - - # The student progress tab is not accessible to a student - # before launch, so the instructor view-as-student feature - # should return a 404 as well. - # TODO (vshnayder): If this is not the behavior we want, will need - # to make access checking smarter and understand both the effective - # user (the student), and the requesting user (the prof) - url = reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id}) - print 'checking for 404 on view-as-student: {0}'.format(url) - self.check_for_get_code(404, url) - - # The courseware url should redirect, not 200 - 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) - self.enroll(self.course) - self.enroll(self.full) - - # shouldn't be able to get to anything except the light pages - check_non_staff(self.course) - check_non_staff(self.full) - - print '=== Testing course instructor access....' - # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.course.location) - group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) - - self.logout() - self.login(self.instructor, self.password) - # Enroll in the classes---can't see courseware otherwise. - self.enroll(self.course) - self.enroll(self.full) - - # should now be able to get to everything for self.course - check_non_staff(self.full) - check_staff(self.course) - - print '=== Testing staff access....' - # now also make the instructor staff - instructor = get_user(self.instructor) - instructor.is_staff = True - instructor.save() - - # and now should be able to load both - check_staff(self.toy) - #check_staff(self.full) - - def _do_test_enrollment_period(self): - """Actually do the test, relying on settings to be right.""" - - # Make courses start in the future - tomorrow = time.time() + 24 * 3600 - nextday = tomorrow + 24 * 3600 - yesterday = time.time() - 24 * 3600 - - print "changing" - # self.course's enrollment period hasn't started - self.course.enrollment_start = time.gmtime(tomorrow) - self.course.enrollment_end = time.gmtime(nextday) - - # full course's has - self.full.enrollment_start = time.gmtime(yesterday) - self.full.enrollment_end = time.gmtime(tomorrow) - - print "login" - # First, try with an enrolled student - print '=== Testing student access....' - self.login(self.student, self.password) - self.assertFalse(self.try_enroll(self.course)) - self.assertTrue(self.try_enroll(self.full)) - - print '=== Testing course instructor access....' - # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.course.location) - group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) - - print "logout/login" - self.logout() - self.login(self.instructor, self.password) - print "Instructor should be able to enroll in toy course" - self.assertTrue(self.try_enroll(self.course)) - - print '=== Testing staff access....' - # now make the instructor global staff, but not in the instructor group - group.user_set.remove(get_user(self.instructor)) - instructor = get_user(self.instructor) - instructor.is_staff = True - instructor.save() - - # unenroll and try again - self.unenroll(self.course) - self.assertTrue(self.try_enroll(self.course)) - - def _do_test_beta_period(self): - """Actually test beta periods, relying on settings to be right.""" - - # trust, but verify :) - self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - - # Make courses start in the future - tomorrow = time.time() + 24 * 3600 - # nextday = tomorrow + 24 * 3600 - # yesterday = time.time() - 24 * 3600 - - # toy course's hasn't started - self.course.lms.start = time.gmtime(tomorrow) - self.assertFalse(self.course.has_started()) - - # but should be accessible for beta testers - self.course.lms.days_early_for_beta = 2 - - # student user shouldn't see it - student_user = get_user(self.student) - self.assertFalse(has_access(student_user, self.course, 'load')) - - # now add the student to the beta test group - group_name = course_beta_test_group_name(self.course.location) - group = Group.objects.create(name=group_name) - group.user_set.add(student_user) - - # now the student should see it - self.assertTrue(has_access(student_user, self.course, 'load')) - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestSubmittingProblems(LoginEnrollmentTestCase): @@ -1006,6 +477,7 @@ class TestSubmittingProblems(LoginEnrollmentTestCase): resp = self.client.post(modx_url, { (answer_key_prefix + k): v for k,v in responses.items() } ) + return resp def reset_question_answer(self, problem_url_name): From 97275a328634649d3bf2a41a819e2c931346caa7 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Tue, 18 Jun 2013 11:53:43 -0400 Subject: [PATCH 003/161] Refactored some of the classes in tests.py to not unnecessarily depend on the XML modulestore. Conflicts: lms/djangoapps/courseware/tests/test_navigation.py lms/djangoapps/courseware/tests/test_view_authentication.py lms/djangoapps/courseware/tests/tests.py --- .../xmodule/modulestore/tests/factories.py | 5 +- lms/djangoapps/courseware/access.py | 5 +- .../courseware/tests/test_navigation.py | 1 - .../tests/test_view_authentication.py | 17 +- lms/djangoapps/courseware/tests/tests.py | 459 ------------------ 5 files changed, 17 insertions(+), 470 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 247122fa31..b91e9be700 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -1,12 +1,13 @@ from factory import Factory, lazy_attribute_sequence, lazy_attribute from uuid import uuid4 +import datetime + from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.inheritance import own_metadata from xmodule.x_module import ModuleSystem from mitxmako.shortcuts import render_to_string from xblock.runtime import InvalidScopeError -import datetime from pytz import UTC @@ -149,6 +150,8 @@ class XModuleItemFactory(Factory): if new_item.location.category not in DETACHED_CATEGORIES: store.update_children(parent_location, parent.children + [new_item.location.url()]) + new_item = store.get_item(new_item.location) + return new_item diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 07987a8edf..9e6a371552 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -245,7 +245,8 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): if descriptor.lms.start is not None: now = datetime.now(UTC()) effective_start = _adjust_start_date_for_beta_testers(user, descriptor) - if now > effective_start: + difference = (now - effective_start).total_seconds() + if difference > 3600: # after start date, everyone can see it debug("Allow: now > effective start date") return True @@ -508,6 +509,7 @@ def _adjust_start_date_for_beta_testers(user, descriptor): start_as_datetime = descriptor.lms.start delta = timedelta(descriptor.lms.days_early_for_beta) effective = start_as_datetime - delta + # ...and back to time_struct return effective @@ -570,7 +572,6 @@ def _has_access_to_location(user, location, access_level, course_context): debug("Deny: user not in groups %s", instructor_groups) else: log.debug("Error in access._has_access_to_location access_level=%s unknown" % access_level) - return False diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index 7d7406f30c..242379d8ca 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -80,7 +80,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) TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) -# TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) class LoginEnrollmentTestCase(TestCase): diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index e739c25c93..da4f40e0db 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -1,5 +1,4 @@ import logging -import time import datetime import pytz import random @@ -80,10 +79,14 @@ class TestViewAuth(MongoLoginHelpers): def setUp(self): xmodule.modulestore.django._MODULESTORES = {} - self.full = CourseFactory.create(display_name='Robot_Sub_Course') + self.full = CourseFactory.create(number='666', display_name='Robot_Sub_Course') self.course = CourseFactory.create() - self.overview_chapter = ItemFactory.create(display_name='Overview') + self.courseware_chapter = ItemFactory.create(display_name='courseware') + self.sub_courseware_chapter = ItemFactory.create(parent_location=self.full.location, + display_name='courseware') + self.sub_overview_chapter = ItemFactory.create(parent_location=self.sub_courseware_chapter.location, + display_name='Overview') self.progress_chapter = ItemFactory.create(parent_location=self.course.location, display_name='progress') self.info_chapter = ItemFactory.create(parent_location=self.course.location, @@ -121,6 +124,7 @@ class TestViewAuth(MongoLoginHelpers): # should work now -- redirect to first page response = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) + self.assertRedirectsNoFollow(response, reverse('courseware_section', kwargs={'course_id': self.course.id, @@ -210,8 +214,8 @@ class TestViewAuth(MongoLoginHelpers): # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) - self.course.start = tomorrow - self.full.start = tomorrow + self.course.lms.start = tomorrow + self.full.lms.start = tomorrow self.assertFalse(self.course.has_started()) self.assertFalse(self.full.has_started()) @@ -344,7 +348,6 @@ class TestViewAuth(MongoLoginHelpers): print "changing" # self.course's enrollment period hasn't started - print self.course.enrollment_start self.course = update_course(self.course, course_data) # full course's has self.full = update_course(self.full, full_data) @@ -391,7 +394,7 @@ class TestViewAuth(MongoLoginHelpers): # nextday = tomorrow + 24 * 3600 # yesterday = time.time() - 24 * 3600 - # toy course's hasn't started + # self.course's hasn't started self.course.lms.start = tomorrow self.assertFalse(self.course.has_started()) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index c6e5a5f5b7..d7883d88d3 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -272,144 +272,6 @@ class LoginEnrollmentTestCase(TestCase): return resp -class ActivateLoginTest(LoginEnrollmentTestCase): - '''Test logging in and logging out''' - def setUp(self): - self.setup_viewtest_user() - - def test_activate_login(self): - '''Test login -- the setup function does all the work''' - pass - - def test_logout(self): - '''Test logout -- setup function does login''' - self.logout() - - -class PageLoaderTestCase(LoginEnrollmentTestCase): - ''' Base class that adds a function to load all pages in a modulestore ''' - - def check_random_page_loads(self, module_store): - ''' - Choose a page in the course randomly, and assert that it loads - ''' - # enroll in the course before trying to access pages - courses = module_store.get_courses() - self.assertEqual(len(courses), 1) - course = courses[0] - self.enroll(course) - course_id = course.id - - # Search for items in the course - # None is treated as a wildcard - course_loc = course.location - location_query = Location(course_loc.tag, course_loc.org, - course_loc.course, None, None, None) - - items = module_store.get_items(location_query) - - if len(items) < 1: - self.fail('Could not retrieve any items from course') - else: - descriptor = random.choice(items) - - # We have ancillary course information now as modules - # and we can't simply use 'jump_to' to view them - if descriptor.location.category == 'about': - self._assert_loads('about_course', - {'course_id': course_id}, - descriptor) - - elif descriptor.location.category == 'static_tab': - kwargs = {'course_id': course_id, - 'tab_slug': descriptor.location.name} - self._assert_loads('static_tab', kwargs, descriptor) - - elif descriptor.location.category == 'course_info': - self._assert_loads('info', {'course_id': course_id}, - descriptor) - - elif descriptor.location.category == 'custom_tag_template': - pass - - else: - - kwargs = {'course_id': course_id, - 'location': descriptor.location.url()} - - self._assert_loads('jump_to', kwargs, descriptor, - expect_redirect=True, - check_content=True) - - def _assert_loads(self, django_url, kwargs, descriptor, - expect_redirect=False, - check_content=False): - ''' - Assert that the url loads correctly. - If expect_redirect, then also check that we were redirected. - If check_content, then check that we don't get - an error message about unavailable modules. - ''' - - url = reverse(django_url, kwargs=kwargs) - response = self.client.get(url, follow=True) - - if response.status_code != 200: - self.fail('Status %d for page %s' % - (response.status_code, descriptor.location.url())) - - if expect_redirect: - self.assertEqual(response.redirect_chain[0][1], 302) - - if check_content: - unavailable_msg = "this module is temporarily unavailable" - self.assertEqual(response.content.find(unavailable_msg), -1) - self.assertFalse(isinstance(descriptor, ErrorDescriptor)) - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): - '''Check that all pages in test courses load properly from XML''' - - def setUp(self): - super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() - self.setup_viewtest_user() - xmodule.modulestore.django._MODULESTORES = {} - - def test_toy_course_loads(self): - module_class = 'xmodule.hidden_module.HiddenDescriptor' - module_store = XMLModuleStore(TEST_DATA_DIR, - default_class=module_class, - course_dirs=['toy'], - load_error_modules=True) - - self.check_random_page_loads(module_store) - - -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): - '''Check that all pages in test courses load properly from Mongo''' - - def setUp(self): - super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() - self.setup_viewtest_user() - xmodule.modulestore.django._MODULESTORES = {} - modulestore().collection.drop() - - def test_toy_course_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['toy']) - self.check_random_page_loads(module_store) - - def test_full_textbooks_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['full']) - - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) - - self.assertGreater(len(course.textbooks), 0) - - @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) class TestDraftModuleStore(TestCase): def test_get_items_with_course_items(self): @@ -424,327 +286,6 @@ class TestDraftModuleStore(TestCase): # not allowed to be passed in (i.e. was throwing exception) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestViewAuth(LoginEnrollmentTestCase): - """Check that view authentication works properly""" - - def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - - self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - self.toy = modulestore().get_course("edX/toy/2012_Fall") - - # Create two accounts - self.student = 'view@test.com' - self.instructor = 'view2@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.create_account('u2', self.instructor, self.password) - self.activate_user(self.student) - self.activate_user(self.instructor) - - def test_instructor_pages(self): - """Make sure only instructors for the course - or staff can load the instructor - dashboard, the grade views, and student profile pages""" - - # First, try with an enrolled student - self.login(self.student, self.password) - # shouldn't work before enroll - response = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) - - self.assertRedirectsNoFollow(response, - reverse('about_course', - args=[self.toy.id])) - self.enroll(self.toy) - self.enroll(self.full) - # should work now -- redirect to first page - response = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) - self.assertRedirectsNoFollow(response, - reverse('courseware_section', - kwargs={'course_id': self.toy.id, - 'chapter': 'Overview', - 'section': 'Toy_Videos'})) - - def instructor_urls(course): - "list of urls that only instructors/staff should be able to see" - urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( - 'instructor_dashboard', - 'gradebook', - 'grade_summary',)] - - urls.append(reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id})) - return urls - - # Randomly sample an instructor page - url = random.choice(instructor_urls(self.toy) + - instructor_urls(self.full)) - - # Shouldn't be able to get to the instructor pages - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.toy.location) - group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) - - self.logout() - self.login(self.instructor, self.password) - - # Now should be able to get to the toy course, but not the full course - url = random.choice(instructor_urls(self.toy)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) - - url = random.choice(instructor_urls(self.full)) - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # now also make the instructor staff - instructor = get_user(self.instructor) - instructor.is_staff = True - instructor.save() - - # and now should be able to load both - url = random.choice(instructor_urls(self.toy) + - instructor_urls(self.full)) - 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. - Because settings is global, be careful not to mess it up for other tests - (Can't use override_settings because we're only changing part of the - MITX_FEATURES dict) - """ - oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] - - try: - settings.MITX_FEATURES['DISABLE_START_DATES'] = False - test() - 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""" - self.run_wrapped(self._do_test_dark_launch) - - def test_enrollment_period(self): - """Check that enrollment periods work""" - self.run_wrapped(self._do_test_enrollment_period) - - def test_beta_period(self): - """Check that beta-test access works""" - self.run_wrapped(self._do_test_beta_period) - - def _do_test_dark_launch(self): - """Actually do the test, relying on settings to be right.""" - - # Make courses start in the future - tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1) - self.toy.lms.start = tomorrow - self.full.lms.start = tomorrow - - self.assertFalse(self.toy.has_started()) - self.assertFalse(self.full.has_started()) - self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - - def reverse_urls(names, course): - """Reverse a list of course urls""" - return [reverse(name, kwargs={'course_id': course.id}) - for name in names] - - def dark_student_urls(course): - """ - list of urls that students should be able to see only - after launch, but staff should see before - """ - urls = reverse_urls(['info', 'progress'], course) - urls.extend([ - reverse('book', kwargs={'course_id': course.id, - 'book_index': index}) - for index, book in enumerate(course.textbooks) - ]) - return urls - - def light_student_urls(course): - """ - list of urls that students should be able to see before - launch. - """ - urls = reverse_urls(['about_course'], course) - urls.append(reverse('courses')) - - return urls - - def instructor_urls(course): - """list of urls that only instructors/staff should be able to see""" - urls = reverse_urls(['instructor_dashboard', - 'gradebook', 'grade_summary'], course) - return urls - - def check_non_staff(course): - """Check that access is right for non-staff in course""" - print '=== Checking non-staff access for {0}'.format(course.id) - - # Randomly sample a dark url - url = random.choice(instructor_urls(course) + - dark_student_urls(course) + - reverse_urls(['courseware'], course)) - print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # Randomly sample a light url - url = random.choice(light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) - - def check_staff(course): - """Check that access is right for staff in course""" - print '=== Checking staff access for {0}'.format(course.id) - - # Randomly sample a url - url = random.choice(instructor_urls(course) + - dark_student_urls(course) + - light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) - - # The student progress tab is not accessible to a student - # before launch, so the instructor view-as-student feature - # should return a 404 as well. - # TODO (vshnayder): If this is not the behavior we want, will need - # to make access checking smarter and understand both the effective - # user (the student), and the requesting user (the prof) - url = reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id}) - print 'checking for 404 on view-as-student: {0}'.format(url) - self.check_for_get_code(404, url) - - # The courseware url should redirect, not 200 - 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) - self.enroll(self.toy) - self.enroll(self.full) - - # shouldn't be able to get to anything except the light pages - check_non_staff(self.toy) - check_non_staff(self.full) - - print '=== Testing course instructor access....' - # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.toy.location) - group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) - - self.logout() - self.login(self.instructor, self.password) - # Enroll in the classes---can't see courseware otherwise. - self.enroll(self.toy) - self.enroll(self.full) - - # should now be able to get to everything for toy course - check_non_staff(self.full) - check_staff(self.toy) - - print '=== Testing staff access....' - # now also make the instructor staff - instructor = get_user(self.instructor) - instructor.is_staff = True - instructor.save() - - # and now should be able to load both - check_staff(self.toy) - check_staff(self.full) - - def _do_test_enrollment_period(self): - """Actually do the test, relying on settings to be right.""" - - # Make courses start in the future - tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1) - nextday = tomorrow + datetime.timedelta(days=1) - yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=1) - - print "changing" - # toy course's enrollment period hasn't started - self.toy.enrollment_start = tomorrow - self.toy.enrollment_end = nextday - - # full course's has - self.full.enrollment_start = yesterday - self.full.enrollment_end = tomorrow - - print "login" - # First, try with an enrolled student - print '=== Testing student access....' - self.login(self.student, self.password) - self.assertFalse(self.try_enroll(self.toy)) - self.assertTrue(self.try_enroll(self.full)) - - print '=== Testing course instructor access....' - # Make the instructor staff in the toy course - group_name = _course_staff_group_name(self.toy.location) - group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) - - print "logout/login" - self.logout() - self.login(self.instructor, self.password) - print "Instructor should be able to enroll in toy course" - self.assertTrue(self.try_enroll(self.toy)) - - print '=== Testing staff access....' - # now make the instructor global staff, but not in the instructor group - group.user_set.remove(get_user(self.instructor)) - instructor = get_user(self.instructor) - instructor.is_staff = True - instructor.save() - - # unenroll and try again - self.unenroll(self.toy) - self.assertTrue(self.try_enroll(self.toy)) - - def _do_test_beta_period(self): - """Actually test beta periods, relying on settings to be right.""" - - # trust, but verify :) - self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - - # Make courses start in the future - tomorrow = datetime.datetime.now(UTC()) + datetime.timedelta(days=1) - - # toy course's hasn't started - self.toy.lms.start = tomorrow - self.assertFalse(self.toy.has_started()) - - # but should be accessible for beta testers - self.toy.lms.days_early_for_beta = 2 - - # student user shouldn't see it - student_user = get_user(self.student) - self.assertFalse(has_access(student_user, self.toy, 'load')) - - # now add the student to the beta test group - group_name = course_beta_test_group_name(self.toy.location) - group = Group.objects.create(name=group_name) - group.user_set.add(student_user) - - # now the student should see it - self.assertTrue(has_access(student_user, self.toy, 'load')) - - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestSubmittingProblems(LoginEnrollmentTestCase): """Check that a course gets graded properly""" From 905d884aa7a517bdc11cde348f5b8fb76ccef093 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Tue, 18 Jun 2013 15:41:53 -0400 Subject: [PATCH 004/161] Removed a unnecessary imports from mongo_login_helpers.py and repeated code from tests.py. --- .../courseware/tests/mongo_login_helpers.py | 21 +--- lms/djangoapps/courseware/tests/tests.py | 104 ------------------ 2 files changed, 1 insertion(+), 124 deletions(-) diff --git a/lms/djangoapps/courseware/tests/mongo_login_helpers.py b/lms/djangoapps/courseware/tests/mongo_login_helpers.py index a9eb822516..a329f71d13 100644 --- a/lms/djangoapps/courseware/tests/mongo_login_helpers.py +++ b/lms/djangoapps/courseware/tests/mongo_login_helpers.py @@ -3,32 +3,13 @@ import json from urlparse import urlsplit, urlunsplit -from django.contrib.auth.models import User, Group -from django.test import TestCase -from django.test.client import RequestFactory -from django.conf import settings +from django.contrib.auth.models import User from django.core.urlresolvers import reverse -from django.test.utils import override_settings - -import xmodule.modulestore.django - -# Need access to internal func to put users in the right group -from courseware import grades -from courseware.model_data import ModelDataCache -from courseware.access import (has_access, _course_staff_group_name, - course_beta_test_group_name) from student.models import Registration -from xmodule.error_module import ErrorDescriptor -from xmodule.modulestore.django import modulestore -from xmodule.modulestore import Location -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml import XMLModuleStore -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.mongo import MongoModuleStore log = logging.getLogger("mitx." + __name__) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index d7883d88d3..3e39227171 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -351,110 +351,6 @@ class TestSubmittingProblems(LoginEnrollmentTestCase): return resp -class TestCourseGrader(TestSubmittingProblems): - """Check that a course gets graded properly""" - - course_slug = "graded" - course_when = "2012_Fall" - - def get_grade_summary(self): - '''calls grades.grade for current user and course''' - model_data_cache = ModelDataCache.cache_for_descriptor_descendents( - self.course.id, self.student_user, self.course) - - fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.course.id})) - - return grades.grade(self.student_user, fake_request, - self.course, model_data_cache) - - def get_homework_scores(self): - '''get scores for homeworks''' - return self.get_grade_summary()['totaled_scores']['Homework'] - - def get_progress_summary(self): - '''return progress summary structure for current user and course''' - model_data_cache = ModelDataCache.cache_for_descriptor_descendents( - self.course.id, self.student_user, self.course) - - fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.course.id})) - - progress_summary = grades.progress_summary(self.student_user, - fake_request, - self.course, - model_data_cache) - return progress_summary - - def check_grade_percent(self, percent): - '''assert that percent grade is as expected''' - grade_summary = self.get_grade_summary() - self.assertEqual(grade_summary['percent'], percent) - - def test_get_graded(self): - #### Check that the grader shows we have 0% in the course - self.check_grade_percent(0) - - #### Submit the answers to a few problems as ajax calls - def earned_hw_scores(): - """Global scores, each Score is a Problem Set""" - return [s.earned for s in self.get_homework_scores()] - - def score_for_hw(hw_url_name): - """returns list of scores for a given url""" - hw_section = [section for section - in self.get_progress_summary()[0]['sections'] - if section.get('url_name') == hw_url_name][0] - return [s.earned for s in hw_section['scores']] - - # Only get half of the first problem correct - self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Incorrect'}) - self.check_grade_percent(0.06) - self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters - self.assertEqual(score_for_hw('Homework1'), [1.0, 0.0]) - - # Get both parts of the first problem correct - self.reset_question_answer('H1P1') - self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(0.13) - self.assertEqual(earned_hw_scores(), [2.0, 0, 0]) - self.assertEqual(score_for_hw('Homework1'), [2.0, 0.0]) - - # This problem is shown in an ABTest - self.submit_question_answer('H1P2', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(0.25) - self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0]) - self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) - - # This problem is hidden in an ABTest. - # Getting it correct doesn't change total grade - self.submit_question_answer('H1P3', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(0.25) - self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) - - # On the second homework, we only answer half of the questions. - # Then it will be dropped when homework three becomes the higher percent - # This problem is also weighted to be 4 points (instead of default of 2) - # If the problem was unweighted the percent would have been 0.38 so we - # know it works. - self.submit_question_answer('H2P1', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(0.42) - self.assertEqual(earned_hw_scores(), [4.0, 4.0, 0]) - - # Third homework - self.submit_question_answer('H3P1', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(0.42) # Score didn't change - self.assertEqual(earned_hw_scores(), [4.0, 4.0, 2.0]) - - self.submit_question_answer('H3P2', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(0.5) # Now homework2 dropped. Score changes - self.assertEqual(earned_hw_scores(), [4.0, 4.0, 4.0]) - - # Now we answer the final question (worth half of the grade) - self.submit_question_answer('FinalQuestion', {'2_1': 'Correct', '2_2': 'Correct'}) - self.check_grade_percent(1.0) # Hooray! We got 100% - - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestSchematicResponse(TestSubmittingProblems): """Check that we can submit a schematic response, and it answers properly.""" From 7b074424b540db5ad9b2c0c5840de04162680134 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 19 Jun 2013 14:39:02 -0400 Subject: [PATCH 005/161] Initialize MakoMiddleware manually during certificate grading runs. Without this, problems fail to load because they can't find how to render themselves, and the certificate generation grading run will get an inaccurately low count of the possible points a user could get (anything they didn't see will be omitted), inflating their grade during certificate calculation and making it inconsistent with their Progress page. --- lms/djangoapps/certificates/queue.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index b4632ce9ab..af1037f903 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -3,6 +3,7 @@ from certificates.models import certificate_status_for_student from certificates.models import CertificateStatuses as status from certificates.models import CertificateWhitelist +from mitxmako.middleware import MakoMiddleware from courseware import grades, courses from django.test.client import RequestFactory from capa.xqueue_interface import XQueueInterface @@ -51,6 +52,14 @@ class XQueueCertInterface(object): """ def __init__(self, request=None): + # MakoMiddleware Note: + # Line below has the side-effect of writing to a module level lookup + # table that will allow problems to render themselves. If this is not + # present, problems that a student hasn't seen will error when loading, + # causing the grading system to under-count the possible score and + # inflate their grade. This dependency is bad and was probably recently + # introduced. This is the bandage until we can trace the root cause. + m = MakoMiddleware() # Get basic auth (username/password) for # xqueue connection if it's in the settings @@ -161,6 +170,10 @@ class XQueueCertInterface(object): cert, created = GeneratedCertificate.objects.get_or_create( user=student, course_id=course_id) + # Needed + self.request.user = student + self.request.session = {} + grade = grades.grade(student, self.request, course) is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() @@ -211,5 +224,5 @@ class XQueueCertInterface(object): (error, msg) = self.xqueue_interface.send_to_queue( header=xheader, body=json.dumps(contents)) if error: - logger.critical('Unable to add a request to the queue') + logger.critical('Unable to add a request to the queue: {} {}'.format(error, msg)) raise Exception('Unable to send queue message') From 4f78c1977f2256fc832514256f4f967040f7eaff Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Wed, 19 Jun 2013 10:59:24 -0400 Subject: [PATCH 006/161] Allow error messages with non-ascii characters to be handled correctly Also, add a test for this behavior. --- CHANGELOG.rst | 2 ++ common/lib/xmodule/xmodule/capa_module.py | 2 +- .../xmodule/xmodule/tests/test_capa_module.py | 28 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 206be44c87..6a79757c0f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -42,6 +42,8 @@ setting now run entirely outside the Python sandbox. Blades: Added tests for Video Alpha player. +Common: Have the capa module handle unicode better (especially errors) + Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d9f7fc61aa..85c935c9e7 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -781,7 +781,7 @@ class CapaModule(CapaFields, XModule): # Otherwise, display just an error message, # without a stack trace else: - msg = "Error: %s" % str(inst.message) + msg = u"Error: {msg}".format(msg=inst.message) return {'success': msg} diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 696ef58268..85e69cabc1 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Tests of the Capa XModule""" #pylint: disable=C0111 #pylint: disable=R0904 @@ -520,6 +521,33 @@ class CapaModuleTest(unittest.TestCase): # Expect that the number of attempts is NOT incremented self.assertEqual(module.attempts, 1) + def test_check_problem_error_nonascii(self): + + # Try each exception that capa_module should handle + for exception_class in [StudentInputError, + LoncapaProblemError, + ResponseError]: + + # Create the module + module = CapaFactory.create(attempts=1) + + # Ensure that the user is NOT staff + module.system.user_is_staff = False + + # Simulate answering a problem that raises the exception + with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: + mock_grade.side_effect = exception_class(u"ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ") + + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) + + # Expect an AJAX alert message in 'success' + expected_msg = u'Error: ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ' + self.assertEqual(expected_msg, result['success']) + + # Expect that the number of attempts is NOT incremented + self.assertEqual(module.attempts, 1) + def test_check_problem_error_with_staff_user(self): # Try each exception that capa module should handle From 401dd550e477ca0616313f85aa2f64d64dc88a2b Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Tue, 18 Jun 2013 13:14:52 -0400 Subject: [PATCH 007/161] Convert many byte strings to unicode; change string formatting --- common/lib/calc/calc.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 49 +++++++++++++---------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/common/lib/calc/calc.py b/common/lib/calc/calc.py index f0934a9ed5..bbfd9545f6 100644 --- a/common/lib/calc/calc.py +++ b/common/lib/calc/calc.py @@ -93,7 +93,7 @@ def check_variables(string, variables): Pyparsing uses a left-to-right parser, which makes a more elegant approach pretty hopeless. """ - general_whitespace = re.compile('[^\\w]+') + general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii # List of all alnums in string possible_variables = re.split(general_whitespace, string) bad_variables = [] diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 85c935c9e7..3bd8331678 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -60,7 +60,7 @@ class Randomization(String): class ComplexEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, complex): - return "{real:.7g}{imag:+.7g}*j".format(real=obj.real, imag=obj.imag) + return u"{real:.7g}{imag:+.7g}*j".format(real=obj.real, imag=obj.imag) return json.JSONEncoder.default(self, obj) @@ -167,7 +167,7 @@ class CapaModule(CapaFields, XModule): self.seed = self.lcp.seed except Exception as err: - msg = 'cannot create LoncapaProblem {loc}: {err}'.format( + msg = u'cannot create LoncapaProblem {loc}: {err}'.format( loc=self.location.url(), err=err) # TODO (vshnayder): do modules need error handlers too? # We shouldn't be switching on DEBUG. @@ -176,12 +176,15 @@ class CapaModule(CapaFields, XModule): # TODO (vshnayder): This logic should be general, not here--and may # want to preserve the data instead of replacing it. # e.g. in the CMS - msg = '

%s

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

%s

' % traceback.format_exc().replace('<', '<') + msg = u'

{msg}

'.format(msg=cgi.escape(msg)) + msg += u'

{tb}

'.format( + tb=cgi.escape(traceback.format_exc())) # create a dummy problem with error message instead of failing - problem_text = ('' - 'Problem %s has an error:%s' % - (self.location.url(), msg)) + problem_text = (u'' + u'Problem {url} has an error:{msg}'.format( + url=self.location.url(), + msg=msg) + ) self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text) else: # add extra info and raise @@ -362,15 +365,14 @@ class CapaModule(CapaFields, XModule): # 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('<', '<') + u'[courseware.capa.capa_module] ' + u'Failed to generate HTML for problem {url}'.format( + url=cgi.escape(self.location.url())) + ) + msg += u'

Error:

{msg}

'.format(msg=cgi.escape(err.message)) + msg += u'

{tb}

'.format(tb=cgi.escape(traceback.format_exc())) 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: # We're in non-debug mode, and possibly even in production. We want # to avoid bricking of problem as much as possible @@ -454,8 +456,9 @@ class CapaModule(CapaFields, XModule): html = self.system.render_template('problem.html', context) if encapsulate: - html = '
'.format( - id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
" + html = u'
'.format( + id=self.location.html_id(), ajax_url=self.system.ajax_url + ) + html + "
" # now do the substitutions which are filesystem based, e.g. '/static/' prefixes return self.system.replace_urls(html) @@ -641,7 +644,8 @@ class CapaModule(CapaFields, XModule): try: new_answer = {answer_id: self.system.replace_urls(answers[answer_id])} except TypeError: - log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) + log.debug(u'Unable to perform URL substitution on answers[%s]: %s', + answer_id, answers[answer_id]) new_answer = {answer_id: answers[answer_id]} new_answers.update(new_answer) @@ -693,7 +697,7 @@ class CapaModule(CapaFields, XModule): # will return (key, '', '') # We detect this and raise an error if not name: - raise ValueError("%s must contain at least one underscore" % str(key)) + raise ValueError(u"{key} must contain at least one underscore".format(key=key)) else: # This allows for answers which require more than one value for @@ -711,7 +715,7 @@ class CapaModule(CapaFields, XModule): # If the name already exists, then we don't want # to override it. Raise an error instead if name in answers: - raise ValueError("Key %s already exists in answers dict" % str(name)) + raise ValueError(u"Key {name} already exists in answers dict".format(name=name)) else: answers[name] = val @@ -759,7 +763,8 @@ class CapaModule(CapaFields, XModule): prev_submit_time = self.lcp.get_recentmost_queuetime() waittime_between_requests = self.system.xqueue['waittime'] if (current_time - prev_submit_time).total_seconds() < waittime_between_requests: - msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests + msg = u'You must wait at least {wait} seconds between submissions'.format( + wait=waittime_between_requests) return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback try: @@ -776,7 +781,7 @@ class CapaModule(CapaFields, XModule): # the full exception, including traceback, # in the response if self.system.user_is_staff: - msg = "Staff debug info: %s" % traceback.format_exc() + msg = u"Staff debug info: {tb}".format(tb=cgi.escape(traceback.format_exc())) # Otherwise, display just an error message, # without a stack trace @@ -787,7 +792,7 @@ class CapaModule(CapaFields, XModule): except Exception as err: if self.system.DEBUG: - msg = "Error checking problem: " + str(err) + msg = "Error checking problem: " + err.message msg += '\nTraceback:\n' + traceback.format_exc() return {'success': msg} raise From b68e1e207e3fb99980de4eb8bf8b904a7ceabb13 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Tue, 18 Jun 2013 13:24:22 -0400 Subject: [PATCH 008/161] Fix some line lengths to make pylint happy --- common/lib/xmodule/xmodule/capa_module.py | 45 ++++++++++++++++------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 3bd8331678..40f685baee 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -22,7 +22,7 @@ from xblock.core import Scope, String, Boolean, Dict, Integer, Float from .fields import Timedelta, Date from django.utils.timezone import UTC -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("mitx.courseware") # pylint: disable=C0103 # Generate this many different variants of problems with rerandomize=per_student @@ -65,17 +65,23 @@ class ComplexEncoder(json.JSONEncoder): class CapaFields(object): - attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) + attempts = Integer(help="Number of attempts taken by the student on this problem", + default=0, scope=Scope.user_state) max_attempts = Integer( display_name="Maximum Attempts", - help="Defines the number of times a student can try to answer this problem. If the value is not set, infinite attempts are allowed.", + help=("Defines the number of times a student can try to answer this problem. " + "If the value is not set, infinite attempts are allowed."), values={"min": 0}, scope=Scope.settings ) due = Date(help="Date that this problem is due by", scope=Scope.settings) - graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) + graceperiod = Timedelta( + help="Amount of time after the due date that submissions will be accepted", + scope=Scope.settings + ) showanswer = String( display_name="Show Answer", - help="Defines when to show the answer to the problem. A default value can be set in Advanced Settings.", + help=("Defines when to show the answer to the problem. " + "A default value can be set in Advanced Settings."), scope=Scope.settings, default="closed", values=[ {"display_name": "Always", "value": "always"}, @@ -86,23 +92,33 @@ class CapaFields(object): {"display_name": "Past Due", "value": "past_due"}, {"display_name": "Never", "value": "never"}] ) - force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) + force_save_button = Boolean( + help="Whether to force the save button to appear on the page", + scope=Scope.settings, default=False + ) rerandomize = Randomization( - display_name="Randomization", help="Defines how often inputs are randomized when a student loads the problem. This setting only applies to problems that can have randomly generated numeric values. A default value can be set in Advanced Settings.", - default="always", scope=Scope.settings, values=[{"display_name": "Always", "value": "always"}, - {"display_name": "On Reset", "value": "onreset"}, - {"display_name": "Never", "value": "never"}, - {"display_name": "Per Student", "value": "per_student"}] + display_name="Randomization", + help="Defines how often inputs are randomized when a student loads the problem. " + "This setting only applies to problems that can have randomly generated numeric values. " + "A default value can be set in Advanced Settings.", + default="always", scope=Scope.settings, values=[ + {"display_name": "Always", "value": "always"}, + {"display_name": "On Reset", "value": "onreset"}, + {"display_name": "Never", "value": "never"}, + {"display_name": "Per Student", "value": "per_student"} + ] ) data = String(help="XML data for the problem", scope=Scope.content) - correct_map = Dict(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={}) + correct_map = Dict(help="Dictionary with the correctness of current student answers", + scope=Scope.user_state, default={}) input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state) student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state) seed = Integer(help="Random seed for this student", scope=Scope.user_state) weight = Float( display_name="Problem Weight", - help="Defines the number of points each problem is worth. If the value is not set, each response field in the problem is worth one point.", + help=("Defines the number of points each problem is worth. " + "If the value is not set, each response field in the problem is worth one point."), values={"min": 0, "step": .1}, scope=Scope.settings ) @@ -998,7 +1014,8 @@ class CapaDescriptor(CapaFields, RawDescriptor): mako_template = "widgets/problem-edit.html" js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]} js_module_name = "MarkdownEditingDescriptor" - css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), resource_string(__name__, 'css/problem/edit.scss')]} + css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), + resource_string(__name__, 'css/problem/edit.scss')]} # Capa modules have some additional metadata: # TODO (vshnayder): do problems have any other metadata? Do they From f623e42983545f99a0cf7bd69e7bccccb55e285e Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Tue, 18 Jun 2013 15:48:55 -0400 Subject: [PATCH 009/161] Fix formatting of docstrings; add more docstrings --- common/lib/xmodule/xmodule/capa_module.py | 170 ++++++++++++------ .../xmodule/xmodule/tests/test_capa_module.py | 16 +- 2 files changed, 126 insertions(+), 60 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 40f685baee..b927106b4a 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -47,7 +47,13 @@ def randomization_bin(seed, problem_id): class Randomization(String): + """ + Define a field to store how to randomize a problem. + """ def from_json(self, value): + """ + For backward compatability? + """ if value in ("", "true"): return "always" elif value == "false": @@ -58,13 +64,22 @@ class Randomization(String): class ComplexEncoder(json.JSONEncoder): + """ + Extend the JSON encoder to correctly handle complex numbers + """ def default(self, obj): + """ + Print a nicely formatted complex number, or default to the JSON encoder + """ if isinstance(obj, complex): return u"{real:.7g}{imag:+.7g}*j".format(real=obj.real, imag=obj.imag) return json.JSONEncoder.default(self, obj) class CapaFields(object): + """ + Define the possible fields for a Capa problem + """ attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) max_attempts = Integer( @@ -130,12 +145,12 @@ class CapaFields(object): class CapaModule(CapaFields, XModule): - ''' + """ An XModule implementing LonCapa format problems, implemented by way of capa.capa_problem.LoncapaProblem CapaModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__ - ''' + """ icon_class = 'problem' js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'), @@ -150,7 +165,9 @@ class CapaModule(CapaFields, XModule): css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} def __init__(self, *args, **kwargs): - """ Accepts the same arguments as xmodule.x_module:XModule.__init__ """ + """ + Accepts the same arguments as xmodule.x_module:XModule.__init__ + """ XModule.__init__(self, *args, **kwargs) due_date = self.due @@ -211,7 +228,9 @@ class CapaModule(CapaFields, XModule): assert self.seed is not None def choose_new_seed(self): - """Choose a new seed.""" + """ + Choose a new seed. + """ if self.rerandomize == 'never': self.seed = 1 elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): @@ -225,6 +244,9 @@ class CapaModule(CapaFields, XModule): self.seed %= MAX_RANDOMIZATION_BINS def new_lcp(self, state, text=None): + """ + Generate a new Loncapa Problem + """ if text is None: text = self.data @@ -237,6 +259,9 @@ class CapaModule(CapaFields, XModule): ) def get_state_for_lcp(self): + """ + Give a dictionary holding the state of the module + """ return { 'done': self.done, 'correct_map': self.correct_map, @@ -246,6 +271,9 @@ class CapaModule(CapaFields, XModule): } def set_state_from_lcp(self): + """ + Set the module's state from the settings in `self.lcp` + """ lcp_state = self.lcp.get_state() self.done = lcp_state['done'] self.correct_map = lcp_state['correct_map'] @@ -254,26 +282,36 @@ class CapaModule(CapaFields, XModule): self.seed = lcp_state['seed'] def get_score(self): + """ + Access the problem's score + """ return self.lcp.get_score() def max_score(self): + """ + Access the problem's max score + """ return self.lcp.get_max_score() def get_progress(self): - ''' For now, just return score / max_score - ''' + """ + For now, just return score / max_score + """ d = self.get_score() score = d['score'] total = d['total'] if total > 0: try: return Progress(score, total) - except Exception: + except (TypeError, ValueError): log.exception("Got bad progress") return None return None def get_html(self): + """ + Return some html with data about the module + """ return self.system.render_template('problem_ajax.html', { 'element_id': self.location.html_id(), 'id': self.id, @@ -284,6 +322,7 @@ class CapaModule(CapaFields, XModule): 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" """ @@ -369,12 +408,12 @@ class CapaModule(CapaFields, XModule): def handle_problem_html_error(self, err): """ - Change our problem to a dummy problem containing - a warning message to display to users. + Create a dummy problem to represent any errors. - Returns the HTML to show to users + 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. + `err` is the Exception encountered while rendering the problem HTML. """ log.exception(err) @@ -434,8 +473,12 @@ class CapaModule(CapaFields, XModule): 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.''' + """ + 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() @@ -480,15 +523,16 @@ class CapaModule(CapaFields, XModule): return self.system.replace_urls(html) def handle_ajax(self, dispatch, get): - ''' + """ This is called by courseware.module_render, to handle an AJAX call. - "get" is request.POST. + + `get` is request.POST. Returns a json dictionary: { 'progress_changed' : True/False, 'progress' : 'none'/'in_progress'/'done', } - ''' + """ handlers = { 'problem_get': self.get_problem, 'problem_check': self.check_problem, @@ -527,7 +571,9 @@ class CapaModule(CapaFields, XModule): datetime.datetime.now(UTC()) > self.close_date) def closed(self): - ''' Is the student still allowed to submit answers? ''' + """ + Is the student still allowed to submit answers? + """ if self.max_attempts is not None and self.attempts >= self.max_attempts: return True if self.is_past_due(): @@ -546,18 +592,24 @@ class CapaModule(CapaFields, XModule): return self.lcp.done def is_attempted(self): - """Used by conditional module""" + """ + Has the problem been attempted? + + used by conditional module + """ return self.attempts > 0 def is_correct(self): - """True if full points""" + """ + True iff full points + """ d = self.get_score() return d['score'] == d['total'] def answer_available(self): - ''' + """ Is the user allowed to see an answer? - ''' + """ if self.showanswer == '': return False elif self.showanswer == "never": @@ -589,7 +641,7 @@ class CapaModule(CapaFields, XModule): Delivers grading response (e.g. from asynchronous code checking) to the capa problem, so its score can be updated - 'get' must have a field 'response' which is a string that contains the + `get` must have a field `response` which is a string that contains the grader's response No ajax return is needed. Return empty dict. @@ -603,7 +655,7 @@ class CapaModule(CapaFields, XModule): return dict() # No AJAX return is needed def handle_ungraded_response(self, get): - ''' + """ Delivers a response from the XQueue to the capa problem The score of the problem will not be updated @@ -616,7 +668,7 @@ class CapaModule(CapaFields, XModule): empty dictionary No ajax return is needed, so an empty dict is returned - ''' + """ queuekey = get['queuekey'] score_msg = get['xqueue_body'] # pass along the xqueue message to the problem @@ -625,25 +677,25 @@ class CapaModule(CapaFields, XModule): return dict() def handle_input_ajax(self, get): - ''' + """ Handle ajax calls meant for a particular input in the problem Args: - get (dict) - data that should be passed to the input Returns: - dict containing the response from the input - ''' + """ response = self.lcp.handle_input_ajax(get) # save any state changes that may occur self.set_state_from_lcp() return response def get_answer(self, get): - ''' + """ For the "show answer" button. Returns the answers: {'answers' : answers} - ''' + """ event_info = dict() event_info['problem_id'] = self.location.url() self.system.track_function('showanswer', event_info) @@ -669,40 +721,44 @@ class CapaModule(CapaFields, XModule): # Figure out if we should move these to capa_problem? def get_problem(self, get): - ''' Return results of get_problem_html, as a simple dict for json-ing. + """ + Return results of get_problem_html, as a simple dict for json-ing. + { 'html': } - Used if we want to reconfirm we have the right thing e.g. after - several AJAX calls. - ''' + Used if we want to reconfirm we have the right thing e.g. after + several AJAX calls. + """ return {'html': self.get_problem_html(encapsulate=False)} @staticmethod def make_dict_of_responses(get): - '''Make dictionary of student responses (aka "answers") - get is POST dictionary (Django QueryDict). + """ + Make dictionary of student responses (aka "answers") - The *get* dict has keys of the form 'x_y', which are mapped + `get` is POST dictionary (Django QueryDict). + + The `get` dict has keys of the form 'x_y', which are mapped to key 'y' in the returned dict. For example, 'input_1_2_3' would be mapped to '1_2_3' in the returned dict. Some inputs always expect a list in the returned dict (e.g. checkbox inputs). The convention is that - keys in the *get* dict that end with '[]' will always + keys in the `get` dict that end with '[]' will always have list values in the returned dict. - For example, if the *get* dict contains {'input_1[]': 'test' } + For example, if the `get` dict contains {'input_1[]': 'test' } then the output dict would contain {'1': ['test'] } (the value is a list). Raises an exception if: - A key in the *get* dictionary does not contain >= 1 underscores - (e.g. "input" is invalid; "input_1" is valid) + -A key in the `get` dictionary does not contain at least one underscore + (e.g. "input" is invalid, but "input_1" is valid) - Two keys end up with the same name in the returned dict. - (e.g. 'input_1' and 'input_1[]', which both get mapped - to 'input_1' in the returned dict) - ''' + -Two keys end up with the same name in the returned dict. + (e.g. 'input_1' and 'input_1[]', which both get mapped to 'input_1' + in the returned dict) + """ answers = dict() for key in get: @@ -749,12 +805,13 @@ class CapaModule(CapaFields, XModule): }) def check_problem(self, get): - ''' Checks whether answers to a problem are correct, and - returns a map of correct/incorrect answers: + """ + Checks whether answers to a problem are correct - {'success' : 'correct' | 'incorrect' | AJAX alert msg string, - 'contents' : html} - ''' + Returns a map of correct/incorrect answers: + {'success' : 'correct' | 'incorrect' | AJAX alert msg string, + 'contents' : html} + """ event_info = dict() event_info['state'] = self.lcp.get_state() event_info['problem_id'] = self.location.url() @@ -958,16 +1015,17 @@ class CapaModule(CapaFields, XModule): 'msg': msg} def reset_problem(self, get): - ''' Changes problem state to unfinished -- removes student answers, - and causes problem to rerender itself. + """ + Changes problem state to unfinished -- removes student answers, + and causes problem to rerender itself. - Returns a dictionary of the form: - {'success': True/False, - 'html': Problem 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. - ''' + 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() event_info['problem_id'] = self.location.url() diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 85e69cabc1..81df686015 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -"""Tests of the Capa XModule""" +""" +Tests of the Capa XModule +""" #pylint: disable=C0111 #pylint: disable=R0904 #pylint: disable=C0103 @@ -48,12 +50,16 @@ class CapaFactory(object): @staticmethod def input_key(): - """ Return the input key to use when passing GET parameters """ + """ + Return the input key to use when passing GET parameters + """ return ("input_" + CapaFactory.answer_key()) @staticmethod def answer_key(): - """ Return the key stored in the capa problem answer dict """ + """ + Return the key stored in the capa problem answer dict + """ return ("-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % CapaFactory.num]) + "_2_1") @@ -362,7 +368,9 @@ class CapaModuleTest(unittest.TestCase): result = CapaModule.make_dict_of_responses(invalid_get_dict) def _querydict_from_dict(self, param_dict): - """ Create a Django QueryDict from a Python dictionary """ + """ + Create a Django QueryDict from a Python dictionary + """ # QueryDict objects are immutable by default, so we make # a copy that we can update. From e4af7287b6f204dc759f1e9a349bf29a6864a025 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Tue, 18 Jun 2013 14:04:43 -0400 Subject: [PATCH 010/161] Initial testing for parallelization --- cms/djangoapps/contentstore/tests/test_contentstore.py | 6 ++++++ cms/envs/test.py | 2 +- .../lib/xmodule/xmodule/modulestore/tests/django_utils.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index d24deacecf..86699ef479 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -43,10 +43,13 @@ from django_comment_common.utils import are_permissions_roles_seeded from xmodule.exceptions import InvalidVersionError import datetime from pytz import UTC +#from uuid import uuid4 TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') +TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) +TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % 4 #uuid4().hex class MongoCollectionFindWrapper(object): @@ -60,6 +63,7 @@ class MongoCollectionFindWrapper(object): @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ Tests that rely on the toy courses. @@ -83,6 +87,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + def check_components_on_page(self, component_types, expected_types): """ Ensure that the right types end up on the page. @@ -809,6 +814,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): export_to_xml(module_store, content_store, location, root_dir, 'test_export') +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreTest(ModuleStoreTestCase): """ Tests for the CMS ContentStore application. diff --git a/cms/envs/test.py b/cms/envs/test.py index 954a553e10..89813dd937 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -70,7 +70,7 @@ CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'OPTIONS': { 'host': 'localhost', - 'db': 'test_xmodule', + 'db': 'test_xcontent', }, # allow for additional options that can be keyed on a name, e.g. 'trashcan' 'ADDITIONAL_OPTIONS': { diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 04e79ce521..e0e5c1a46f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -27,6 +27,7 @@ class ModuleStoreTestCase(TestCase): # Remove everything except templates modulestore.collection.remove(query) + modulestore.collection.drop() @staticmethod def load_templates_if_necessary(): From f90ed69cd792091c9f2d57bce1cbafe0efd51094 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 18 Jun 2013 15:09:53 -0400 Subject: [PATCH 011/161] move override of MODULESTORE settings into ModuleStore test case class --- cms/djangoapps/contentstore/tests/test_contentstore.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 86699ef479..9c3ec2e3ba 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -45,9 +45,7 @@ import datetime from pytz import UTC #from uuid import uuid4 -TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) -TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') -TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') + TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % 4 #uuid4().hex @@ -62,7 +60,7 @@ class MongoCollectionFindWrapper(object): return self.original(query, *args, **kwargs) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +#@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ @@ -70,6 +68,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): TODO: refactor using CourseFactory so they do not. """ def setUp(self): + + settings.MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') + settings.MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') uname = 'testuser' email = 'test+courses@edx.org' password = 'foo' @@ -88,6 +89,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) + def check_components_on_page(self, component_types, expected_types): """ Ensure that the right types end up on the page. From 51f8c0cfebedb7807b04ef849cfb806a0dcdba0e Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Wed, 19 Jun 2013 11:27:22 -0400 Subject: [PATCH 012/161] Added the beginnings of self cleanup --- .../contentstore/tests/test_contentstore.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 9c3ec2e3ba..46d6a069ce 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -43,11 +43,12 @@ from django_comment_common.utils import are_permissions_roles_seeded from xmodule.exceptions import InvalidVersionError import datetime from pytz import UTC -#from uuid import uuid4 +from uuid import uuid4 +import pymongo TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) -TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % 4 #uuid4().hex +TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex class MongoCollectionFindWrapper(object): @@ -88,7 +89,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) - + def tearDown(self): + m = pymongo.MongoClient() + m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + #contentstore().fs_files.drop() def check_components_on_page(self, component_types, expected_types): """ @@ -449,7 +453,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content_store = contentstore() trash_store = contentstore('trashcan') module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) # look up original (and thumbnail) in content store, should be there after import @@ -853,6 +856,11 @@ class ContentStoreTest(ModuleStoreTestCase): 'display_name': 'Robot Super Course', } + def tearDown(self): + m = pymongo.MongoClient() + m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + #contentstore().fs_files.drop() + def test_create_course(self): """Test new course creation - happy path""" resp = self.client.post(reverse('create_new_course'), self.course_data) From fa18b48f6eaec45bc65f16f9585fa2555462ad55 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Thu, 20 Jun 2013 09:05:35 -0400 Subject: [PATCH 013/161] Contentstore singleton is now cleared during teardown --- cms/djangoapps/contentstore/tests/test_contentstore.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 46d6a069ce..b0cbcee032 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -41,6 +41,7 @@ from xmodule.exceptions import NotFoundError from django_comment_common.utils import are_permissions_roles_seeded from xmodule.exceptions import InvalidVersionError +import xmodule.contentstore.django import datetime from pytz import UTC from uuid import uuid4 @@ -92,7 +93,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def tearDown(self): m = pymongo.MongoClient() m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) - #contentstore().fs_files.drop() + xmodule.contentstore.django._CONTENTSTORE.clear() def check_components_on_page(self, component_types, expected_types): """ @@ -859,7 +860,7 @@ class ContentStoreTest(ModuleStoreTestCase): def tearDown(self): m = pymongo.MongoClient() m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) - #contentstore().fs_files.drop() + xmodule.contentstore.django._CONTENTSTORE.clear() def test_create_course(self): """Test new course creation - happy path""" From cb04f9f0b82dfe46777ceb0584fadb656f7b6780 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Thu, 20 Jun 2013 17:10:36 -0400 Subject: [PATCH 014/161] Moved port range to rake file --- jenkins/test.sh | 3 --- rakelib/tests.rake | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/jenkins/test.sh b/jenkins/test.sh index e5ac4f6f71..2ff32a9911 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -60,9 +60,6 @@ fi export PIP_DOWNLOAD_CACHE=/mnt/pip-cache -# Allow django liveserver tests to use a range of ports -export DJANGO_LIVE_TEST_SERVER_ADDRESS=${DJANGO_LIVE_TEST_SERVER_ADDRESS-localhost:8000-9000} - source /mnt/virtualenvs/"$JOB_NAME"/bin/activate bundle install diff --git a/rakelib/tests.rake b/rakelib/tests.rake index f169d28256..c0592cca7a 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -16,7 +16,7 @@ def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"] test_id = dirs.join(' ') if test_id.nil? or test_id == '' - cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', test_id) + cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', '--liveserver=localhost:8000-9000', test_id) test_sh(run_under_coverage(cmd, system)) end From 7db93976c5860cf818bc915bb890b1a9c18b6838 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Fri, 21 Jun 2013 11:02:25 -0400 Subject: [PATCH 015/161] PR fixes --- common/lib/xmodule/xmodule/capa_module.py | 9 +++------ .../lib/xmodule/xmodule/tests/test_capa_module.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index b927106b4a..d740a73946 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -22,7 +22,7 @@ from xblock.core import Scope, String, Boolean, Dict, Integer, Float from .fields import Timedelta, Date from django.utils.timezone import UTC -log = logging.getLogger("mitx.courseware") # pylint: disable=C0103 +log = logging.getLogger("mitx.courseware") # Generate this many different variants of problems with rerandomize=per_student @@ -51,9 +51,6 @@ class Randomization(String): Define a field to store how to randomize a problem. """ def from_json(self, value): - """ - For backward compatability? - """ if value in ("", "true"): return "always" elif value == "false": @@ -865,8 +862,8 @@ class CapaModule(CapaFields, XModule): except Exception as err: if self.system.DEBUG: - msg = "Error checking problem: " + err.message - msg += '\nTraceback:\n' + traceback.format_exc() + msg = u"Error checking problem: {}".format(err.message) + msg += u'\nTraceback:\n{}'.format(traceback.format_exc()) return {'success': msg} raise diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 81df686015..c6ffd32e89 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -505,9 +505,10 @@ class CapaModuleTest(unittest.TestCase): def test_check_problem_error(self): # Try each exception that capa_module should handle - for exception_class in [StudentInputError, - LoncapaProblemError, - ResponseError]: + exception_classes = [StudentInputError, + LoncapaProblemError, + ResponseError] + for exception_class in exception_classes: # Create the module module = CapaFactory.create(attempts=1) @@ -532,9 +533,10 @@ class CapaModuleTest(unittest.TestCase): def test_check_problem_error_nonascii(self): # Try each exception that capa_module should handle - for exception_class in [StudentInputError, - LoncapaProblemError, - ResponseError]: + exception_classes = [StudentInputError, + LoncapaProblemError, + ResponseError] + for exception_class in exception_classes: # Create the module module = CapaFactory.create(attempts=1) From 9bfddd4891281dfeeb37b2d596c41627908813e2 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Fri, 21 Jun 2013 14:05:57 -0400 Subject: [PATCH 016/161] Addressed pull request feedback. --- .../xmodule/modulestore/tests/django_utils.py | 16 + .../xmodule/modulestore/tests/factories.py | 4 + lms/djangoapps/courseware/access.py | 3 +- .../courseware/tests/check_request_code.py | 24 + lms/djangoapps/courseware/tests/helpers.py | 142 +++++ .../courseware/tests/modulestore_config.py | 72 +++ .../courseware/tests/mongo_login_helpers.py | 172 ------ .../tests/test_draft_modulestore.py | 21 + .../courseware/tests/test_masquerade.py | 7 +- .../courseware/tests/test_navigation.py | 449 ++------------- .../tests/test_view_authentication.py | 374 ++++++------- lms/djangoapps/courseware/tests/tests.py | 516 +++++------------- .../instructor/tests/test_download_csv.py | 9 +- .../instructor/tests/test_enrollment.py | 5 +- .../instructor/tests/test_forum_admin.py | 9 +- lms/djangoapps/open_ended_grading/tests.py | 19 +- lms/urls.py | 2 +- 17 files changed, 694 insertions(+), 1150 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/check_request_code.py create mode 100644 lms/djangoapps/courseware/tests/helpers.py create mode 100644 lms/djangoapps/courseware/tests/modulestore_config.py delete mode 100644 lms/djangoapps/courseware/tests/mongo_login_helpers.py create mode 100644 lms/djangoapps/courseware/tests/test_draft_modulestore.py diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 04e79ce521..944b9e5bd4 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -14,6 +14,22 @@ class ModuleStoreTestCase(TestCase): collection with templates before running the TestCase and drops it they are finished. """ + def update_course(self, course, data): + """ + Updates the version of course in the mongo modulestore + with the metadata in data and returns the updated version. + """ + + store = xmodule.modulestore.django.modulestore() + + store.update_item(course.location, data) + + store.update_metadata(course.location, data) + + updated_course = store.get_instance(course.id, course.location) + + return updated_course + @staticmethod def flush_mongo_except_templates(): ''' diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index b91e9be700..4f63fbc1d2 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -60,6 +60,8 @@ class XModuleCourseFactory(Factory): if data is not None: store.update_item(new_course.location, data) + '''update_item updates the the course as it exists in the modulestore, but doesn't + update the instance we are working with, so have to refetch the course after updating it.''' new_course = store.get_instance(new_course.id, new_course.location) return new_course @@ -150,6 +152,8 @@ class XModuleItemFactory(Factory): if new_item.location.category not in DETACHED_CATEGORIES: store.update_children(parent_location, parent.children + [new_item.location.url()]) + '''update_children updates the the item as it exists in the modulestore, but doesn't + update the instance we are working with, so have to refetch the item after updating it.''' new_item = store.get_item(new_item.location) return new_item diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 9e6a371552..eb732311cf 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -245,8 +245,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): if descriptor.lms.start is not None: now = datetime.now(UTC()) effective_start = _adjust_start_date_for_beta_testers(user, descriptor) - difference = (now - effective_start).total_seconds() - if difference > 3600: + if now > effective_start: # after start date, everyone can see it debug("Allow: now > effective start date") return True diff --git a/lms/djangoapps/courseware/tests/check_request_code.py b/lms/djangoapps/courseware/tests/check_request_code.py new file mode 100644 index 0000000000..1393d2fe17 --- /dev/null +++ b/lms/djangoapps/courseware/tests/check_request_code.py @@ -0,0 +1,24 @@ + + +def check_for_get_code(code, url): + """ + Check that we got the expected code when accessing url via GET. + Returns the response. + """ + resp = self.client.get(url) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp + + +def check_for_post_code(code, url, data={}): + """ + Check that we got the expected code when accessing url via POST. + Returns the response. + """ + resp = self.client.post(url, data) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py new file mode 100644 index 0000000000..99da5e9061 --- /dev/null +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -0,0 +1,142 @@ +import json + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse + +from student.models import Registration + +from django.test import TestCase + + +def check_for_get_code(self, code, url): + """ + Check that we got the expected code when accessing url via GET. + Returns the HTTP response. + 'self' is a class that subclasses TestCase. + """ + resp = self.client.get(url) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (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. + Returns the HTTP response. + 'self' is a class that subclasses TestCase. + """ + resp = self.client.post(url, data) + self.assertEqual(resp.status_code, code, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp + + +class LoginEnrollmentTestCase(TestCase): + + def setup_user(self): + """ + Create a user account, activate, and log in. + """ + self.email = 'foo@test.com' + self.password = 'bar' + self.username = 'test' + self.create_account(self.username, + self.email, self.password) + self.activate_user(self.email) + self.login(self.email, self.password) + + # ============ User creation and login ============== + + def login(self, email, password): + """ + Login, check that the corresponding view's response has a 200 status code. + """ + resp = resp = self.client.post(reverse('login'), + {'email': email, 'password': password}) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + self.assertTrue(data['success']) + + def logout(self): + """ + Logout, check that it worked. + Returns an HTTP response which e + """ + resp = self.client.get(reverse('logout'), {}) + # should redirect + self.assertEqual(resp.status_code, 302) + + def create_account(self, username, email, password): + """ + Create the account and check that it worked. + """ + resp = self.client.post(reverse('create_account'), { + 'username': username, + 'email': email, + 'password': password, + 'name': 'username', + 'terms_of_service': 'true', + 'honor_code': 'true', + }) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.content) + self.assertEqual(data['success'], True) + + # Check both that the user is created, and inactive + self.assertFalse(User.objects.get(email=email).is_active) + + def activate_user(self, email): + """ + Look up the activation key for the user, then hit the activate view. + No error checking. + """ + activation_key = Registration.objects.get(user__email=email).activation_key + + # and now we try to activate + url = reverse('activate', kwargs={'key': activation_key}) + + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + # Now make sure that the user is now actually activated + self.assertTrue(User.objects.get(email=email).is_active) + + def enroll(self, course, verify=False): + """ + Try to enroll and return boolean indicating result. + 'course' is an instance of CourseDescriptor. + 'verify' is an optional parameter specifying whether we + want to verify that the student was successfully enrolled + in the course. + """ + resp = self.client.post(reverse('change_enrollment'), { + 'enrollment_action': 'enroll', + 'course_id': course.id, + }) + print ('Enrollment in %s result status code: %s' + % (course.location.url(), str(resp.status_code))) + result = resp.status_code == 200 + if verify: + self.assertTrue(result) + return result + + # def enroll(self, course): + # """ + # Enroll the currently logged-in user, and check that it worked. + # """ + + # result = self.try_enroll(course) + # self.assertTrue(result) + + def unenroll(self, course): + """ + Unenroll the currently logged-in user, and check that it worked. + 'course' is an instance of CourseDescriptor. + """ + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'unenroll', + 'course_id': course.id, + }) + self.assertEqual(resp.status_code, 200) diff --git a/lms/djangoapps/courseware/tests/modulestore_config.py b/lms/djangoapps/courseware/tests/modulestore_config.py new file mode 100644 index 0000000000..81d0f4f911 --- /dev/null +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -0,0 +1,72 @@ +from uuid import uuid4 + +from django.conf import settings + + +def mongo_store_config(data_dir): + ''' + Defines default module store using MongoModuleStore + + Use of this config requires mongo to be running + ''' + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string' + } + } + } + store['direct'] = store['default'] + return store + + +def draft_mongo_store_config(data_dir): + '''Defines default module store using DraftMongoModuleStore''' + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + } + } + + +def xml_store_config(data_dir): + '''Defines default module store using XMLModuleStore''' + 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) +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) diff --git a/lms/djangoapps/courseware/tests/mongo_login_helpers.py b/lms/djangoapps/courseware/tests/mongo_login_helpers.py deleted file mode 100644 index a329f71d13..0000000000 --- a/lms/djangoapps/courseware/tests/mongo_login_helpers.py +++ /dev/null @@ -1,172 +0,0 @@ -import logging -import json - -from urlparse import urlsplit, urlunsplit - -from django.contrib.auth.models import User -from django.core.urlresolvers import reverse - -from student.models import Registration - -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase - -log = logging.getLogger("mitx." + __name__) - - -def parse_json(response): - """Parse response, which is assumed to be json""" - return json.loads(response.content) - - -def get_user(email): - '''look up a user by email''' - return User.objects.get(email=email) - - -def get_registration(email): - '''look up registration object by email''' - return Registration.objects.get(user__email=email) - - -class MongoLoginHelpers(ModuleStoreTestCase): - - def assertRedirectsNoFollow(self, response, expected_url): - """ - http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ - - Don't check that the redirected-to page loads--there should be other tests for that. - - Some of the code taken from django.test.testcases.py - """ - self.assertEqual(response.status_code, 302, - 'Response status code was %d instead of 302' - % (response.status_code)) - url = response['Location'] - - 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)) - - self.assertEqual(url, expected_url, - "Response redirected to '%s', expected '%s'" % - (url, expected_url)) - - def setup_viewtest_user(self): - '''create a user account, activate, and log in''' - self.viewtest_email = 'view@test.com' - self.viewtest_password = 'foo' - self.viewtest_username = 'viewtest' - self.create_account(self.viewtest_username, - self.viewtest_email, self.viewtest_password) - self.activate_user(self.viewtest_email) - self.login(self.viewtest_email, self.viewtest_password) - - # ============ User creation and login ============== - - def _login(self, email, password): - '''Login. View should always return 200. The success/fail is in the - returned json''' - resp = self.client.post(reverse('login'), - {'email': email, 'password': password}) - self.assertEqual(resp.status_code, 200) - return resp - - def login(self, email, password): - '''Login, check that it worked.''' - resp = self._login(email, password) - data = parse_json(resp) - self.assertTrue(data['success']) - return resp - - def logout(self): - '''Logout, check that it worked.''' - resp = self.client.get(reverse('logout'), {}) - # should redirect - self.assertEqual(resp.status_code, 302) - return resp - - def _create_account(self, username, email, password): - '''Try to create an account. No error checking''' - resp = self.client.post('/create_account', { - 'username': username, - 'email': email, - 'password': password, - 'name': 'Fred Weasley', - 'terms_of_service': 'true', - 'honor_code': 'true', - }) - return resp - - def create_account(self, username, email, password): - '''Create the account and check that it worked''' - resp = self._create_account(username, email, password) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['success'], True) - - # Check both that the user is created, and inactive - self.assertFalse(get_user(email).is_active) - - return resp - - def _activate_user(self, email): - '''Look up the activation key for the user, then hit the activate view. - No error checking''' - activation_key = get_registration(email).activation_key - - # and now we try to activate - url = reverse('activate', kwargs={'key': activation_key}) - resp = self.client.get(url) - return resp - - def activate_user(self, email): - resp = self._activate_user(email) - self.assertEqual(resp.status_code, 200) - # Now make sure that the user is now actually activated - self.assertTrue(get_user(email).is_active) - - def try_enroll(self, course): - """Try to enroll. Return bool success instead of asserting it.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'enroll', - 'course_id': course.id, - }) - print ('Enrollment in %s result status code: %s' - % (course.location.url(), str(resp.status_code))) - return resp.status_code == 200 - - def enroll(self, course): - """Enroll the currently logged-in user, and check that it worked.""" - result = self.try_enroll(course) - self.assertTrue(result) - - def unenroll(self, course): - """Unenroll the currently logged-in user, and check that it worked.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'unenroll', - 'course_id': course.id, - }) - self.assertTrue(resp.status_code == 200) - - def check_for_get_code(self, code, url): - """ - Check that we got the expected code when accessing url via GET. - Returns the response. - """ - resp = self.client.get(url) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (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. - Returns the response. - """ - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp diff --git a/lms/djangoapps/courseware/tests/test_draft_modulestore.py b/lms/djangoapps/courseware/tests/test_draft_modulestore.py new file mode 100644 index 0000000000..db6d4c45b5 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_draft_modulestore.py @@ -0,0 +1,21 @@ +from django.test import TestCase +from django.test.utils import override_settings + +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location + +from modulestore_config import TEST_DATA_DRAFT_MONGO_MODULESTORE + + +@override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) +class TestDraftModuleStore(TestCase): + def test_get_items_with_course_items(self): + store = modulestore() + + # fix was to allow get_items() to take the course_id parameter + store.get_items(Location(None, None, 'vertical', None, None), + course_id='abc', depth=0) + + # test success is just getting through the above statement. + # The bug was that 'course_id' argument was + # not allowed to be passed in (i.e. was throwing exception) diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index f9ddf88b5f..4b9a5a578c 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -14,11 +14,13 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import User, Group from courseware.access import _course_staff_group_name -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user +from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django import json + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): ''' @@ -41,7 +43,7 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): def make_instructor(course): group_name = _course_staff_group_name(course.location) g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) + g.user_set.add(User.objects.get(email=self.instructor)) make_instructor(self.graded_course) @@ -67,7 +69,6 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): self.assertTrue(sdebug in resp.content) - def toggle_masquerade(self): ''' Toggle masquerade state diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index 242379d8ca..9f9bf7ba92 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -1,378 +1,24 @@ -import logging -import json -import random - -from urlparse import urlsplit, urlunsplit -from uuid import uuid4 - -from django.contrib.auth.models import User -from django.test import TestCase -from django.conf import settings from django.core.urlresolvers import reverse from django.test.utils import override_settings -import xmodule.modulestore.django - -from student.models import Registration -from xmodule.error_module import ErrorDescriptor -from xmodule.modulestore.django import modulestore -from xmodule.modulestore import Location -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml import XMLModuleStore - from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from mongo_login_helpers import * +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -log = logging.getLogger("mitx." + __name__) +import xmodule.modulestore.django - -def parse_json(response): - """Parse response, which is assumed to be json""" - return json.loads(response.content) - - -def get_user(email): - '''look up a user by email''' - return User.objects.get(email=email) - - -def get_registration(email): - '''look up registration object by email''' - return Registration.objects.get(user__email=email) - - -def mongo_store_config(data_dir): - ''' - Defines default module store using MongoModuleStore - - Use of this config requires mongo to be running - ''' - store = { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } - } - } - store['direct'] = store['default'] - return store - - -def xml_store_config(data_dir): - '''Defines default module store using XMLModuleStore''' - 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) -TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) - - -class LoginEnrollmentTestCase(TestCase): - - ''' - Base TestCase providing support for user creation, - activation, login, and course enrollment - ''' - - def assertRedirectsNoFollow(self, response, expected_url): - """ - http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ - - Don't check that the redirected-to page loads--there should be other tests for that. - - Some of the code taken from django.test.testcases.py - """ - self.assertEqual(response.status_code, 302, - 'Response status code was %d instead of 302' - % (response.status_code)) - url = response['Location'] - - 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)) - - self.assertEqual(url, expected_url, - "Response redirected to '%s', expected '%s'" % - (url, expected_url)) - - def setup_viewtest_user(self): - '''create a user account, activate, and log in''' - self.viewtest_email = 'view@test.com' - self.viewtest_password = 'foo' - self.viewtest_username = 'viewtest' - self.create_account(self.viewtest_username, - self.viewtest_email, self.viewtest_password) - self.activate_user(self.viewtest_email) - self.login(self.viewtest_email, self.viewtest_password) - - # ============ User creation and login ============== - - def _login(self, email, password): - '''Login. View should always return 200. The success/fail is in the - returned json''' - resp = self.client.post(reverse('login'), - {'email': email, 'password': password}) - self.assertEqual(resp.status_code, 200) - return resp - - def login(self, email, password): - '''Login, check that it worked.''' - resp = self._login(email, password) - data = parse_json(resp) - self.assertTrue(data['success']) - return resp - - def logout(self): - '''Logout, check that it worked.''' - resp = self.client.get(reverse('logout'), {}) - # should redirect - self.assertEqual(resp.status_code, 302) - return resp - - def _create_account(self, username, email, password): - '''Try to create an account. No error checking''' - resp = self.client.post('/create_account', { - 'username': username, - 'email': email, - 'password': password, - 'name': 'Fred Weasley', - 'terms_of_service': 'true', - 'honor_code': 'true', - }) - return resp - - def create_account(self, username, email, password): - '''Create the account and check that it worked''' - resp = self._create_account(username, email, password) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['success'], True) - - # Check both that the user is created, and inactive - self.assertFalse(get_user(email).is_active) - - return resp - - def _activate_user(self, email): - '''Look up the activation key for the user, then hit the activate view. - No error checking''' - activation_key = get_registration(email).activation_key - - # and now we try to activate - url = reverse('activate', kwargs={'key': activation_key}) - resp = self.client.get(url) - return resp - - def activate_user(self, email): - resp = self._activate_user(email) - self.assertEqual(resp.status_code, 200) - # Now make sure that the user is now actually activated - self.assertTrue(get_user(email).is_active) - - def try_enroll(self, course): - """Try to enroll. Return bool success instead of asserting it.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'enroll', - 'course_id': course.id, - }) - print ('Enrollment in %s result status code: %s' - % (course.location.url(), str(resp.status_code))) - return resp.status_code == 200 - - def enroll(self, course): - """Enroll the currently logged-in user, and check that it worked.""" - result = self.try_enroll(course) - self.assertTrue(result) - - def unenroll(self, course): - """Unenroll the currently logged-in user, and check that it worked.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'unenroll', - 'course_id': course.id, - }) - self.assertTrue(resp.status_code == 200) - - def check_for_get_code(self, code, url): - """ - Check that we got the expected code when accessing url via GET. - Returns the response. - """ - resp = self.client.get(url) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (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. - Returns the response. - """ - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp - - -class ActivateLoginTest(LoginEnrollmentTestCase): - '''Test logging in and logging out''' - def setUp(self): - self.setup_viewtest_user() - - def test_activate_login(self): - '''Test login -- the setup function does all the work''' - pass - - def test_logout(self): - '''Test logout -- setup function does login''' - self.logout() - - -class PageLoaderTestCase(LoginEnrollmentTestCase): - ''' Base class that adds a function to load all pages in a modulestore ''' - - def check_random_page_loads(self, module_store): - ''' - Choose a page in the course randomly, and assert that it loads - ''' - # enroll in the course before trying to access pages - courses = module_store.get_courses() - self.assertEqual(len(courses), 1) - course = courses[0] - self.enroll(course) - course_id = course.id - - # Search for items in the course - # None is treated as a wildcard - course_loc = course.location - location_query = Location(course_loc.tag, course_loc.org, - course_loc.course, None, None, None) - - items = module_store.get_items(location_query) - - if len(items) < 1: - self.fail('Could not retrieve any items from course') - else: - descriptor = random.choice(items) - - # We have ancillary course information now as modules - # and we can't simply use 'jump_to' to view them - if descriptor.location.category == 'about': - self._assert_loads('about_course', - {'course_id': course_id}, - descriptor) - - elif descriptor.location.category == 'static_tab': - kwargs = {'course_id': course_id, - 'tab_slug': descriptor.location.name} - self._assert_loads('static_tab', kwargs, descriptor) - - elif descriptor.location.category == 'course_info': - self._assert_loads('info', {'course_id': course_id}, - descriptor) - - elif descriptor.location.category == 'custom_tag_template': - pass - - else: - - kwargs = {'course_id': course_id, - 'location': descriptor.location.url()} - - self._assert_loads('jump_to', kwargs, descriptor, - expect_redirect=True, - check_content=True) - - def _assert_loads(self, django_url, kwargs, descriptor, - expect_redirect=False, - check_content=False): - ''' - Assert that the url loads correctly. - If expect_redirect, then also check that we were redirected. - If check_content, then check that we don't get - an error message about unavailable modules. - ''' - - url = reverse(django_url, kwargs=kwargs) - response = self.client.get(url, follow=True) - - if response.status_code != 200: - self.fail('Status %d for page %s' % - (response.status_code, descriptor.location.url())) - - if expect_redirect: - self.assertEqual(response.redirect_chain[0][1], 302) - - if check_content: - unavailable_msg = "this module is temporarily unavailable" - self.assertEqual(response.content.find(unavailable_msg), -1) - self.assertFalse(isinstance(descriptor, ErrorDescriptor)) - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): - '''Check that all pages in test courses load properly from XML''' - - def setUp(self): - super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() - self.setup_viewtest_user() - xmodule.modulestore.django._MODULESTORES = {} - - def test_toy_course_loads(self): - module_class = 'xmodule.hidden_module.HiddenDescriptor' - module_store = XMLModuleStore(TEST_DATA_DIR, - default_class=module_class, - course_dirs=['toy'], - load_error_modules=True) - - self.check_random_page_loads(module_store) +from helpers import LoginEnrollmentTestCase, check_for_get_code +from modulestore_config import TEST_DATA_MONGO_MODULESTORE @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): - '''Check that all pages in test courses load properly from Mongo''' +class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): - def setUp(self): - super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() - self.setup_viewtest_user() - xmodule.modulestore.django._MODULESTORES = {} - modulestore().collection.drop() + STUDENT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] - def test_toy_course_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['toy']) - self.check_random_page_loads(module_store) - - def test_full_textbooks_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['full']) - - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) - - self.assertGreater(len(course.textbooks), 0) - - -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestNavigation(MongoLoginHelpers): - - """Check that navigation state is saved properly""" + """ + Check that navigation state is saved properly. + """ def setUp(self): xmodule.modulestore.django._MODULESTORES = {} @@ -388,52 +34,67 @@ class TestNavigation(MongoLoginHelpers): self.section9 = ItemFactory.create(parent_location=self.chapter9.location, display_name='factory_section') - #Create two accounts - self.student = 'view@test.com' - self.student2 = 'view2@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.create_account('u2', self.student2, self.password) - self.activate_user(self.student) - self.activate_user(self.student2) + # Create student accounts and activate them. + for i in range(len(self.STUDENT_INFO)): + self.create_account('u{0}'.format(i), self.STUDENT_INFO[i][0], self.STUDENT_INFO[i][1]) + self.activate_user(self.STUDENT_INFO[i][0]) - def test_accordion_state(self): - """Make sure that the accordion remembers where you were properly""" - self.login(self.student, self.password) - self.enroll(self.course) - self.enroll(self.full) - - # First request should redirect to ToyVideos + def test_redirects_first_time(self): + """ + Verify that the first time we click on the courseware tab we are + redirected to the 'Welcome' section. + """ + self.login(self.STUDENT_INFO[0][0], self.STUDENT_INFO[0][1]) + self.enroll(self.course, True) + self.enroll(self.full, True) resp = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) - # Don't use no-follow, because state should - # only be saved once we actually hit the section self.assertRedirects(resp, reverse( 'courseware_section', kwargs={'course_id': self.course.id, 'chapter': 'Overview', 'section': 'Welcome'})) - # Hitting the couseware tab again should - # redirect to the first chapter: 'Overview' + def test_redirects_second_time(self): + """ + Verify the accordion remembers we've already visited the Welcome section + and redirects correpondingly. + """ + self.login(self.STUDENT_INFO[0][0], self.STUDENT_INFO[0][1]) + self.enroll(self.course, True) + self.enroll(self.full, True) + + self.client.get(reverse('courseware_section', kwargs={'course_id': self.course.id, + 'chapter': 'Overview', + 'section': 'Welcome'})) + resp = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) - self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.course.id, - 'chapter': 'Overview'})) + self.assertRedirects(resp, reverse('courseware_chapter', + kwargs={'course_id': self.course.id, + 'chapter': 'Overview'})) - # Now we directly navigate to a section in a different chapter - self.check_for_get_code(200, reverse('courseware_section', - kwargs={'course_id': self.course.id, - 'chapter': 'factory_chapter', - 'section': 'factory_section'})) + def test_accordion_state(self): + """ + Verify the accordion remembers which chapter you were last viewing. + """ - # And now hitting the courseware tab should redirect to 'secret:magic' + self.login(self.STUDENT_INFO[0][0], self.STUDENT_INFO[0][1]) + self.enroll(self.course, True) + self.enroll(self.full, True) + + # Now we directly navigate to a section in a chapter other than 'Overview'. + check_for_get_code(self, 200, reverse('courseware_section', + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter', + 'section': 'factory_section'})) + + # And now hitting the courseware tab should redirect to 'factory_chapter' resp = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) - self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.course.id, - 'chapter': 'factory_chapter'})) + self.assertRedirects(resp, reverse('courseware_chapter', + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter'})) diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index da4f40e0db..ffae4688bf 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -1,80 +1,56 @@ -import logging import datetime import pytz import random -from uuid import uuid4 +import xmodule.modulestore.django from django.contrib.auth.models import User, Group from django.conf import settings from django.core.urlresolvers import reverse from django.test.utils import override_settings -import xmodule.modulestore.django - # Need access to internal func to put users in the right group from courseware.access import (has_access, _course_staff_group_name, course_beta_test_group_name) -from mongo_login_helpers import MongoLoginHelpers +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -log = logging.getLogger("mitx." + __name__) - - -def get_user(email): - '''look up a user by email''' - return User.objects.get(email=email) - - -def update_course(course, data): - """ - Updates the version of course in the mongo modulestore - with the metadata in data and returns the updated version. - """ - - store = xmodule.modulestore.django.modulestore() - - store.update_item(course.location, data) - - store.update_metadata(course.location, data) - - updated_course = store.get_instance(course.id, course.location) - - return updated_course - - -def mongo_store_config(data_dir): - ''' - Defines default module store using MongoModuleStore - - Use of this config requires mongo to be running - ''' - store = { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } - } - } - store['direct'] = store['default'] - return store - - -TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +from helpers import LoginEnrollmentTestCase, check_for_get_code +from modulestore_config import TEST_DATA_MONGO_MODULESTORE @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestViewAuth(MongoLoginHelpers): - """Check that view authentication works properly""" +class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Check that view authentication works properly. + """ + + ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] + + @classmethod + def _instructor_urls(self, course): + """ + List of urls that only instructors/staff should be able to see. + """ + urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( + 'instructor_dashboard', + 'gradebook', + 'grade_summary',)] + + urls.append(reverse('student_progress', + kwargs={'course_id': course.id, + 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id})) + return urls + + @staticmethod + def _reverse_urls(names, course): + """ + Reverse a list of course urls. + """ + return [reverse(name, kwargs={'course_id': course.id}) + for name in names] def setUp(self): xmodule.modulestore.django._MODULESTORES = {} @@ -87,98 +63,105 @@ class TestViewAuth(MongoLoginHelpers): display_name='courseware') self.sub_overview_chapter = ItemFactory.create(parent_location=self.sub_courseware_chapter.location, display_name='Overview') - self.progress_chapter = ItemFactory.create(parent_location=self.course.location, - display_name='progress') - self.info_chapter = ItemFactory.create(parent_location=self.course.location, - display_name='info') self.welcome_section = ItemFactory.create(parent_location=self.overview_chapter.location, display_name='Welcome') - self.somewhere_in_progress = ItemFactory.create(parent_location=self.progress_chapter.location, - display_name='1') - # Create two accounts - self.student = 'view@test.com' - self.instructor = 'view2@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.create_account('u2', self.instructor, self.password) - self.activate_user(self.student) - self.activate_user(self.instructor) + # Create two accounts and activate them. + for i in range(len(self.ACCOUNT_INFO)): + self.create_account('u{0}'.format(i), self.ACCOUNT_INFO[i][0], self.ACCOUNT_INFO[i][1]) + self.activate_user(self.ACCOUNT_INFO[i][0]) - def test_instructor_pages(self): - """Make sure only instructors for the course - or staff can load the instructor - dashboard, the grade views, and student profile pages""" + def test_redirection_unenrolled(self): + """ + Verify unenrolled student is redirected to the 'about' section of the chapter + instead of the 'Welcome' section after clicking on the courseware tab. + """ + + self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + response = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + self.assertRedirects(response, + reverse('about_course', + args=[self.course.id])) + + def test_redirection_enrolled(self): + """ + Verify enrolled student is redirected to the 'Welcome' section of + the chapter after clicking on the courseware tab. + """ + + self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + self.enroll(self.course) - # First, try with an enrolled student - self.login(self.student, self.password) - # shouldn't work before enroll response = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) - self.assertRedirectsNoFollow(response, - reverse('about_course', - args=[self.course.id])) + self.assertRedirects(response, + reverse('courseware_section', + kwargs={'course_id': self.course.id, + 'chapter': 'Overview', + 'section': 'Welcome'})) + + def test_instructor_page_access_nonstaff(self): + """ + Verify non-staff cannot load the instructor + dashboard, the grade views, and student profile pages. + """ + + self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + self.enroll(self.course) self.enroll(self.full) - # should work now -- redirect to first page - response = self.client.get(reverse('courseware', - kwargs={'course_id': self.course.id})) - - self.assertRedirectsNoFollow(response, - reverse('courseware_section', - kwargs={'course_id': self.course.id, - 'chapter': 'Overview', - 'section': 'Welcome'})) - - def instructor_urls(course): - "list of urls that only instructors/staff should be able to see" - urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( - 'instructor_dashboard', - 'gradebook', - 'grade_summary',)] - - urls.append(reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id})) - return urls # Randomly sample an instructor page - url = random.choice(instructor_urls(self.course) + - instructor_urls(self.full)) + url = random.choice(self._instructor_urls(self.course) + + self._instructor_urls(self.full)) # Shouldn't be able to get to the instructor pages print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) + check_for_get_code(self, 404, url) - # Make the instructor staff in the toy course + def test_instructor_course_access(self): + """ + Verify instructor can load the instructor dashboard, the grade views, + and student profile pages for their course. + """ + + # Make the instructor staff in self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) + group.user_set.add(User.objects.get(email=self.ACCOUNT_INFO[1][0])) - self.logout() - self.login(self.instructor, self.password) + self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) - # Now should be able to get to the toy course, but not the full course - url = random.choice(instructor_urls(self.course)) + # Now should be able to get to self.course, but not self.full + url = random.choice(self._instructor_urls(self.course)) print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + check_for_get_code(self, 200, url) - url = random.choice(instructor_urls(self.full)) + url = random.choice(self._instructor_urls(self.full)) print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) + check_for_get_code(self, 404, url) - # now also make the instructor staff - instructor = get_user(self.instructor) + def test_instructor_as_staff_access(self): + """ + Verify the instructor can load staff pages if he is given + staff permissions. + """ + + self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) + + # now make the instructor also staff + instructor = User.objects.get(email=self.ACCOUNT_INFO[1][0]) instructor.is_staff = True instructor.save() # and now should be able to load both - url = random.choice(instructor_urls(self.course) + - instructor_urls(self.full)) + url = random.choice(self._instructor_urls(self.course) + + self._instructor_urls(self.full)) print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + check_for_get_code(self, 200, url) def run_wrapped(self, test): """ @@ -196,42 +179,47 @@ class TestViewAuth(MongoLoginHelpers): 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""" + """ + Make sure that before course start, students can't access course + pages, but instructors can. + """ self.run_wrapped(self._do_test_dark_launch) def test_enrollment_period(self): - """Check that enrollment periods work""" + """ + Check that enrollment periods work. + """ self.run_wrapped(self._do_test_enrollment_period) def test_beta_period(self): - """Check that beta-test access works""" + """ + Check that beta-test access works. + """ self.run_wrapped(self._do_test_beta_period) def _do_test_dark_launch(self): - """Actually do the test, relying on settings to be right.""" + """ + Actually do the test, relying on settings to be right. + """ # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) - self.course.lms.start = tomorrow - self.full.lms.start = tomorrow + course_data = {'start': tomorrow} + full_data = {'start': tomorrow} + self.course = self.update_course(self.course, course_data) + self.full = self.update_course(self.full, full_data) self.assertFalse(self.course.has_started()) self.assertFalse(self.full.has_started()) self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - def reverse_urls(names, course): - """Reverse a list of course urls""" - return [reverse(name, kwargs={'course_id': course.id}) - for name in names] - def dark_student_urls(course): """ - list of urls that students should be able to see only + List of urls that students should be able to see only after launch, but staff should see before """ - urls = reverse_urls(['info', 'progress'], course) + urls = self._reverse_urls(['info', 'progress'], course) urls.extend([ reverse('book', kwargs={'course_id': course.id, 'book_index': index}) @@ -241,38 +229,50 @@ class TestViewAuth(MongoLoginHelpers): def light_student_urls(course): """ - list of urls that students should be able to see before + List of urls that students should be able to see before launch. """ - urls = reverse_urls(['about_course'], course) + urls = self._reverse_urls(['about_course'], course) urls.append(reverse('courses')) return urls def instructor_urls(course): - """list of urls that only instructors/staff should be able to see""" - urls = reverse_urls(['instructor_dashboard', - 'gradebook', 'grade_summary'], course) + """ + List of urls that only instructors/staff should be able to see. + """ + urls = self._reverse_urls(['instructor_dashboard', + 'gradebook', 'grade_summary'], course) return urls - def check_non_staff(course): - """Check that access is right for non-staff in course""" + def check_non_staff_light(course): + """ + Check that non-staff have access to light urls. + """ + print '=== Checking non-staff access for {0}'.format(course.id) + + # Randomly sample a light url + url = random.choice(light_student_urls(course)) + print 'checking for 200 on {0}'.format(url) + check_for_get_code(self, 200, url) + + def check_non_staff_dark(course): + """ + Check that non-staff don't have access to dark urls. + """ print '=== Checking non-staff access for {0}'.format(course.id) # Randomly sample a dark url url = random.choice(instructor_urls(course) + dark_student_urls(course) + - reverse_urls(['courseware'], course)) + self._reverse_urls(['courseware'], course)) print 'checking for 404 on {0}'.format(url) - self.check_for_get_code(404, url) - - # Randomly sample a light url - url = random.choice(light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + check_for_get_code(self, 404, url) def check_staff(course): - """Check that access is right for staff in course""" + """ + Check that access is right for staff in course. + """ print '=== Checking staff access for {0}'.format(course.id) # Randomly sample a url @@ -280,7 +280,7 @@ class TestViewAuth(MongoLoginHelpers): dark_student_urls(course) + light_student_urls(course)) print 'checking for 200 on {0}'.format(url) - self.check_for_get_code(200, url) + check_for_get_code(self, 200, url) # The student progress tab is not accessible to a student # before launch, so the instructor view-as-student feature @@ -290,43 +290,46 @@ class TestViewAuth(MongoLoginHelpers): # user (the student), and the requesting user (the prof) url = reverse('student_progress', kwargs={'course_id': course.id, - 'student_id': get_user(self.student).id}) + 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id}) print 'checking for 404 on view-as-student: {0}'.format(url) - self.check_for_get_code(404, url) + check_for_get_code(self, 404, url) # The courseware url should redirect, not 200 - url = reverse_urls(['courseware'], course)[0] - self.check_for_get_code(302, url) + url = self._reverse_urls(['courseware'], course)[0] + check_for_get_code(self, 302, url) # First, try with an enrolled student print '=== Testing student access....' - self.login(self.student, self.password) - self.enroll(self.course) - self.enroll(self.full) + self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + self.enroll(self.course, True) + self.enroll(self.full, True) # shouldn't be able to get to anything except the light pages - check_non_staff(self.course) - check_non_staff(self.full) + check_non_staff_light(self.course) + check_non_staff_dark(self.course) + check_non_staff_light(self.full) + check_non_staff_dark(self.full) print '=== Testing course instructor access....' - # Make the instructor staff in the toy course + # Make the instructor staff in self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) + group.user_set.add(User.objects.get(email=self.ACCOUNT_INFO[1][0])) self.logout() - self.login(self.instructor, self.password) + self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) # Enroll in the classes---can't see courseware otherwise. - self.enroll(self.course) - self.enroll(self.full) + self.enroll(self.course, True) + self.enroll(self.full, True) # should now be able to get to everything for self.course - check_non_staff(self.full) + check_non_staff_light(self.full) + check_non_staff_dark(self.full) check_staff(self.course) print '=== Testing staff access....' # now also make the instructor staff - instructor = get_user(self.instructor) + instructor = User.objects.get(email=self.ACCOUNT_INFO[1][0]) instructor.is_staff = True instructor.save() @@ -335,7 +338,9 @@ class TestViewAuth(MongoLoginHelpers): check_staff(self.full) def _do_test_enrollment_period(self): - """Actually do the test, relying on settings to be right.""" + """ + Actually do the test, relying on settings to be right. + """ # Make courses start in the future now = datetime.datetime.now(pytz.UTC) @@ -348,42 +353,44 @@ class TestViewAuth(MongoLoginHelpers): print "changing" # self.course's enrollment period hasn't started - self.course = update_course(self.course, course_data) + self.course = self.update_course(self.course, course_data) # full course's has - self.full = update_course(self.full, full_data) + self.full = self.update_course(self.full, full_data) print "login" # First, try with an enrolled student print '=== Testing student access....' - self.login(self.student, self.password) - self.assertFalse(self.try_enroll(self.course)) - self.assertTrue(self.try_enroll(self.full)) + self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + self.assertFalse(self.enroll(self.course)) + self.assertTrue(self.enroll(self.full)) print '=== Testing course instructor access....' - # Make the instructor staff in the toy course + # Make the instructor staff in the self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) + group.user_set.add(User.objects.get(email=self.ACCOUNT_INFO[1][0])) print "logout/login" self.logout() - self.login(self.instructor, self.password) - print "Instructor should be able to enroll in toy course" - self.assertTrue(self.try_enroll(self.course)) + self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) + print "Instructor should be able to enroll in self.course" + self.assertTrue(self.enroll(self.course)) print '=== Testing staff access....' # now make the instructor global staff, but not in the instructor group - group.user_set.remove(get_user(self.instructor)) - instructor = get_user(self.instructor) + group.user_set.remove(User.objects.get(email=self.ACCOUNT_INFO[1][0])) + instructor = User.objects.get(email=self.ACCOUNT_INFO[1][0]) instructor.is_staff = True instructor.save() # unenroll and try again self.unenroll(self.course) - self.assertTrue(self.try_enroll(self.course)) + self.assertTrue(self.enroll(self.course)) def _do_test_beta_period(self): - """Actually test beta periods, relying on settings to be right.""" + """ + Actually test beta periods, relying on settings to be right. + """ # trust, but verify :) self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) @@ -391,18 +398,17 @@ class TestViewAuth(MongoLoginHelpers): # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) - # nextday = tomorrow + 24 * 3600 - # yesterday = time.time() - 24 * 3600 + course_data = {'start': tomorrow} # self.course's hasn't started - self.course.lms.start = tomorrow + self.course = self.update_course(self.course, course_data) self.assertFalse(self.course.has_started()) # but should be accessible for beta testers self.course.lms.days_early_for_beta = 2 # student user shouldn't see it - student_user = get_user(self.student) + student_user = User.objects.get(email=self.ACCOUNT_INFO[0][0]) self.assertFalse(has_access(student_user, self.course, 'load')) # now add the student to the beta test group diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 3e39227171..43b190c04b 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,275 +1,172 @@ ''' Test for lms courseware app ''' -import logging -import json import random -from urlparse import urlsplit, urlunsplit -from uuid import uuid4 - -from django.contrib.auth.models import User, Group from django.test import TestCase -from django.test.client import RequestFactory -from django.conf import settings from django.core.urlresolvers import reverse from django.test.utils import override_settings import xmodule.modulestore.django -# Need access to internal func to put users in the right group -from courseware import grades -from courseware.model_data import ModelDataCache -from courseware.access import (has_access, _course_staff_group_name, - course_beta_test_group_name) - -from student.models import Registration from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml import XMLModuleStore -import datetime -from django.utils.timezone import UTC -from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from helpers import LoginEnrollmentTestCase +from modulestore_config import TEST_DATA_DIR, TEST_DATA_XML_MODULESTORE, TEST_DATA_MONGO_MODULESTORE, TEST_DATA_DRAFT_MONGO_MODULESTORE -log = logging.getLogger("mitx." + __name__) +class ActivateLoginTest(LoginEnrollmentTestCase): + """ + Test logging in and logging out. + """ + def setUp(self): + self.setup_user() - -def parse_json(response): - """Parse response, which is assumed to be json""" - return json.loads(response.content) - - -def get_user(email): - '''look up a user by email''' - return User.objects.get(email=email) - - -def get_registration(email): - '''look up registration object by email''' - return Registration.objects.get(user__email=email) - - -def mongo_store_config(data_dir): - ''' - Defines default module store using MongoModuleStore - - Use of this config requires mongo to be running - ''' - store = { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string' - } - } - } - store['direct'] = store['default'] - return store - - -def draft_mongo_store_config(data_dir): - '''Defines default module store using DraftMongoModuleStore''' - return { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } - }, - 'direct': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } - } - } - - -def xml_store_config(data_dir): - '''Defines default module store using XMLModuleStore''' - 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) -TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) -TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) - - -class LoginEnrollmentTestCase(TestCase): - - ''' - Base TestCase providing support for user creation, - activation, login, and course enrollment - ''' - - def assertRedirectsNoFollow(self, response, expected_url): + def test_activate_login(self): """ - http://devblog.point2.com/2010/04/23/djangos-assertredirects-little-gotcha/ - - Don't check that the redirected-to page loads--there should be other tests for that. - - Some of the code taken from django.test.testcases.py + Test login -- the setup function does all the work. """ - self.assertEqual(response.status_code, 302, - 'Response status code was %d instead of 302' - % (response.status_code)) - url = response['Location'] + pass - 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)) - - self.assertEqual(url, expected_url, - "Response redirected to '%s', expected '%s'" % - (url, expected_url)) - - def setup_viewtest_user(self): - '''create a user account, activate, and log in''' - self.viewtest_email = 'view@test.com' - self.viewtest_password = 'foo' - self.viewtest_username = 'viewtest' - self.create_account(self.viewtest_username, - self.viewtest_email, self.viewtest_password) - self.activate_user(self.viewtest_email) - self.login(self.viewtest_email, self.viewtest_password) - - # ============ User creation and login ============== - - def _login(self, email, password): - '''Login. View should always return 200. The success/fail is in the - returned json''' - resp = self.client.post(reverse('login'), - {'email': email, 'password': password}) - self.assertEqual(resp.status_code, 200) - return resp - - def login(self, email, password): - '''Login, check that it worked.''' - resp = self._login(email, password) - data = parse_json(resp) - self.assertTrue(data['success']) - return resp - - def logout(self): - '''Logout, check that it worked.''' - resp = self.client.get(reverse('logout'), {}) - # should redirect - self.assertEqual(resp.status_code, 302) - return resp - - def _create_account(self, username, email, password): - '''Try to create an account. No error checking''' - resp = self.client.post('/create_account', { - 'username': username, - 'email': email, - 'password': password, - 'name': 'Fred Weasley', - 'terms_of_service': 'true', - 'honor_code': 'true', - }) - return resp - - def create_account(self, username, email, password): - '''Create the account and check that it worked''' - resp = self._create_account(username, email, password) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['success'], True) - - # Check both that the user is created, and inactive - self.assertFalse(get_user(email).is_active) - - return resp - - def _activate_user(self, email): - '''Look up the activation key for the user, then hit the activate view. - No error checking''' - activation_key = get_registration(email).activation_key - - # and now we try to activate - url = reverse('activate', kwargs={'key': activation_key}) - resp = self.client.get(url) - return resp - - def activate_user(self, email): - resp = self._activate_user(email) - self.assertEqual(resp.status_code, 200) - # Now make sure that the user is now actually activated - self.assertTrue(get_user(email).is_active) - - def try_enroll(self, course): - """Try to enroll. Return bool success instead of asserting it.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'enroll', - 'course_id': course.id, - }) - print ('Enrollment in %s result status code: %s' - % (course.location.url(), str(resp.status_code))) - return resp.status_code == 200 - - def enroll(self, course): - """Enroll the currently logged-in user, and check that it worked.""" - result = self.try_enroll(course) - self.assertTrue(result) - - def unenroll(self, course): - """Unenroll the currently logged-in user, and check that it worked.""" - resp = self.client.post('/change_enrollment', { - 'enrollment_action': 'unenroll', - 'course_id': course.id, - }) - self.assertTrue(resp.status_code == 200) - - def check_for_get_code(self, code, url): + def test_logout(self): """ - Check that we got the expected code when accessing url via GET. - Returns the response. + Test logout -- setup function does login. """ - resp = self.client.get(url) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp + self.logout() - def check_for_post_code(self, code, url, data={}): + +class PageLoaderTestCase(LoginEnrollmentTestCase): + """ + Base class that adds a function to load all pages in a modulestore. + """ + + def check_random_page_loads(self, module_store): """ - Check that we got the expected code when accessing url via POST. - Returns the response. + Choose a page in the course randomly, and assert that it loads. """ - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp + # enroll in the course before trying to access pages + courses = module_store.get_courses() + self.assertEqual(len(courses), 1) + course = courses[0] + self.enroll(course, True) + course_id = course.id + + # Search for items in the course + # None is treated as a wildcard + course_loc = course.location + location_query = Location(course_loc.tag, course_loc.org, + course_loc.course, None, None, None) + + items = module_store.get_items(location_query) + + if len(items) < 1: + self.fail('Could not retrieve any items from course') + else: + descriptor = random.choice(items) + + # We have ancillary course information now as modules + # and we can't simply use 'jump_to' to view them + if descriptor.location.category == 'about': + self._assert_loads('about_course', + {'course_id': course_id}, + descriptor) + + elif descriptor.location.category == 'static_tab': + kwargs = {'course_id': course_id, + 'tab_slug': descriptor.location.name} + self._assert_loads('static_tab', kwargs, descriptor) + + elif descriptor.location.category == 'course_info': + self._assert_loads('info', {'course_id': course_id}, + descriptor) + + elif descriptor.location.category == 'custom_tag_template': + pass + + else: + + kwargs = {'course_id': course_id, + 'location': descriptor.location.url()} + + self._assert_loads('jump_to', kwargs, descriptor, + expect_redirect=True, + check_content=True) + + def _assert_loads(self, django_url, kwargs, descriptor, + expect_redirect=False, + check_content=False): + """ + Assert that the url loads correctly. + If expect_redirect, then also check that we were redirected. + If check_content, then check that we don't get + an error message about unavailable modules. + """ + + url = reverse(django_url, kwargs=kwargs) + response = self.client.get(url, follow=True) + + if response.status_code != 200: + self.fail('Status %d for page %s' % + (response.status_code, descriptor.location.url())) + + if expect_redirect: + self.assertEqual(response.redirect_chain[0][1], 302) + + if check_content: + unavailable_msg = "this module is temporarily unavailable" + self.assertEqual(response.content.find(unavailable_msg), -1) + self.assertFalse(isinstance(descriptor, ErrorDescriptor)) + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): + """ + Check that all pages in test courses load properly from XML. + """ + + def setUp(self): + super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() + self.setup_user() + xmodule.modulestore.django._MODULESTORES = {} + + def test_toy_course_loads(self): + module_class = 'xmodule.hidden_module.HiddenDescriptor' + module_store = XMLModuleStore(TEST_DATA_DIR, + default_class=module_class, + course_dirs=['toy'], + load_error_modules=True) + + self.check_random_page_loads(module_store) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): + """ + Check that all pages in test courses load properly from Mongo. + """ + + def setUp(self): + super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() + self.setup_user() + xmodule.modulestore.django._MODULESTORES = {} + modulestore().collection.drop() + + def test_toy_course_loads(self): + module_store = modulestore() + import_from_xml(module_store, TEST_DATA_DIR, ['toy']) + self.check_random_page_loads(module_store) + + def test_full_textbooks_loads(self): + module_store = modulestore() + import_from_xml(module_store, TEST_DATA_DIR, ['full']) + + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) @@ -284,134 +181,3 @@ class TestDraftModuleStore(TestCase): # test success is just getting through the above statement. # The bug was that 'course_id' argument was # not allowed to be passed in (i.e. was throwing exception) - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestSubmittingProblems(LoginEnrollmentTestCase): - """Check that a course gets graded properly""" - - # Subclasses should specify the course slug - course_slug = "UNKNOWN" - course_when = "UNKNOWN" - - def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - - course_name = "edX/%s/%s" % (self.course_slug, self.course_when) - self.course = modulestore().get_course(course_name) - assert self.course, "Couldn't load course %r" % course_name - - # create a test student - self.student = 'view@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.activate_user(self.student) - self.enroll(self.course) - - self.student_user = get_user(self.student) - - self.factory = RequestFactory() - - def problem_location(self, problem_url_name): - return "i4x://edX/{}/problem/{}".format(self.course_slug, problem_url_name) - - def modx_url(self, problem_location, dispatch): - return reverse( - 'modx_dispatch', - kwargs={ - 'course_id': self.course.id, - 'location': problem_location, - 'dispatch': dispatch, - } - ) - - def submit_question_answer(self, problem_url_name, responses): - """ - Submit answers to a question. - - Responses is a dict mapping problem ids (not sure of the right term) - to answers: - {'2_1': 'Correct', '2_2': 'Incorrect'} - - """ - problem_location = self.problem_location(problem_url_name) - modx_url = self.modx_url(problem_location, 'problem_check') - answer_key_prefix = 'input_i4x-edX-{}-problem-{}_'.format(self.course_slug, problem_url_name) - resp = self.client.post(modx_url, - { (answer_key_prefix + k): v for k, v in responses.items() } - ) - - return resp - - def reset_question_answer(self, problem_url_name): - '''resets specified problem for current user''' - problem_location = self.problem_location(problem_url_name) - modx_url = self.modx_url(problem_location, 'problem_reset') - resp = self.client.post(modx_url) - return resp - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestSchematicResponse(TestSubmittingProblems): - """Check that we can submit a schematic response, and it answers properly.""" - - course_slug = "embedded_python" - course_when = "2013_Spring" - - def test_schematic(self): - resp = self.submit_question_answer('schematic_problem', - { '2_1': json.dumps( - [['transient', {'Z': [ - [0.0000004, 2.8], - [0.0000009, 2.8], - [0.0000014, 2.8], - [0.0000019, 2.8], - [0.0000024, 2.8], - [0.0000029, 0.2], - [0.0000034, 0.2], - [0.0000039, 0.2] - ]}]] - ) - }) - respdata = json.loads(resp.content) - self.assertEqual(respdata['success'], 'correct') - - self.reset_question_answer('schematic_problem') - resp = self.submit_question_answer('schematic_problem', - { '2_1': json.dumps( - [['transient', {'Z': [ - [0.0000004, 2.8], - [0.0000009, 0.0], # wrong. - [0.0000014, 2.8], - [0.0000019, 2.8], - [0.0000024, 2.8], - [0.0000029, 0.2], - [0.0000034, 0.2], - [0.0000039, 0.2] - ]}]] - ) - }) - respdata = json.loads(resp.content) - self.assertEqual(respdata['success'], 'incorrect') - - def test_check_function(self): - resp = self.submit_question_answer('cfn_problem', {'2_1': "0, 1, 2, 3, 4, 5, 'Outside of loop', 6"}) - respdata = json.loads(resp.content) - self.assertEqual(respdata['success'], 'correct') - - self.reset_question_answer('cfn_problem') - - resp = self.submit_question_answer('cfn_problem', {'2_1': "xyzzy!"}) - respdata = json.loads(resp.content) - self.assertEqual(respdata['success'], 'incorrect') - - def test_computed_answer(self): - resp = self.submit_question_answer('computed_answer', {'2_1': "Xyzzy"}) - respdata = json.loads(resp.content) - self.assertEqual(respdata['success'], 'correct') - - self.reset_question_answer('computed_answer') - - resp = self.submit_question_answer('computed_answer', {'2_1': "NO!"}) - respdata = json.loads(resp.content) - self.assertEqual(respdata['success'], 'incorrect') diff --git a/lms/djangoapps/instructor/tests/test_download_csv.py b/lms/djangoapps/instructor/tests/test_download_csv.py index 29e18eee4d..fd5bd562ba 100644 --- a/lms/djangoapps/instructor/tests/test_download_csv.py +++ b/lms/djangoapps/instructor/tests/test_download_csv.py @@ -11,12 +11,13 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/inst from django.test.utils import override_settings # Need access to internal func to put users in the right group -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, User from django.core.urlresolvers import reverse from courseware.access import _course_staff_group_name -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user +from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django @@ -45,7 +46,7 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): def make_instructor(course): group_name = _course_staff_group_name(course.location) g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) + g.user_set.add(User.objects.get(email=self.instructor)) make_instructor(self.toy) @@ -72,7 +73,7 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): # All the not-actually-in-the-course hw and labs come from the # default grading policy string in graders.py expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final" -"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0" +"2","u2","username","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0" ''' self.assertEqual(body, expected_body, msg) diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index ce5f2d2e50..e70ccc6ffd 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -7,7 +7,8 @@ from django.test.utils import override_settings from django.contrib.auth.models import Group, User from django.core.urlresolvers import reverse from courseware.access import _course_staff_group_name -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user +from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django from student.models import CourseEnrollment, CourseEnrollmentAllowed @@ -40,7 +41,7 @@ class TestInstructorEnrollsStudent(LoginEnrollmentTestCase): def make_instructor(course): group_name = _course_staff_group_name(course.location) g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) + g.user_set.add(User.objects.get(email=self.instructor)) make_instructor(self.toy) diff --git a/lms/djangoapps/instructor/tests/test_forum_admin.py b/lms/djangoapps/instructor/tests/test_forum_admin.py index 7b4e729867..90dadd569e 100644 --- a/lms/djangoapps/instructor/tests/test_forum_admin.py +++ b/lms/djangoapps/instructor/tests/test_forum_admin.py @@ -6,7 +6,7 @@ Unit tests for instructor dashboard forum administration from django.test.utils import override_settings # Need access to internal func to put users in the right group -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, User from django.core.urlresolvers import reverse from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, \ @@ -14,7 +14,8 @@ from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, \ from django_comment_client.utils import has_forum_access from courseware.access import _course_staff_group_name -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user +from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django @@ -55,7 +56,7 @@ class TestInstructorDashboardForumAdmin(LoginEnrollmentTestCase): group_name = _course_staff_group_name(self.toy.location) g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) + g.user_set.add(User.objects.get(email=self.instructor)) self.logout() self.login(self.instructor, self.password) @@ -146,4 +147,4 @@ class TestInstructorDashboardForumAdmin(LoginEnrollmentTestCase): added_roles.append(rolename) added_roles.sort() roles = ', '.join(added_roles) - self.assertTrue(response.content.find('{0}'.format(roles)) >= 0, 'not finding roles "{0}"'.format(roles)) \ No newline at end of file + self.assertTrue(response.content.find('{0}'.format(roles)) >= 0, 'not finding roles "{0}"'.format(roles)) diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index 13d780df12..db19d212a2 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -8,8 +8,7 @@ import json from mock import MagicMock, patch, Mock from django.core.urlresolvers import reverse -from django.contrib.auth.models import Group -from django.http import HttpResponse +from django.contrib.auth.models import Group, User from django.conf import settings from mitxmako.shortcuts import render_to_string @@ -21,7 +20,6 @@ from xmodule.x_module import ModuleSystem from open_ended_grading import staff_grading_service, views from courseware.access import _course_staff_group_name -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user import logging @@ -31,6 +29,9 @@ from django.test.utils import override_settings from xmodule.tests import test_util_open_ended from courseware.tests import factories +from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.helpers import LoginEnrollmentTestCase, check_for_get_code, check_for_post_code + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestStaffGradingService(LoginEnrollmentTestCase): @@ -58,7 +59,7 @@ class TestStaffGradingService(LoginEnrollmentTestCase): def make_instructor(course): group_name = _course_staff_group_name(course.location) group = Group.objects.create(name=group_name) - group.user_set.add(get_user(self.instructor)) + group.user_set.add(User.objects.get(email=self.instructor)) make_instructor(self.toy) @@ -75,8 +76,8 @@ class TestStaffGradingService(LoginEnrollmentTestCase): # both get and post should return 404 for view_name in ('staff_grading_get_next', 'staff_grading_save_grade'): url = reverse(view_name, kwargs={'course_id': self.course_id}) - self.check_for_get_code(404, url) - self.check_for_post_code(404, url) + check_for_get_code(self, 404, url) + check_for_post_code(self, 404, url) def test_get_next(self): self.login(self.instructor, self.password) @@ -84,7 +85,7 @@ class TestStaffGradingService(LoginEnrollmentTestCase): url = reverse('staff_grading_get_next', kwargs={'course_id': self.course_id}) data = {'location': self.location} - response = self.check_for_post_code(200, url, data) + response = check_for_post_code(self, 200, url, data) content = json.loads(response.content) @@ -113,7 +114,7 @@ class TestStaffGradingService(LoginEnrollmentTestCase): if skip: data.update({'skipped': True}) - response = self.check_for_post_code(200, url, data) + response = check_for_post_code(self, 200, url, data) content = json.loads(response.content) self.assertTrue(content['success'], str(content)) self.assertEquals(content['submission_id'], self.mock_service.cnt) @@ -130,7 +131,7 @@ class TestStaffGradingService(LoginEnrollmentTestCase): url = reverse('staff_grading_get_problem_list', kwargs={'course_id': self.course_id}) data = {} - response = self.check_for_post_code(200, url, data) + response = check_for_post_code(self, 200, url, data) content = json.loads(response.content) self.assertTrue(content['success'], str(content)) diff --git a/lms/urls.py b/lms/urls.py index 74ac44cf59..2d85fe1e66 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -36,7 +36,7 @@ urlpatterns = ('', # nopep8 url(r'^login_ajax$', 'student.views.login_user', name="login"), url(r'^login_ajax/(?P[^/]*)$', 'student.views.login_user'), url(r'^logout$', 'student.views.logout_user', name='logout'), - url(r'^create_account$', 'student.views.create_account'), + url(r'^create_account$', 'student.views.create_account', name='create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name="activate"), url(r'^begin_exam_registration/(?P[^/]+/[^/]+/[^/]+)$', 'student.views.begin_exam_registration', name="begin_exam_registration"), From 58fe6d4e8367c570648068d3307cc3105a88edf5 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Fri, 21 Jun 2013 16:17:33 -0400 Subject: [PATCH 017/161] Cleaned up import and comment --- cms/djangoapps/contentstore/tests/test_contentstore.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index b0cbcee032..6d2055d459 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -45,7 +45,7 @@ import xmodule.contentstore.django import datetime from pytz import UTC from uuid import uuid4 -import pymongo +from pymongo import MongoClient TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) @@ -62,7 +62,6 @@ class MongoCollectionFindWrapper(object): return self.original(query, *args, **kwargs) -#@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ @@ -91,7 +90,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) def tearDown(self): - m = pymongo.MongoClient() + m = MongoClient() m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) xmodule.contentstore.django._CONTENTSTORE.clear() @@ -858,7 +857,7 @@ class ContentStoreTest(ModuleStoreTestCase): } def tearDown(self): - m = pymongo.MongoClient() + m = MongoClient() m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) xmodule.contentstore.django._CONTENTSTORE.clear() From 5e6de488abaa45f765b5aef48a1b36851a673be1 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Fri, 21 Jun 2013 16:28:32 -0400 Subject: [PATCH 018/161] Fixed pylint/pep8 violations --- .../contentstore/tests/test_contentstore.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 6d2055d459..514b631521 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -90,8 +90,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) def tearDown(self): - m = MongoClient() - m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) xmodule.contentstore.django._CONTENTSTORE.clear() def check_components_on_page(self, component_types, expected_types): @@ -414,7 +414,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(len(all_assets), 0) # make sure we have some thumbnails in our contentstore - all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location) + content_store.get_all_content_thumbnails_for_course(course_location) # # cdodge: temporarily comment out assertion on thumbnails because many environments @@ -543,7 +543,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): all_assets = trash_store.get_all_content_for_course(course_location) self.assertEqual(len(all_assets), 0) - all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) self.assertEqual(len(all_thumbnails), 0) @@ -608,7 +607,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertRaises(InvalidVersionError, draft_store.unpublish, location) - def test_bad_contentstore_request(self): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) @@ -857,8 +855,8 @@ class ContentStoreTest(ModuleStoreTestCase): } def tearDown(self): - m = MongoClient() - m.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) xmodule.contentstore.django._CONTENTSTORE.clear() def test_create_course(self): From 3f9a72e6ce805a63d091cc387b44021d079d46c4 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Fri, 21 Jun 2013 16:32:13 -0400 Subject: [PATCH 019/161] Consolidated imports --- cms/djangoapps/contentstore/tests/test_contentstore.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 514b631521..66fead562e 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -23,7 +23,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.store_utilities import delete_course from xmodule.modulestore.django import modulestore -from xmodule.contentstore.django import contentstore +from xmodule.contentstore.django import contentstore, _CONTENTSTORE from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint @@ -41,7 +41,6 @@ from xmodule.exceptions import NotFoundError from django_comment_common.utils import are_permissions_roles_seeded from xmodule.exceptions import InvalidVersionError -import xmodule.contentstore.django import datetime from pytz import UTC from uuid import uuid4 @@ -92,7 +91,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def tearDown(self): mongo = MongoClient() mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) - xmodule.contentstore.django._CONTENTSTORE.clear() + _CONTENTSTORE.clear() def check_components_on_page(self, component_types, expected_types): """ @@ -857,7 +856,7 @@ class ContentStoreTest(ModuleStoreTestCase): def tearDown(self): mongo = MongoClient() mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) - xmodule.contentstore.django._CONTENTSTORE.clear() + _CONTENTSTORE.clear() def test_create_course(self): """Test new course creation - happy path""" From e045860cb652686f8ab5bcaff659a636db6f4d32 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 20 Jun 2013 20:36:51 -0400 Subject: [PATCH 020/161] Pylint complains if you use string, even if you use it for what its still meant to be used for. --- common/djangoapps/external_auth/views.py | 2 +- common/djangoapps/student/views.py | 2 +- common/lib/symmath/symmath/formula.py | 2 +- lms/djangoapps/django_comment_client/tests.py | 2 +- lms/djangoapps/lms_migration/management/commands/create_user.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 93ab70debb..06709eff9e 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -3,7 +3,7 @@ import json import logging import random import re -import string +import string # pylint: disable=W0402 import fnmatch from textwrap import dedent diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e065333409..6b9c9104c5 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -4,7 +4,7 @@ import json import logging import random import re -import string +import string # pylint: disable=W0402 import urllib import uuid import time diff --git a/common/lib/symmath/symmath/formula.py b/common/lib/symmath/symmath/formula.py index ca4e20ace3..d5b97a2550 100644 --- a/common/lib/symmath/symmath/formula.py +++ b/common/lib/symmath/symmath/formula.py @@ -10,7 +10,7 @@ # Provides sympy representation. import os -import string +import string # pylint: disable=W0402 import re import logging import operator diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py index 8fd8ed7e2b..8c6a48d8c1 100644 --- a/lms/djangoapps/django_comment_client/tests.py +++ b/lms/djangoapps/django_comment_client/tests.py @@ -1,4 +1,4 @@ -import string +import string # pylint: disable=W0402 import random from django.contrib.auth.models import User diff --git a/lms/djangoapps/lms_migration/management/commands/create_user.py b/lms/djangoapps/lms_migration/management/commands/create_user.py index 87abf4f73a..5d96d96a8a 100644 --- a/lms/djangoapps/lms_migration/management/commands/create_user.py +++ b/lms/djangoapps/lms_migration/management/commands/create_user.py @@ -6,7 +6,7 @@ import os import sys -import string +import string # pylint: disable=W0402 import datetime from getpass import getpass import json From df4b512b6f2651fea6894d4f1ab9e923eaec2bd4 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 21 Jun 2013 10:42:17 -0400 Subject: [PATCH 021/161] Change wildcard imports into specific imports --- common/djangoapps/heartbeat/urls.py | 2 +- common/djangoapps/student/admin.py | 4 ++-- common/djangoapps/track/admin.py | 2 +- lms/djangoapps/courseware/admin.py | 2 +- lms/djangoapps/django_comment_client/helpers.py | 2 +- .../instructor/management/commands/compute_grades.py | 2 +- lms/djangoapps/psychometrics/admin.py | 2 +- .../psychometrics/management/commands/init_psychometrics.py | 6 +++--- lms/djangoapps/psychometrics/psychoanalyze.py | 5 +++-- lms/lib/comment_client/comment.py | 4 ++-- lms/lib/comment_client/comment_client.py | 2 +- lms/lib/comment_client/commentable.py | 2 -- lms/lib/comment_client/thread.py | 3 ++- lms/lib/comment_client/user.py | 2 +- 14 files changed, 20 insertions(+), 20 deletions(-) diff --git a/common/djangoapps/heartbeat/urls.py b/common/djangoapps/heartbeat/urls.py index 3f45a95dd2..6a0be757c9 100644 --- a/common/djangoapps/heartbeat/urls.py +++ b/common/djangoapps/heartbeat/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import * +from django.conf.urls import url, patterns urlpatterns = patterns('', # nopep8 url(r'^$', 'heartbeat.views.heartbeat', name='heartbeat'), diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 64fe844801..4d6976d7d4 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -2,9 +2,9 @@ django admin pages for courseware model ''' -from student.models import * +from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed +from student.models import CourseEnrollment, Registration, PendingNameChange from django.contrib import admin -from django.contrib.auth.models import User admin.site.register(UserProfile) diff --git a/common/djangoapps/track/admin.py b/common/djangoapps/track/admin.py index 1f19c59a93..d75f206846 100644 --- a/common/djangoapps/track/admin.py +++ b/common/djangoapps/track/admin.py @@ -2,7 +2,7 @@ django admin pages for courseware model ''' -from track.models import * +from track.models import TrackingLog from django.contrib import admin admin.site.register(TrackingLog) diff --git a/lms/djangoapps/courseware/admin.py b/lms/djangoapps/courseware/admin.py index 9ef4c1de20..743d1fed52 100644 --- a/lms/djangoapps/courseware/admin.py +++ b/lms/djangoapps/courseware/admin.py @@ -2,7 +2,7 @@ django admin pages for courseware model ''' -from courseware.models import * +from courseware.models import StudentModule, OfflineComputedGrade, OfflineComputedGradeLog from django.contrib import admin from django.contrib.auth.models import User diff --git a/lms/djangoapps/django_comment_client/helpers.py b/lms/djangoapps/django_comment_client/helpers.py index a8a51ad95c..1310c4e0c1 100644 --- a/lms/djangoapps/django_comment_client/helpers.py +++ b/lms/djangoapps/django_comment_client/helpers.py @@ -2,7 +2,7 @@ from django.conf import settings from .mustache_helpers import mustache_helpers from functools import partial -from .utils import * +from .utils import extend_content, merge_dict, render_mustache import django_comment_client.settings as cc_settings import pystache_custom as pystache diff --git a/lms/djangoapps/instructor/management/commands/compute_grades.py b/lms/djangoapps/instructor/management/commands/compute_grades.py index 4518450e39..d1c66d51d2 100644 --- a/lms/djangoapps/instructor/management/commands/compute_grades.py +++ b/lms/djangoapps/instructor/management/commands/compute_grades.py @@ -3,7 +3,7 @@ # django management command: dump grades to csv files # for use by batch processes -from instructor.offline_gradecalc import * +from instructor.offline_gradecalc import offline_grade_calculation from courseware.courses import get_course_by_id from xmodule.modulestore.django import modulestore diff --git a/lms/djangoapps/psychometrics/admin.py b/lms/djangoapps/psychometrics/admin.py index ff1a14d722..b7c04b5069 100644 --- a/lms/djangoapps/psychometrics/admin.py +++ b/lms/djangoapps/psychometrics/admin.py @@ -2,7 +2,7 @@ django admin pages for courseware model ''' -from psychometrics.models import * +from psychometrics.models import PsychometricData from django.contrib import admin admin.site.register(PsychometricData) diff --git a/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py index 87e62f4a2c..f9cfbd28f5 100644 --- a/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py +++ b/lms/djangoapps/psychometrics/management/commands/init_psychometrics.py @@ -4,9 +4,9 @@ import json -from courseware.models import * -from track.models import * -from psychometrics.models import * +from courseware.models import StudentModule +from track.models import TrackingLog +from psychometrics.models import PsychometricData from xmodule.modulestore import Location from django.conf import settings diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py index ab9a5e6242..c6e66445a4 100644 --- a/lms/djangoapps/psychometrics/psychoanalyze.py +++ b/lms/djangoapps/psychometrics/psychoanalyze.py @@ -14,7 +14,8 @@ from scipy.optimize import curve_fit from django.conf import settings from django.db.models import Sum, Max -from psychometrics.models import * +from psychometrics.models import PsychometricData +from courseware.models import StudentModule from pytz import UTC log = logging.getLogger("mitx.psychometrics") @@ -303,7 +304,7 @@ def generate_plots_for_problem(problem): def make_psychometrics_data_update_handler(course_id, user, module_state_key): """ Construct and return a procedure which may be called to update - the PsychometricsData instance for the given StudentModule instance. + the PsychometricData instance for the given StudentModule instance. """ sm, status = StudentModule.objects.get_or_create( course_id=course_id, diff --git a/lms/lib/comment_client/comment.py b/lms/lib/comment_client/comment.py index fb5a4ad0c3..fd68d5cdeb 100644 --- a/lms/lib/comment_client/comment.py +++ b/lms/lib/comment_client/comment.py @@ -1,6 +1,6 @@ -from .utils import * +from .utils import CommentClientError, perform_request -from .thread import Thread +from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread import models import settings diff --git a/lms/lib/comment_client/comment_client.py b/lms/lib/comment_client/comment_client.py index d91c5ea47f..4f660533f1 100644 --- a/lms/lib/comment_client/comment_client.py +++ b/lms/lib/comment_client/comment_client.py @@ -5,7 +5,7 @@ from .thread import Thread from .user import User from .commentable import Commentable -from .utils import * +from .utils import perform_request import settings diff --git a/lms/lib/comment_client/commentable.py b/lms/lib/comment_client/commentable.py index 111809f8f0..05efd70e50 100644 --- a/lms/lib/comment_client/commentable.py +++ b/lms/lib/comment_client/commentable.py @@ -1,5 +1,3 @@ -from .utils import * - import models import settings diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py index 0b0be576b8..00d5f01814 100644 --- a/lms/lib/comment_client/thread.py +++ b/lms/lib/comment_client/thread.py @@ -1,4 +1,5 @@ -from .utils import * +from .utils import merge_dict, strip_blank, strip_none, extract, perform_request +from .utils import CommentClientError import models import settings diff --git a/lms/lib/comment_client/user.py b/lms/lib/comment_client/user.py index a9e47fe6aa..2370052d90 100644 --- a/lms/lib/comment_client/user.py +++ b/lms/lib/comment_client/user.py @@ -1,4 +1,4 @@ -from .utils import * +from .utils import merge_dict, perform_request, CommentClientError import models import settings From 75b390124f402b3a1519ee6a9b40e3827c155f2d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 21 Jun 2013 10:42:49 -0400 Subject: [PATCH 022/161] Tweaks to our pylintrc rules. --- pylintrc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pylintrc b/pylintrc index af958e4af4..dea0f240c6 100644 --- a/pylintrc +++ b/pylintrc @@ -41,6 +41,10 @@ disable= # W0142: Used * or ** magic I0011,C0301,W0141,W0142, +# Django makes classes that trigger these +# W0232: Class has no __init__ method + W0232, + # Might use these when the code is in better shape # C0302: Too many lines in module # R0201: Method could be a function From 57909ce1aaba363645eef900fd1760d7aa276327 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 21 Jun 2013 10:55:36 -0400 Subject: [PATCH 023/161] Fix all W0602, global used but no assignment done. --- .../djangoapps/student/management/commands/massemailtxt.py | 1 - common/lib/xmodule/xmodule/contentstore/django.py | 2 -- common/lib/xmodule/xmodule/modulestore/django.py | 2 -- lms/djangoapps/django_comment_client/utils.py | 6 ------ 4 files changed, 11 deletions(-) diff --git a/common/djangoapps/student/management/commands/massemailtxt.py b/common/djangoapps/student/management/commands/massemailtxt.py index fec354e974..ae25430a85 100644 --- a/common/djangoapps/student/management/commands/massemailtxt.py +++ b/common/djangoapps/student/management/commands/massemailtxt.py @@ -37,7 +37,6 @@ rate -- messages per second self.log_file.write(datetime.datetime.utcnow().isoformat() + ' -- ' + text + '\n') def handle(self, *args, **options): - global log_file (user_file, message_base, logfilename, ratestr) = args users = [u.strip() for u in open(user_file).readlines()] diff --git a/common/lib/xmodule/xmodule/contentstore/django.py b/common/lib/xmodule/xmodule/contentstore/django.py index f163348cc8..25a5d7912f 100644 --- a/common/lib/xmodule/xmodule/contentstore/django.py +++ b/common/lib/xmodule/xmodule/contentstore/django.py @@ -18,8 +18,6 @@ def load_function(path): def contentstore(name='default'): - global _CONTENTSTORE - if name not in _CONTENTSTORE: class_ = load_function(settings.CONTENTSTORE['ENGINE']) options = {} diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index a2e2a4a5a5..c98e6cadef 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -26,8 +26,6 @@ def load_function(path): def modulestore(name='default'): - global _MODULESTORES - if name not in _MODULESTORES: class_ = load_function(settings.MODULESTORE[name]['ENGINE']) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 496c834950..6668826b67 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -73,21 +73,17 @@ def get_discussion_id_map(course): """ return a dict of the form {category: modules} """ - global _DISCUSSIONINFO initialize_discussion_info(course) return _DISCUSSIONINFO[course.id]['id_map'] def get_discussion_title(course, discussion_id): - global _DISCUSSIONINFO initialize_discussion_info(course) title = _DISCUSSIONINFO[course.id]['id_map'].get(discussion_id, {}).get('title', '(no title)') return title def get_discussion_category_map(course): - - global _DISCUSSIONINFO initialize_discussion_info(course) return filter_unstarted_categories(_DISCUSSIONINFO[course.id]['category_map']) @@ -141,8 +137,6 @@ def sort_map_entries(category_map): def initialize_discussion_info(course): - global _DISCUSSIONINFO - course_id = course.id discussion_id_map = {} From 45815e2d03f3a53defbf629bda73c653935f28db Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 21 Jun 2013 14:03:56 -0400 Subject: [PATCH 024/161] Remove obsolete file comment_client/legacy.py --- lms/lib/comment_client/legacy.py | 226 ------------------------------- 1 file changed, 226 deletions(-) delete mode 100644 lms/lib/comment_client/legacy.py diff --git a/lms/lib/comment_client/legacy.py b/lms/lib/comment_client/legacy.py deleted file mode 100644 index de7ce201ce..0000000000 --- a/lms/lib/comment_client/legacy.py +++ /dev/null @@ -1,226 +0,0 @@ -def delete_threads(commentable_id, *args, **kwargs): - return _perform_request('delete', _url_for_commentable_threads(commentable_id), *args, **kwargs) - - -def get_threads(commentable_id, recursive=False, query_params={}, *args, **kwargs): - default_params = {'page': 1, 'per_page': 20, 'recursive': recursive} - attributes = dict(default_params.items() + query_params.items()) - response = _perform_request('get', _url_for_threads(commentable_id), attributes, *args, **kwargs) - return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) - - -def search_threads(course_id, recursive=False, query_params={}, *args, **kwargs): - default_params = {'page': 1, 'per_page': 20, 'course_id': course_id, 'recursive': recursive} - attributes = dict(default_params.items() + query_params.items()) - response = _perform_request('get', _url_for_search_threads(), attributes, *args, **kwargs) - return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) - - -def search_similar_threads(course_id, recursive=False, query_params={}, *args, **kwargs): - default_params = {'course_id': course_id, 'recursive': recursive} - attributes = dict(default_params.items() + query_params.items()) - return _perform_request('get', _url_for_search_similar_threads(), attributes, *args, **kwargs) - - -def search_recent_active_threads(course_id, recursive=False, query_params={}, *args, **kwargs): - default_params = {'course_id': course_id, 'recursive': recursive} - attributes = dict(default_params.items() + query_params.items()) - return _perform_request('get', _url_for_search_recent_active_threads(), attributes, *args, **kwargs) - - -def search_trending_tags(course_id, query_params={}, *args, **kwargs): - default_params = {'course_id': course_id} - attributes = dict(default_params.items() + query_params.items()) - return _perform_request('get', _url_for_search_trending_tags(), attributes, *args, **kwargs) - - -def create_user(attributes, *args, **kwargs): - return _perform_request('post', _url_for_users(), attributes, *args, **kwargs) - - -def update_user(user_id, attributes, *args, **kwargs): - return _perform_request('put', _url_for_user(user_id), attributes, *args, **kwargs) - - -def get_threads_tags(*args, **kwargs): - return _perform_request('get', _url_for_threads_tags(), {}, *args, **kwargs) - - -def tags_autocomplete(value, *args, **kwargs): - return _perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs) - - -def create_thread(commentable_id, attributes, *args, **kwargs): - return _perform_request('post', _url_for_threads(commentable_id), attributes, *args, **kwargs) - - -def get_thread(thread_id, recursive=False, *args, **kwargs): - return _perform_request('get', _url_for_thread(thread_id), {'recursive': recursive}, *args, **kwargs) - - -def update_thread(thread_id, attributes, *args, **kwargs): - return _perform_request('put', _url_for_thread(thread_id), attributes, *args, **kwargs) - - -def create_comment(thread_id, attributes, *args, **kwargs): - return _perform_request('post', _url_for_thread_comments(thread_id), attributes, *args, **kwargs) - - -def delete_thread(thread_id, *args, **kwargs): - return _perform_request('delete', _url_for_thread(thread_id), *args, **kwargs) - - -def get_comment(comment_id, recursive=False, *args, **kwargs): - return _perform_request('get', _url_for_comment(comment_id), {'recursive': recursive}, *args, **kwargs) - - -def update_comment(comment_id, attributes, *args, **kwargs): - return _perform_request('put', _url_for_comment(comment_id), attributes, *args, **kwargs) - - -def create_sub_comment(comment_id, attributes, *args, **kwargs): - return _perform_request('post', _url_for_comment(comment_id), attributes, *args, **kwargs) - - -def delete_comment(comment_id, *args, **kwargs): - return _perform_request('delete', _url_for_comment(comment_id), *args, **kwargs) - - -def vote_for_comment(comment_id, user_id, value, *args, **kwargs): - return _perform_request('put', _url_for_vote_comment(comment_id), {'user_id': user_id, 'value': value}, *args, **kwargs) - - -def undo_vote_for_comment(comment_id, user_id, *args, **kwargs): - return _perform_request('delete', _url_for_vote_comment(comment_id), {'user_id': user_id}, *args, **kwargs) - - -def vote_for_thread(thread_id, user_id, value, *args, **kwargs): - return _perform_request('put', _url_for_vote_thread(thread_id), {'user_id': user_id, 'value': value}, *args, **kwargs) - - -def undo_vote_for_thread(thread_id, user_id, *args, **kwargs): - return _perform_request('delete', _url_for_vote_thread(thread_id), {'user_id': user_id}, *args, **kwargs) - - -def get_notifications(user_id, *args, **kwargs): - return _perform_request('get', _url_for_notifications(user_id), *args, **kwargs) - - -def get_user_info(user_id, complete=True, *args, **kwargs): - return _perform_request('get', _url_for_user(user_id), {'complete': complete}, *args, **kwargs) - - -def subscribe(user_id, subscription_detail, *args, **kwargs): - return _perform_request('post', _url_for_subscription(user_id), subscription_detail, *args, **kwargs) - - -def subscribe_user(user_id, followed_user_id, *args, **kwargs): - return subscribe(user_id, {'source_type': 'user', 'source_id': followed_user_id}) - -follow = subscribe_user - - -def subscribe_thread(user_id, thread_id, *args, **kwargs): - return subscribe(user_id, {'source_type': 'thread', 'source_id': thread_id}) - - -def subscribe_commentable(user_id, commentable_id, *args, **kwargs): - return subscribe(user_id, {'source_type': 'other', 'source_id': commentable_id}) - - -def unsubscribe(user_id, subscription_detail, *args, **kwargs): - return _perform_request('delete', _url_for_subscription(user_id), subscription_detail, *args, **kwargs) - - -def unsubscribe_user(user_id, followed_user_id, *args, **kwargs): - return unsubscribe(user_id, {'source_type': 'user', 'source_id': followed_user_id}) - -unfollow = unsubscribe_user - - -def unsubscribe_thread(user_id, thread_id, *args, **kwargs): - return unsubscribe(user_id, {'source_type': 'thread', 'source_id': thread_id}) - - -def unsubscribe_commentable(user_id, commentable_id, *args, **kwargs): - return unsubscribe(user_id, {'source_type': 'other', 'source_id': commentable_id}) - - -def _perform_request(method, url, data_or_params=None, *args, **kwargs): - if method in ['post', 'put', 'patch']: - response = requests.request(method, url, data=data_or_params) - else: - response = requests.request(method, url, params=data_or_params) - if 200 < response.status_code < 500: - raise CommentClientError(response.text) - elif response.status_code == 500: - raise CommentClientUnknownError(response.text) - else: - if kwargs.get("raw", False): - return response.text - else: - return json.loads(response.text) - - -def _url_for_threads(commentable_id): - return "{prefix}/{commentable_id}/threads".format(prefix=PREFIX, commentable_id=commentable_id) - - -def _url_for_thread(thread_id): - return "{prefix}/threads/{thread_id}".format(prefix=PREFIX, thread_id=thread_id) - - -def _url_for_thread_comments(thread_id): - return "{prefix}/threads/{thread_id}/comments".format(prefix=PREFIX, thread_id=thread_id) - - -def _url_for_comment(comment_id): - return "{prefix}/comments/{comment_id}".format(prefix=PREFIX, comment_id=comment_id) - - -def _url_for_vote_comment(comment_id): - return "{prefix}/comments/{comment_id}/votes".format(prefix=PREFIX, comment_id=comment_id) - - -def _url_for_vote_thread(thread_id): - return "{prefix}/threads/{thread_id}/votes".format(prefix=PREFIX, thread_id=thread_id) - - -def _url_for_notifications(user_id): - return "{prefix}/users/{user_id}/notifications".format(prefix=PREFIX, user_id=user_id) - - -def _url_for_subscription(user_id): - return "{prefix}/users/{user_id}/subscriptions".format(prefix=PREFIX, user_id=user_id) - - -def _url_for_user(user_id): - return "{prefix}/users/{user_id}".format(prefix=PREFIX, user_id=user_id) - - -def _url_for_search_threads(): - return "{prefix}/search/threads".format(prefix=PREFIX) - - -def _url_for_search_similar_threads(): - return "{prefix}/search/threads/more_like_this".format(prefix=PREFIX) - - -def _url_for_search_recent_active_threads(): - return "{prefix}/search/threads/recent_active".format(prefix=PREFIX) - - -def _url_for_search_trending_tags(): - return "{prefix}/search/tags/trending".format(prefix=PREFIX) - - -def _url_for_threads_tags(): - return "{prefix}/threads/tags".format(prefix=PREFIX) - - -def _url_for_threads_tags_autocomplete(): - return "{prefix}/threads/tags/autocomplete".format(prefix=PREFIX) - - -def _url_for_users(): - return "{prefix}/users".format(prefix=PREFIX) From 5a5d425eb348e2c646037879d54c997c00b4bf6f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 21 Jun 2013 14:41:16 -0400 Subject: [PATCH 025/161] Files that may not exist need F0401 suppressed during import. --- cms/envs/dev.py | 2 +- lms/envs/dev.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 07630bdf31..2dcb3640ca 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -181,6 +181,6 @@ if SEGMENT_IO_KEY: ##################################################################### # Lastly, see if the developer has any local overrides. try: - from .private import * + from .private import * # pylint: disable=F0401 except ImportError: pass diff --git a/lms/envs/dev.py b/lms/envs/dev.py index b1519b77bc..813f9cf32c 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -258,6 +258,6 @@ if SEGMENT_IO_LMS_KEY: ##################################################################### # Lastly, see if the developer has any local overrides. try: - from .private import * + from .private import * # pylint: disable=F0401 except ImportError: pass From fa9a8f4af09a27bd88aeea33a81ec0f5086d9363 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Fri, 21 Jun 2013 18:00:30 -0400 Subject: [PATCH 026/161] Greater dir naming flexibility. Accepts any dirname for the edx-platform repo. Allows the script to be called from any directory, not just $BASE/edx-platform. --- scripts/create-dev-env.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/create-dev-env.sh b/scripts/create-dev-env.sh index edb0bcdcae..0816b72d21 100755 --- a/scripts/create-dev-env.sh +++ b/scripts/create-dev-env.sh @@ -98,19 +98,23 @@ clone_repos() { set_base_default() { # if PROJECT_HOME not set # 2 possibilities: this is from cloned repo, or not - # this script is in "./scripts" if a git clone - this_repo=$(cd "${BASH_SOURCE%/*}/.." && pwd) - if [[ "${this_repo##*/}" = "edx-platform" && -d "$this_repo/.git" ]]; then - # set BASE one-up from this_repo; - echo "${this_repo%/*}" + + # See if remote's url is named edx-platform (this works for forks too, but + # not if the name was changed). + cd "$( dirname "${BASH_SOURCE[0]}" )" + this_repo=$(basename $(git ls-remote --get-url 2>/dev/null) 2>/dev/null) || + echo -n "" + + if [[ "x$this_repo" = "xedx-platform.git" ]]; then + # We are in the edx repo and already have git installed. Let git do the + # work of finding base dir: + echo "$(dirname $(git rev-parse --show-toplevel))" else echo "$HOME/edx_all" fi } - - ### START PROG=${0##*/} From 2eefa494b145610eb8b9a2037ee3be7aa0daba51 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 21 Jun 2013 23:46:56 -0700 Subject: [PATCH 027/161] Width of labels for multiple-choice capa problems = width of text Stanford got some feedback from our students/faculty that students were making accidental clicks on radio-button capa questions. They would click way to the right of the label text, but it would still select the corresponding input, which caused some students to make unintentional changes to their answers. This was because labels for these inputs were display:block and width:100% Changing these labels to float:left clear:both should fix it. --- lms/static/sass/course/base/_base.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lms/static/sass/course/base/_base.scss b/lms/static/sass/course/base/_base.scss index 6d87b7f554..a1c948d4f5 100644 --- a/lms/static/sass/course/base/_base.scss +++ b/lms/static/sass/course/base/_base.scss @@ -46,6 +46,13 @@ form { } } +form.choicegroup { + label { + clear: both; + float: left; + } +} + textarea, input[type="text"], input[type="email"], From 85b4a4ccab37e14e6f0543f8b7165e667c0768ef Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Sat, 22 Jun 2013 16:13:40 +0300 Subject: [PATCH 028/161] removes choiceresponse wiping after clicking Show Answer --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 3 --- 1 file changed, 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 987d20b65a..f773fc81c4 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -364,8 +364,6 @@ class @Problem choicegroup: (element, display, answers) => element = $(element) - element.find('input').attr('disabled', 'disabled') - input_id = element.attr('id').replace(/inputtype_/,'') answer = answers[input_id] for choice in answer @@ -379,7 +377,6 @@ class @Problem inputtypeHideAnswerMethods: choicegroup: (element, display) => element = $(element) - element.find('input').attr('disabled', null) element.find('label').removeClass('choicegroup_correct') javascriptinput: (element, display) => From 4a98e2eda75b1a8b036e4f3f5e035c5049aab776 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 5 Jun 2013 23:14:18 -0700 Subject: [PATCH 029/161] Moves user activation away from just clicking on reset password To following the link in the password reset email --- common/djangoapps/student/forms.py | 72 +++++++++++++++++++ common/djangoapps/student/views.py | 31 ++++---- .../registration/password_reset_email.html | 2 +- lms/urls.py | 2 +- 4 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 common/djangoapps/student/forms.py diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py new file mode 100644 index 0000000000..75c89e0a26 --- /dev/null +++ b/common/djangoapps/student/forms.py @@ -0,0 +1,72 @@ +from django import forms +from django.utils.translation import ugettext, ugettext_lazy as _ +from django.template import loader +from django.contrib.auth.models import User +from django.contrib.auth.hashers import UNUSABLE_PASSWORD, is_password_usable, get_hasher +from django.contrib.auth.tokens import default_token_generator +from django.contrib.sites.models import get_current_site +from django.utils.http import int_to_base36 + + + +# This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm +# I think copy-and-paste here is somewhat better than subclassing and +# just changing the definition of clean_email, because it's less +# likely to be broken by incompatibility with a new django version. +# (If this form is good enough now, a snapshot of it ought to last a while) + +class PasswordResetFormNoActive(forms.Form): + error_messages = { + 'unknown': _("That e-mail address doesn't have an associated " + "user account. Are you sure you've registered?"), + 'unusable': _("The user account associated with this e-mail " + "address cannot reset the password."), + } + email = forms.EmailField(label=_("E-mail"), max_length=75) + + def clean_email(self): + """ + Validates that an active user exists with the given email address. + """ + email = self.cleaned_data["email"] + #The line below contains the only change, removing is_active=True + self.users_cache = User.objects.filter(email__iexact=email) + if not len(self.users_cache): + raise forms.ValidationError(self.error_messages['unknown']) + if any((user.password == UNUSABLE_PASSWORD) + for user in self.users_cache): + raise forms.ValidationError(self.error_messages['unusable']) + return email + + def save(self, domain_override=None, + subject_template_name='registration/password_reset_subject.txt', + email_template_name='registration/password_reset_email.html', + use_https=False, token_generator=default_token_generator, + from_email=None, request=None): + """ + Generates a one-use only link for resetting password and sends to the + user. + """ + from django.core.mail import send_mail + for user in self.users_cache: + if not domain_override: + current_site = get_current_site(request) + site_name = current_site.name + domain = current_site.domain + else: + site_name = domain = domain_override + c = { + 'email': user.email, + 'domain': domain, + 'site_name': site_name, + 'uid': int_to_base36(user.id), + 'user': user, + 'token': token_generator.make_token(user), + 'protocol': use_https and 'https' or 'http', + } + subject = loader.render_to_string(subject_template_name, c) + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + email = loader.render_to_string(email_template_name, c) + send_mail(subject, email, from_email, [user.email]) + diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e065333409..50f6d90368 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -11,9 +11,9 @@ import time from django.conf import settings from django.contrib.auth import logout, authenticate, login -from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import password_reset_confirm from django.core.cache import cache from django.core.context_processors import csrf from django.core.mail import send_mail @@ -24,6 +24,7 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbid from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie from django.utils.http import cookie_date +from django.utils.http import base36_to_int from mitxmako.shortcuts import render_to_response, render_to_string from bs4 import BeautifulSoup @@ -34,6 +35,8 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente CourseEnrollment, unique_id_for_user, get_testcenter_registration, CourseEnrollmentAllowed) +from student.forms import PasswordResetFormNoActive + from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -962,17 +965,7 @@ def password_reset(request): if request.method != "POST": raise Http404 - # By default, Django doesn't allow Users with is_active = False to reset their passwords, - # but this bites people who signed up a long time ago, never activated, and forgot their - # password. So for their sake, we'll auto-activate a user for whom password_reset is called. - try: - user = User.objects.get(email=request.POST['email']) - user.is_active = True - user.save() - except: - log.exception("Tried to auto-activate user to enable password reset, but failed.") - - form = PasswordResetForm(request.POST) + form = PasswordResetFormNoActive(request.POST) if form.is_valid(): form.save(use_https=request.is_secure(), from_email=settings.DEFAULT_FROM_EMAIL, @@ -984,6 +977,20 @@ def password_reset(request): return HttpResponse(json.dumps({'success': False, 'error': 'Invalid e-mail'})) +def password_reset_confirm_wrapper(request, uidb36=None, token=None): + ''' A wrapper around django.contrib.auth.views.password_reset_confirm. + Needed because we want to set the user as active at this step. + ''' + #cribbed from django.contrib.auth.views.password_reset_confirm + try: + uid_int = base36_to_int(uidb36) + user = User.objects.get(id=uid_int) + user.is_active = True + user.save() + except (ValueError, User.DoesNotExist): + pass + return password_reset_confirm(request, uidb36=uidb36, token=token) + def reactivation_email_for_user(user): try: diff --git a/lms/templates/registration/password_reset_email.html b/lms/templates/registration/password_reset_email.html index bf6c3e0891..68073d9ddd 100644 --- a/lms/templates/registration/password_reset_email.html +++ b/lms/templates/registration/password_reset_email.html @@ -3,7 +3,7 @@ {% trans "Please go to the following page and choose a new password:" %} {% block reset_link %} -https://{{domain}}{% url 'django.contrib.auth.views.password_reset_confirm' uidb36=uid token=token %} +https://{{domain}}{% url 'student.views.password_reset_confirm_wrapper' uidb36=uid token=token %} {% endblock %} If you didn't request this change, you can disregard this email - we have not yet reset your password. diff --git a/lms/urls.py b/lms/urls.py index 52ce539f73..50ce35cde0 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -51,7 +51,7 @@ urlpatterns = ('', # nopep8 url(r'^password_change_done/$', django.contrib.auth.views.password_change_done, name='auth_password_change_done'), url(r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', - django.contrib.auth.views.password_reset_confirm, + 'student.views.password_reset_confirm_wrapper', name='auth_password_reset_confirm'), url(r'^password_reset_complete/$', django.contrib.auth.views.password_reset_complete, name='auth_password_reset_complete'), From 72e08456a8dcebd61e8a3476504c25209546f780 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 21 Jun 2013 16:50:32 -0400 Subject: [PATCH 030/161] Refactor Advanced Settings page to use Backbone notifications. --- cms/static/js/views/settings/advanced_view.js | 91 ++++++++++--------- cms/templates/base.html | 6 -- cms/templates/settings_advanced.html | 57 ------------ 3 files changed, 46 insertions(+), 108 deletions(-) diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index 863393d341..69a2c9f622 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -20,9 +20,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ self.render(); } ); - // because these are outside of this.$el, they can't be in the event hash - $('.save-button').on('click', this, this.saveView); - $('.cancel-button').on('click', this, this.revertView); this.listenTo(this.model, 'invalid', this.handleValidationError); }, render: function() { @@ -45,7 +42,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ var policyValues = listEle$.find('.json'); _.each(policyValues, this.attachJSONEditor, this); - this.showMessage(); return this; }, attachJSONEditor : function (textarea) { @@ -61,7 +57,9 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ mode: "application/json", lineNumbers: false, lineWrapping: false, onChange: function(instance, changeobj) { // this event's being called even when there's no change :-( - if (instance.getValue() !== oldValue) self.showSaveCancelButtons(); + if (instance.getValue() !== oldValue && !self.notificationBarShowing) { + self.showNotificationBar(); + } }, onFocus : function(mirror) { $(textarea).parent().children('label').addClass("is-focused"); @@ -99,59 +97,62 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ } }); }, - showMessage: function (type) { - $(".wrapper-alert").removeClass("is-shown"); - if (type) { - if (type === this.error_saving) { - $(".wrapper-alert-error").addClass("is-shown").attr('aria-hidden','false'); - } - else if (type === this.successful_changes) { - $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); - this.hideSaveCancelButtons(); - } - } - else { - // This is the case of the page first rendering, or when Cancel is pressed. - this.hideSaveCancelButtons(); - } + showNotificationBar: function() { + var self = this; + var message = gettext("Your changes will not take effect until you save your progress. Take care with key and value formatting, as validation is not implemented.") + var confirm = new CMS.Views.Notification.Warning({ + title: gettext("You've Made Some Changes"), + message: message, + actions: { + primary: { + "text": gettext("Save Changes"), + "class": "action-save", + "click": function() { + self.saveView(); + confirm.hide(); + self.notificationBarShowing = false; + } + }, + secondary: [{ + "text": gettext("Cancel"), + "class": "action-cancel", + "click": function() { + self.revertView(); + confirm.hide(); + self.notificationBarShowing = false; + } + }], + }}); + this.notificationBarShowing = true; + confirm.show(); }, - showSaveCancelButtons: function(event) { - if (!this.notificationBarShowing) { - this.$el.find(".message-status").removeClass("is-shown"); - $('.wrapper-notification').removeClass('is-hiding').addClass('is-shown').attr('aria-hidden','false'); - this.notificationBarShowing = true; - } - }, - hideSaveCancelButtons: function() { - if (this.notificationBarShowing) { - $('.wrapper-notification').removeClass('is-shown').addClass('is-hiding').attr('aria-hidden','true'); - this.notificationBarShowing = false; - } - }, - saveView : function(event) { - window.CmsUtils.smoothScrollTop(event); + saveView : function() { // TODO one last verification scan: // call validateKey on each to ensure proper format // check for dupes - var self = event.data; - self.model.save({}, + var self = this; + this.model.save({}, { success : function() { self.render(); - self.showMessage(self.successful_changes); + var message = gettext("Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs."); + var saving = new CMS.Views.Alert.Confirmation({ + title: gettext("Your policy changes have been saved."), + message: message, + closeIcon: false + }); + saving.show(); analytics.track('Saved Advanced Settings', { 'course': course_location_analytics }); - } }); }, - revertView : function(event) { - event.preventDefault(); - var self = event.data; - self.model.deleteKeys = []; - self.model.clear({silent : true}); - self.model.fetch({ + revertView : function() { + var self = this; + this.model.deleteKeys = []; + this.model.clear({silent : true}); + this.model.fetch({ success : function() { self.render(); }, reset: true }); diff --git a/cms/templates/base.html b/cms/templates/base.html index 11e8d41496..695a97f1da 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -61,8 +61,6 @@
<%include file="widgets/header.html" /> - ## remove this block after advanced settings notification is rewritten - <%block name="view_alerts">
<%block name="content"> @@ -74,13 +72,9 @@ <%include file="widgets/footer.html" /> <%include file="widgets/tender.html" /> - ## remove this block after advanced settings notification is rewritten - <%block name="view_notifications">
- ## remove this block after advanced settings notification is rewritten - <%block name="view_prompts">
<%block name="jsextra"> diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index 242148418e..6cc3468590 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -104,60 +104,3 @@ editor.render(); - -<%block name="view_notifications"> - - - - -<%block name="view_alerts"> - -
-
- - -
-

Your policy changes have been saved.

-

Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.

-
- - - - close alert - -
-
- - -
-
- - -
-

There was an error saving your information

-

Please see the error below and correct it to ensure there are no problems in rendering your course.

-
-
-
- From 3e376bd78031db5a87c890359f1fbc776030404e Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 24 Jun 2013 11:06:53 -0400 Subject: [PATCH 031/161] Prevent "saved" and "error" views from showing at the same time. Previously the "saved" view was never hidden, even after more data was edited. So if one field was saved successfully and then another was not, we would find ourselves in the unfortunate situation of seeing both views at once, leading to much confusion. --- cms/static/js/views/settings/advanced_view.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index 69a2c9f622..102bb71a52 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -21,6 +21,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ } ); this.listenTo(this.model, 'invalid', this.handleValidationError); + this.savedBar = undefined; }, render: function() { // catch potential outside call before template loaded @@ -136,15 +137,22 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ success : function() { self.render(); var message = gettext("Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs."); - var saving = new CMS.Views.Alert.Confirmation({ + self.saved = new CMS.Views.Alert.Confirmation({ title: gettext("Your policy changes have been saved."), message: message, closeIcon: false }); - saving.show(); + self.saved.show(); analytics.track('Saved Advanced Settings', { 'course': course_location_analytics }); + }, + error: function() { + // If we've already saved some data this will be + // shown; hide it away again. + if(self.saved) { + self.saved.hide(); + } } }); }, From 32a0a2d29dbf60713d355fc51f9a6e296af878de Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 24 Jun 2013 11:13:59 -0400 Subject: [PATCH 032/161] In the middle of addressing pull request comments. This is a safety commit in case I have to revert some changes I'm about to make. --- .../xmodule/modulestore/tests/django_utils.py | 8 ++- .../xmodule/modulestore/tests/factories.py | 8 +-- .../courseware/tests/check_request_code.py | 24 ------- lms/djangoapps/courseware/tests/helpers.py | 33 ++++----- .../courseware/tests/modulestore_config.py | 16 +++-- .../courseware/tests/test_navigation.py | 16 +++-- .../tests/test_view_authentication.py | 72 +++++++++++++------ 7 files changed, 98 insertions(+), 79 deletions(-) delete mode 100644 lms/djangoapps/courseware/tests/check_request_code.py diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 944b9e5bd4..a2306a5c6b 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -14,10 +14,16 @@ class ModuleStoreTestCase(TestCase): collection with templates before running the TestCase and drops it they are finished. """ - def update_course(self, course, data): + @staticmethod + def update_course(course, data): """ Updates the version of course in the mongo modulestore with the metadata in data and returns the updated version. + + 'course' is an instance of CourseDescriptor for which we want + to update metadata. + + 'data' is a dictionary with an entry for each CourseField we want to update. """ store = xmodule.modulestore.django.modulestore() diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 4f63fbc1d2..82ff61204a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -60,8 +60,8 @@ class XModuleCourseFactory(Factory): if data is not None: store.update_item(new_course.location, data) - '''update_item updates the the course as it exists in the modulestore, but doesn't - update the instance we are working with, so have to refetch the course after updating it.''' + #update_item updates the the course as it exists in the modulestore, but doesn't + #update the instance we are working with, so have to refetch the course after updating it. new_course = store.get_instance(new_course.id, new_course.location) return new_course @@ -152,8 +152,8 @@ class XModuleItemFactory(Factory): if new_item.location.category not in DETACHED_CATEGORIES: store.update_children(parent_location, parent.children + [new_item.location.url()]) - '''update_children updates the the item as it exists in the modulestore, but doesn't - update the instance we are working with, so have to refetch the item after updating it.''' + #update_children updates the the item as it exists in the modulestore, but doesn't + #update the instance we are working with, so have to refetch the item after updating it. new_item = store.get_item(new_item.location) return new_item diff --git a/lms/djangoapps/courseware/tests/check_request_code.py b/lms/djangoapps/courseware/tests/check_request_code.py deleted file mode 100644 index 1393d2fe17..0000000000 --- a/lms/djangoapps/courseware/tests/check_request_code.py +++ /dev/null @@ -1,24 +0,0 @@ - - -def check_for_get_code(code, url): - """ - Check that we got the expected code when accessing url via GET. - Returns the response. - """ - resp = self.client.get(url) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp - - -def check_for_post_code(code, url, data={}): - """ - Check that we got the expected code when accessing url via POST. - Returns the response. - """ - resp = self.client.post(url, data) - self.assertEqual(resp.status_code, code, - "got code %d for url '%s'. Expected code %d" - % (resp.status_code, url, code)) - return resp diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 99da5e9061..ce0603990b 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -12,7 +12,12 @@ def check_for_get_code(self, code, url): """ Check that we got the expected code when accessing url via GET. Returns the HTTP response. - 'self' is a class that subclasses TestCase. + + `self` is a class that subclasses TestCase. + + `code` is a status code for HTTP responses. + + `url` is a url pattern for which we have to test the response. """ resp = self.client.get(url) self.assertEqual(resp.status_code, code, @@ -25,7 +30,11 @@ def check_for_post_code(self, code, url, data={}): """ Check that we got the expected code when accessing url via POST. Returns the HTTP response. - 'self' is a class that subclasses TestCase. + `self` is a class that subclasses TestCase. + + `code` is a status code for HTTP responses. + + `url` is a url pattern for which we want to test the response. """ resp = self.client.post(url, data) self.assertEqual(resp.status_code, code, @@ -62,8 +71,8 @@ class LoginEnrollmentTestCase(TestCase): def logout(self): """ - Logout, check that it worked. - Returns an HTTP response which e + Logout; check that the HTTP response code indicates redirection + as expected. """ resp = self.client.get(reverse('logout'), {}) # should redirect @@ -106,8 +115,8 @@ class LoginEnrollmentTestCase(TestCase): def enroll(self, course, verify=False): """ Try to enroll and return boolean indicating result. - 'course' is an instance of CourseDescriptor. - 'verify' is an optional parameter specifying whether we + `course` is an instance of CourseDescriptor. + `verify` is an optional parameter specifying whether we want to verify that the student was successfully enrolled in the course. """ @@ -122,20 +131,12 @@ class LoginEnrollmentTestCase(TestCase): self.assertTrue(result) return result - # def enroll(self, course): - # """ - # Enroll the currently logged-in user, and check that it worked. - # """ - - # result = self.try_enroll(course) - # self.assertTrue(result) - def unenroll(self, course): """ Unenroll the currently logged-in user, and check that it worked. - 'course' is an instance of CourseDescriptor. + `course` is an instance of CourseDescriptor. """ - resp = self.client.post('/change_enrollment', { + resp = self.client.post(reverse('change_enrollment'), { 'enrollment_action': 'unenroll', 'course_id': course.id, }) diff --git a/lms/djangoapps/courseware/tests/modulestore_config.py b/lms/djangoapps/courseware/tests/modulestore_config.py index 81d0f4f911..c3c4ce4e5b 100644 --- a/lms/djangoapps/courseware/tests/modulestore_config.py +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -4,11 +4,11 @@ from django.conf import settings def mongo_store_config(data_dir): - ''' - Defines default module store using MongoModuleStore + """ + Defines default module store using MongoModuleStore. - Use of this config requires mongo to be running - ''' + Use of this config requires mongo to be running. + """ store = { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', @@ -27,7 +27,9 @@ def mongo_store_config(data_dir): def draft_mongo_store_config(data_dir): - '''Defines default module store using DraftMongoModuleStore''' + """ + Defines default module store using DraftMongoModuleStore. + """ return { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', @@ -55,7 +57,9 @@ def draft_mongo_store_config(data_dir): def xml_store_config(data_dir): - '''Defines default module store using XMLModuleStore''' + """ + Defines default module store using XMLModuleStore. + """ return { 'default': { 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index 9f9bf7ba92..f4662f2ef5 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -36,15 +36,18 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): # Create student accounts and activate them. for i in range(len(self.STUDENT_INFO)): - self.create_account('u{0}'.format(i), self.STUDENT_INFO[i][0], self.STUDENT_INFO[i][1]) - self.activate_user(self.STUDENT_INFO[i][0]) + email, password = self.STUDENT_INFO[i] + username = 'u{0}'.format(i) + self.create_account(username, email, password) + self.activate_user(email) def test_redirects_first_time(self): """ Verify that the first time we click on the courseware tab we are redirected to the 'Welcome' section. """ - self.login(self.STUDENT_INFO[0][0], self.STUDENT_INFO[0][1]) + email, password = self.STUDENT_INFO[0] + self.login(email, password) self.enroll(self.course, True) self.enroll(self.full, True) @@ -61,7 +64,8 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify the accordion remembers we've already visited the Welcome section and redirects correpondingly. """ - self.login(self.STUDENT_INFO[0][0], self.STUDENT_INFO[0][1]) + email, password = self.STUDENT_INFO[0] + self.login(email, password) self.enroll(self.course, True) self.enroll(self.full, True) @@ -80,8 +84,8 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Verify the accordion remembers which chapter you were last viewing. """ - - self.login(self.STUDENT_INFO[0][0], self.STUDENT_INFO[0][1]) + email, password = self.STUDENT_INFO[0] + self.login(email, password) self.enroll(self.course, True) self.enroll(self.full, True) diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index ffae4688bf..5db9847d45 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -32,28 +32,40 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): @classmethod def _instructor_urls(self, course): """ - List of urls that only instructors/staff should be able to see. + `course` is an instance of CourseDescriptor whose section URLs are to be returned. + + Returns a list of URLs corresponding to sections in the passed in course. """ + urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( 'instructor_dashboard', 'gradebook', 'grade_summary',)] + email, _ = self.ACCOUNT_INFO[0] + student_id = User.objects.get(email=email).id + urls.append(reverse('student_progress', kwargs={'course_id': course.id, - 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id})) + 'student_id': student_id})) return urls @staticmethod def _reverse_urls(names, course): """ Reverse a list of course urls. + + `names` is a list of URL names that correspond to sections in a course. + + `course` is the instance of CourseDescriptor whose section URLs are to be returned. + + Returns a list URLs corresponding to section in the passed in course. + """ return [reverse(name, kwargs={'course_id': course.id}) for name in names] def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} self.full = CourseFactory.create(number='666', display_name='Robot_Sub_Course') self.course = CourseFactory.create() @@ -68,8 +80,9 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # Create two accounts and activate them. for i in range(len(self.ACCOUNT_INFO)): - self.create_account('u{0}'.format(i), self.ACCOUNT_INFO[i][0], self.ACCOUNT_INFO[i][1]) - self.activate_user(self.ACCOUNT_INFO[i][0]) + username, email, password = 'u{0}'.format(i), self.ACCOUNT_INFO[i][0], self.ACCOUNT_INFO[i][1] + self.create_account(username, email, password) + self.activate_user(email) def test_redirection_unenrolled(self): """ @@ -77,7 +90,8 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): instead of the 'Welcome' section after clicking on the courseware tab. """ - self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + email, password = self.ACCOUNT_INFO[0] + self.login(email, password) response = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) self.assertRedirects(response, @@ -90,7 +104,8 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): the chapter after clicking on the courseware tab. """ - self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + email, password = self.ACCOUNT_INFO[0] + self.login(email, password) self.enroll(self.course) response = self.client.get(reverse('courseware', @@ -108,7 +123,8 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): dashboard, the grade views, and student profile pages. """ - self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + email, password = self.ACCOUNT_INFO[0] + self.login(email, password) self.enroll(self.course) self.enroll(self.full) @@ -127,12 +143,14 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): and student profile pages for their course. """ + email, password = self.ACCOUNT_INFO[1] + # Make the instructor staff in self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) - group.user_set.add(User.objects.get(email=self.ACCOUNT_INFO[1][0])) + group.user_set.add(User.objects.get(email=email)) - self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) + self.login(email, password) # Now should be able to get to self.course, but not self.full url = random.choice(self._instructor_urls(self.course)) @@ -149,10 +167,11 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): staff permissions. """ - self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) + email, password = self.ACCOUNT_INFO[1] + self.login(email, password) # now make the instructor also staff - instructor = User.objects.get(email=self.ACCOUNT_INFO[1][0]) + instructor = User.objects.get(email=email) instructor.is_staff = True instructor.save() @@ -202,6 +221,9 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Actually do the test, relying on settings to be right. """ + student_email, student_password = self.ACCOUNT_INFO[0] + instructor_email, instructor_password = self.ACCOUNT_INFO[1] + # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) @@ -300,7 +322,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # First, try with an enrolled student print '=== Testing student access....' - self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + self.login(student_email, student_password) self.enroll(self.course, True) self.enroll(self.full, True) @@ -314,10 +336,10 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # Make the instructor staff in self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) - group.user_set.add(User.objects.get(email=self.ACCOUNT_INFO[1][0])) + group.user_set.add(User.objects.get(email=instructor_email)) self.logout() - self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) + self.login(instructor_email, instructor_password) # Enroll in the classes---can't see courseware otherwise. self.enroll(self.course, True) self.enroll(self.full, True) @@ -329,7 +351,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): print '=== Testing staff access....' # now also make the instructor staff - instructor = User.objects.get(email=self.ACCOUNT_INFO[1][0]) + instructor = User.objects.get(email=instructor_email) instructor.is_staff = True instructor.save() @@ -342,6 +364,9 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Actually do the test, relying on settings to be right. """ + student_email, student_password = self.ACCOUNT_INFO[0] + instructor_email, instructor_password = self.ACCOUNT_INFO[1] + # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) @@ -360,7 +385,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): print "login" # First, try with an enrolled student print '=== Testing student access....' - self.login(self.ACCOUNT_INFO[0][0], self.ACCOUNT_INFO[0][1]) + self.login(student_email, student_password) self.assertFalse(self.enroll(self.course)) self.assertTrue(self.enroll(self.full)) @@ -368,18 +393,18 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # Make the instructor staff in the self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) - group.user_set.add(User.objects.get(email=self.ACCOUNT_INFO[1][0])) + group.user_set.add(User.objects.get(email=instructor_email)) print "logout/login" self.logout() - self.login(self.ACCOUNT_INFO[1][0], self.ACCOUNT_INFO[1][1]) + self.login(instructor_email, instructor_password) print "Instructor should be able to enroll in self.course" self.assertTrue(self.enroll(self.course)) print '=== Testing staff access....' # now make the instructor global staff, but not in the instructor group - group.user_set.remove(User.objects.get(email=self.ACCOUNT_INFO[1][0])) - instructor = User.objects.get(email=self.ACCOUNT_INFO[1][0]) + group.user_set.remove(User.objects.get(email=instructor_email)) + instructor = User.objects.get(email=instructor_email) instructor.is_staff = True instructor.save() @@ -392,6 +417,9 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Actually test beta periods, relying on settings to be right. """ + student_email, student_password = self.ACCOUNT_INFO[0] + instructor_email, instructor_password = self.ACCOUNT_INFO[1] + # trust, but verify :) self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) @@ -408,7 +436,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.course.lms.days_early_for_beta = 2 # student user shouldn't see it - student_user = User.objects.get(email=self.ACCOUNT_INFO[0][0]) + student_user = User.objects.get(email=student_email) self.assertFalse(has_access(student_user, self.course, 'load')) # now add the student to the beta test group From 72ffe2d8f243d00679e97b1385475e33a00e181b Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 24 Jun 2013 13:44:48 -0400 Subject: [PATCH 033/161] Backbone notifications secondary actions Handle secondary actions on notifications either specified as a single object, or as a list of objects. Under the hood, the initialize method converts a single object to a list containing a single object. --- .../coffee/spec/views/feedback_spec.coffee | 43 ++++++++++++++++++- cms/static/js/views/feedback.js | 5 +++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index a3950c0b3c..e5916c5ed3 100644 --- a/cms/static/coffee/spec/views/feedback_spec.coffee +++ b/cms/static/coffee/spec/views/feedback_spec.coffee @@ -100,11 +100,10 @@ describe "CMS.Views.SystemFeedback click events", -> text: "Save", class: "save-button", click: @primaryClickSpy - secondary: [{ + secondary: text: "Revert", class: "cancel-button", click: @secondaryClickSpy - }] ) @view.show() @@ -124,6 +123,46 @@ describe "CMS.Views.SystemFeedback click events", -> it "should apply class to secondary action", -> expect(@view.$(".action-secondary")).toHaveClass("cancel-button") + +describe "CMS.Views.SystemFeedback multiple secondary actions", -> + beforeEach -> + @secondarySpyOne = jasmine.createSpy('secondarySpyOne') + @secondarySpyTwo = jasmine.createSpy('secondarySpyTwo') + @view = new CMS.Views.Notification.Warning( + title: "No Primary", + message: "Pick a secondary action", + actions: + secondary: [ + { + text: "Option One" + class: "option-one" + click: @secondarySpyOne + }, { + text: "Option Two" + class: "option-two" + click: @secondarySpyTwo + } + ] + ) + @view.show() + + it "should render both", -> + expect(@view.el).toContain(".action-secondary.option-one") + expect(@view.el).toContain(".action-secondary.option-two") + expect(@view.el).not.toContain(".action-secondary.option-one.option-two") + expect(@view.$(".action-secondary.option-one")).toContainText("Option One") + expect(@view.$(".action-secondary.option-two")).toContainText("Option Two") + + it "should differentiate clicks (1)", -> + @view.$(".option-one").click() + expect(@secondarySpyOne).toHaveBeenCalled() + expect(@secondarySpyTwo).not.toHaveBeenCalled() + + it "should differentiate clicks (2)", -> + @view.$(".option-two").click() + expect(@secondarySpyOne).not.toHaveBeenCalled() + expect(@secondarySpyTwo).toHaveBeenCalled() + describe "CMS.Views.Notification minShown and maxShown", -> beforeEach -> @showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show') diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 0cfd6fa4ef..3f161d5b1f 100644 --- a/cms/static/js/views/feedback.js +++ b/cms/static/js/views/feedback.js @@ -49,6 +49,11 @@ CMS.Views.SystemFeedback = Backbone.View.extend({ } this.template = _.template(tpl); this.setElement($("#page-"+this.options.type)); + // handle single "secondary" action + if (this.options.actions && this.options.actions.secondary && + !_.isArray(this.options.actions.secondary)) { + this.options.actions.secondary = [this.options.actions.secondary]; + } return this; }, // public API: show() and hide() From ab7b991e78f7d21389520c33b945e463b45e0e01 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Mon, 24 Jun 2013 11:53:59 -0600 Subject: [PATCH 034/161] Update CHANGELOG.rst --- CHANGELOG.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0e161e4f72..d06cd89621 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Small UX fix on capa multiple-choice problems. Make labels only +as wide as the text to reduce accidental choice selections. + Studio: Remove XML from the video component editor. All settings are moved to be edited as metadata. From fb573a1db64eefc328169d2881c9b1dd25187d14 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 24 Jun 2013 12:40:45 -0400 Subject: [PATCH 035/161] Hide "success" alert as soon as we start editing another field. --- cms/static/js/views/settings/advanced_view.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index 102bb71a52..302a918de1 100644 --- a/cms/static/js/views/settings/advanced_view.js +++ b/cms/static/js/views/settings/advanced_view.js @@ -21,7 +21,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ } ); this.listenTo(this.model, 'invalid', this.handleValidationError); - this.savedBar = undefined; }, render: function() { // catch potential outside call before template loaded @@ -122,10 +121,13 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ confirm.hide(); self.notificationBarShowing = false; } - }], + }] }}); this.notificationBarShowing = true; confirm.show(); + if(this.saved) { + this.saved.hide(); + } }, saveView : function() { // TODO one last verification scan: @@ -146,13 +148,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({ analytics.track('Saved Advanced Settings', { 'course': course_location_analytics }); - }, - error: function() { - // If we've already saved some data this will be - // shown; hide it away again. - if(self.saved) { - self.saved.hide(); - } } }); }, From f1825eff819ec28dd42fcd04e84bd73d4d8bd86c Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Mon, 24 Jun 2013 12:19:34 -0600 Subject: [PATCH 036/161] Update CHANGELOG.rst --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d06cd89621..3dda49928b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,7 +5,7 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. -LMS: Small UX fix on capa multiple-choice problems. Make labels only +Blades: Small UX fix on capa multiple-choice problems. Make labels only as wide as the text to reduce accidental choice selections. Studio: Remove XML from the video component editor. All settings are From 83062c0b7dd6b85e6f50ad717ba796c92c5ecb8d Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Mon, 24 Jun 2013 11:54:31 -0700 Subject: [PATCH 037/161] Tests + Now subclass PasswordResetForm instead of copy Changed to subclassing django's PasswordResetForm and overriding clean_password() instead of copy/paste. Less lines to worry about for diff-cover this way =) --- common/djangoapps/student/forms.py | 65 ++------------ common/djangoapps/student/tests/tests.py | 106 ++++++++++++++++++++++- common/djangoapps/student/views.py | 2 +- 3 files changed, 111 insertions(+), 62 deletions(-) diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 75c89e0a26..1096092117 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -1,33 +1,15 @@ from django import forms -from django.utils.translation import ugettext, ugettext_lazy as _ -from django.template import loader from django.contrib.auth.models import User -from django.contrib.auth.hashers import UNUSABLE_PASSWORD, is_password_usable, get_hasher -from django.contrib.auth.tokens import default_token_generator -from django.contrib.sites.models import get_current_site -from django.utils.http import int_to_base36 +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.hashers import UNUSABLE_PASSWORD - - -# This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm -# I think copy-and-paste here is somewhat better than subclassing and -# just changing the definition of clean_email, because it's less -# likely to be broken by incompatibility with a new django version. -# (If this form is good enough now, a snapshot of it ought to last a while) - -class PasswordResetFormNoActive(forms.Form): - error_messages = { - 'unknown': _("That e-mail address doesn't have an associated " - "user account. Are you sure you've registered?"), - 'unusable': _("The user account associated with this e-mail " - "address cannot reset the password."), - } - email = forms.EmailField(label=_("E-mail"), max_length=75) - +class PasswordResetFormNoActive(PasswordResetForm): def clean_email(self): """ - Validates that an active user exists with the given email address. - """ + This is a literal copy from Django 1.4.5's django.contrib.auth.forms.PasswordResetForm + Except removing the requirement of active users + Validates that a user exists with the given email address. + """ email = self.cleaned_data["email"] #The line below contains the only change, removing is_active=True self.users_cache = User.objects.filter(email__iexact=email) @@ -37,36 +19,3 @@ class PasswordResetFormNoActive(forms.Form): for user in self.users_cache): raise forms.ValidationError(self.error_messages['unusable']) return email - - def save(self, domain_override=None, - subject_template_name='registration/password_reset_subject.txt', - email_template_name='registration/password_reset_email.html', - use_https=False, token_generator=default_token_generator, - from_email=None, request=None): - """ - Generates a one-use only link for resetting password and sends to the - user. - """ - from django.core.mail import send_mail - for user in self.users_cache: - if not domain_override: - current_site = get_current_site(request) - site_name = current_site.name - domain = current_site.domain - else: - site_name = domain = domain_override - c = { - 'email': user.email, - 'domain': domain, - 'site_name': site_name, - 'uid': int_to_base36(user.id), - 'user': user, - 'token': token_generator.make_token(user), - 'protocol': use_https and 'https' or 'http', - } - subject = loader.render_to_string(subject_template_name, c) - # Email subject *must not* contain newlines - subject = ''.join(subject.splitlines()) - email = loader.render_to_string(email_template_name, c) - send_mail(subject, email, from_email, [user.email]) - diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 4638da44b2..10836122b8 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -5,18 +5,118 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ import logging +import json +import re +import unittest +from django import forms +from django.conf import settings from django.test import TestCase -from mock import Mock +from django.test.client import RequestFactory +from django.contrib.auth.models import User +from django.contrib.auth.hashers import UNUSABLE_PASSWORD +from django.template.loader import render_to_string, get_template, TemplateDoesNotExist +from django.core.urlresolvers import is_valid_path + +from mock import Mock, patch +from textwrap import dedent from student.models import unique_id_for_user -from student.views import process_survey_link, _cert_info - +from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper +from student.tests.factories import UserFactory +from student.tests.test_email import mock_render_to_string COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' log = logging.getLogger(__name__) +try: + get_template('registration/password_reset_email.html') + project_uses_password_reset = True +except TemplateDoesNotExist: + project_uses_password_reset = False + + +class ResetPasswordTests(TestCase): + """ Tests that clicking reset password sends email, and doesn't activate the user + """ + request_factory = RequestFactory() + + def setUp(self): + self.user = UserFactory.create() + self.user.is_active = False + self.user.save() + + self.user_bad_passwd = UserFactory.create() + self.user_bad_passwd.is_active = False + self.user_bad_passwd.password = UNUSABLE_PASSWORD + self.user_bad_passwd.save() + + + @unittest.skipUnless(project_uses_password_reset, dedent("""Skipping Test because CMS has not provided + necessary templates for password reset. If this message is in LMS tests, that is a bug and needs to be fixed.""")) + @patch('student.views.password_reset_confirm') + @patch('django.core.mail.send_mail') + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_reset_password_email(self, send_email, reset_confirm): + """Tests sending of reset password email""" + + #First test the bad password user, mainly for diff-cover sake + bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) + bad_pwd_resp = password_reset(bad_pwd_req) + self.assertEquals(bad_pwd_resp.status_code, 200) + self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, + 'error': 'Invalid e-mail or user'})) + + #Now test the exception cases with invalid email. + bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) + bad_email_resp = password_reset(bad_email_req) + self.assertEquals(bad_email_resp.status_code, 200) + self.assertEquals(bad_email_resp.content, json.dumps({'success': False, + 'error': 'Invalid e-mail or user'})) + + #Now test the legit case where email should have been sent + good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + self.assertEquals(good_resp.content, + json.dumps({'success': True, + 'value': "('registration/password_reset_done.html', [])"})) + + ((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args + self.assertIn("Password reset", subject) + self.assertIn("You're receiving this e-mail because you requested a password reset", msg) + self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) + self.assertEquals(len(to_addrs), 1) + self.assertIn(self.user.email, to_addrs) + + #test that the user is not active + #it's a bit unsettling that we have to reload the user from the db for this test to work + #but I guess the user is cached here in the instance of ResetPasswordTests + #so the update in the view does not know to update this class. + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + #now try to activate the user in the password reset phase + bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') + bad_reset_resp = password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') + (confirm_args, confirm_kwargs) = reset_confirm.call_args + self.assertEquals(confirm_kwargs['uidb36'], 'NO') + self.assertEquals(confirm_kwargs['token'], 'OP') + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(reset_match['uidb36'], + reset_match['token'])) + good_reset_resp = password_reset_confirm_wrapper(good_reset_req, reset_match['uidb36'], reset_match['token']) + (confirm_args, confirm_kwargs) = reset_confirm.call_args + self.assertEquals(confirm_kwargs['uidb36'], reset_match['uidb36']) + self.assertEquals(confirm_kwargs['token'], reset_match['token']) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) + + class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 50f6d90368..7ae460b438 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -975,7 +975,7 @@ def password_reset(request): 'value': render_to_string('registration/password_reset_done.html', {})})) else: return HttpResponse(json.dumps({'success': False, - 'error': 'Invalid e-mail'})) + 'error': 'Invalid e-mail or user'})) def password_reset_confirm_wrapper(request, uidb36=None, token=None): ''' A wrapper around django.contrib.auth.views.password_reset_confirm. From e44ef1a54ebff3d61231ad7cc2e7ccbc5faf5933 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 24 Jun 2013 16:24:09 -0400 Subject: [PATCH 038/161] Removed the use of random.choice() --- .../tests/test_view_authentication.py | 268 ++++++++++-------- 1 file changed, 153 insertions(+), 115 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 5db9847d45..8e03e2563b 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -1,8 +1,7 @@ import datetime import pytz -import random -import xmodule.modulestore.django +from mock import patch from django.contrib.auth.models import User, Group from django.conf import settings @@ -65,6 +64,99 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): return [reverse(name, kwargs={'course_id': course.id}) for name in names] + def _dark_student_urls(self, course): + """ + List of urls that students should be able to see only + after launch, but staff should see before + """ + urls = self._reverse_urls(['info', 'progress'], course) + urls.extend([ + reverse('book', kwargs={'course_id': course.id, + 'book_index': index}) + for index, book in enumerate(course.textbooks) + ]) + return urls + + def _light_student_urls(self, course): + """ + List of urls that students should be able to see before + launch. + """ + urls = self._reverse_urls(['about_course'], course) + urls.append(reverse('courses')) + + return urls + + def instructor_urls(self, course): + """ + List of urls that only instructors/staff should be able to see. + """ + urls = self._reverse_urls(['instructor_dashboard', + 'gradebook', 'grade_summary'], course) + return urls + + def _check_non_staff_light(self, course): + """ + Check that non-staff have access to light urls. + + `course` is an instance of CourseDescriptor. + """ + print '=== Checking non-staff access for {0}'.format(course.id) + urls = [reverse('about_course', kwargs={'course_id': course.id}), reverse('courses')] + for url in urls: + print 'checking for 200 on {0}'.format(url) + check_for_get_code(self, 200, url) + + def _check_non_staff_dark(self, course): + """ + Check that non-staff don't have access to dark urls. + """ + print '=== Checking non-staff access for {0}'.format(course.id) + + names = ['courseware', 'instructor_dashboard', 'progress'] + urls = self._reverse_urls(names, course) + urls.extend([ + reverse('book', kwargs={'course_id': course.id, + 'book_index': index}) + for index, book in enumerate(course.textbooks) + ]) + for url in urls: + print 'checking for 404 on {0}'.format(url) + check_for_get_code(self, 404, url) + + def _check_staff(self, course): + """ + Check that access is right for staff in course. + """ + print '=== Checking staff access for {0}'.format(course.id) + + names = ['about_course', 'instructor_dashboard', 'progress'] + urls = self._reverse_urls(names, course) + urls.extend([ + reverse('book', kwargs={'course_id': course.id, + 'book_index': index}) + for index, book in enumerate(course.textbooks) + ]) + for url in urls: + print 'checking for 200 on {0}'.format(url) + check_for_get_code(self, 200, url) + + # The student progress tab is not accessible to a student + # before launch, so the instructor view-as-student feature + # should return a 404 as well. + # TODO (vshnayder): If this is not the behavior we want, will need + # to make access checking smarter and understand both the effective + # user (the student), and the requesting user (the prof) + url = reverse('student_progress', + kwargs={'course_id': course.id, + 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id}) + print 'checking for 404 on view-as-student: {0}'.format(url) + check_for_get_code(self, 404, url) + + # The courseware url should redirect, not 200 + url = self._reverse_urls(['courseware'], course)[0] + check_for_get_code(self, 302, url) + def setUp(self): self.full = CourseFactory.create(number='666', display_name='Robot_Sub_Course') @@ -129,13 +221,13 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.enroll(self.course) self.enroll(self.full) - # Randomly sample an instructor page - url = random.choice(self._instructor_urls(self.course) + - self._instructor_urls(self.full)) + urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.id}), + reverse('instructor_dashboard', kwargs={'course_id': self.full.id})] # Shouldn't be able to get to the instructor pages - print 'checking for 404 on {0}'.format(url) - check_for_get_code(self, 404, url) + for url in urls: + print 'checking for 404 on {0}'.format(url) + check_for_get_code(self, 404, url) def test_instructor_course_access(self): """ @@ -153,11 +245,11 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.login(email, password) # Now should be able to get to self.course, but not self.full - url = random.choice(self._instructor_urls(self.course)) + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) print 'checking for 200 on {0}'.format(url) check_for_get_code(self, 200, url) - url = random.choice(self._instructor_urls(self.full)) + url = reverse('instructor_dashboard', kwargs={'course_id': self.full.id}) print 'checking for 404 on {0}'.format(url) check_for_get_code(self, 404, url) @@ -176,11 +268,12 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): instructor.save() # and now should be able to load both - url = random.choice(self._instructor_urls(self.course) + - self._instructor_urls(self.full)) + urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.id}), + reverse('instructor_dashboard', kwargs={'course_id': self.full.id})] - print 'checking for 200 on {0}'.format(url) - check_for_get_code(self, 200, url) + for url in urls: + print 'checking for 200 on {0}'.format(url) + check_for_get_code(self, 200, url) def run_wrapped(self, test): """ @@ -202,7 +295,9 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Make sure that before course start, students can't access course pages, but instructors can. """ - self.run_wrapped(self._do_test_dark_launch) + self.run_wrapped(self._do_test_dark_launch_enrolled_student) + self.run_wrapped(self._do_test_dark_launch_instructor) + self.run_wrapped(self._do_test_dark_launch_staff) def test_enrollment_period(self): """ @@ -210,19 +305,18 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ self.run_wrapped(self._do_test_enrollment_period) - def test_beta_period(self): - """ - Check that beta-test access works. - """ - self.run_wrapped(self._do_test_beta_period) + # def test_beta_period(self): + # """ + # Check that beta-test access works. + # """ + # self.run_wrapped(self._do_test_beta_period) - def _do_test_dark_launch(self): + def _do_test_dark_launch_enrolled_student(self): """ Actually do the test, relying on settings to be right. """ student_email, student_password = self.ACCOUNT_INFO[0] - instructor_email, instructor_password = self.ACCOUNT_INFO[1] # Make courses start in the future now = datetime.datetime.now(pytz.UTC) @@ -236,90 +330,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertFalse(self.full.has_started()) self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - def dark_student_urls(course): - """ - List of urls that students should be able to see only - after launch, but staff should see before - """ - urls = self._reverse_urls(['info', 'progress'], course) - urls.extend([ - reverse('book', kwargs={'course_id': course.id, - 'book_index': index}) - for index, book in enumerate(course.textbooks) - ]) - return urls - - def light_student_urls(course): - """ - List of urls that students should be able to see before - launch. - """ - urls = self._reverse_urls(['about_course'], course) - urls.append(reverse('courses')) - - return urls - - def instructor_urls(course): - """ - List of urls that only instructors/staff should be able to see. - """ - urls = self._reverse_urls(['instructor_dashboard', - 'gradebook', 'grade_summary'], course) - return urls - - def check_non_staff_light(course): - """ - Check that non-staff have access to light urls. - """ - print '=== Checking non-staff access for {0}'.format(course.id) - - # Randomly sample a light url - url = random.choice(light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - check_for_get_code(self, 200, url) - - def check_non_staff_dark(course): - """ - Check that non-staff don't have access to dark urls. - """ - print '=== Checking non-staff access for {0}'.format(course.id) - - # Randomly sample a dark url - url = random.choice(instructor_urls(course) + - dark_student_urls(course) + - self._reverse_urls(['courseware'], course)) - print 'checking for 404 on {0}'.format(url) - check_for_get_code(self, 404, url) - - def check_staff(course): - """ - Check that access is right for staff in course. - """ - print '=== Checking staff access for {0}'.format(course.id) - - # Randomly sample a url - url = random.choice(instructor_urls(course) + - dark_student_urls(course) + - light_student_urls(course)) - print 'checking for 200 on {0}'.format(url) - check_for_get_code(self, 200, url) - - # The student progress tab is not accessible to a student - # before launch, so the instructor view-as-student feature - # should return a 404 as well. - # TODO (vshnayder): If this is not the behavior we want, will need - # to make access checking smarter and understand both the effective - # user (the student), and the requesting user (the prof) - url = reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id}) - print 'checking for 404 on view-as-student: {0}'.format(url) - check_for_get_code(self, 404, url) - - # The courseware url should redirect, not 200 - url = self._reverse_urls(['courseware'], course)[0] - check_for_get_code(self, 302, url) - # First, try with an enrolled student print '=== Testing student access....' self.login(student_email, student_password) @@ -327,17 +337,27 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.enroll(self.full, True) # shouldn't be able to get to anything except the light pages - check_non_staff_light(self.course) - check_non_staff_dark(self.course) - check_non_staff_light(self.full) - check_non_staff_dark(self.full) + self._check_non_staff_light(self.course) + self._check_non_staff_dark(self.course) + self._check_non_staff_light(self.full) + self._check_non_staff_dark(self.full) + + def _do_test_dark_launch_instructor(self): + + instructor_email, instructor_password = self.ACCOUNT_INFO[1] + + now = datetime.datetime.now(pytz.UTC) + tomorrow = now + datetime.timedelta(days=1) + course_data = {'start': tomorrow} + full_data = {'start': tomorrow} + self.course = self.update_course(self.course, course_data) + self.full = self.update_course(self.full, full_data) print '=== Testing course instructor access....' # Make the instructor staff in self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) group.user_set.add(User.objects.get(email=instructor_email)) - self.logout() self.login(instructor_email, instructor_password) # Enroll in the classes---can't see courseware otherwise. @@ -345,9 +365,24 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.enroll(self.full, True) # should now be able to get to everything for self.course - check_non_staff_light(self.full) - check_non_staff_dark(self.full) - check_staff(self.course) + self._check_non_staff_light(self.full) + self._check_non_staff_dark(self.full) + self._check_staff(self.course) + + def _do_test_dark_launch_staff(self): + + instructor_email, instructor_password = self.ACCOUNT_INFO[1] + + now = datetime.datetime.now(pytz.UTC) + tomorrow = now + datetime.timedelta(days=1) + course_data = {'start': tomorrow} + full_data = {'start': tomorrow} + self.course = self.update_course(self.course, course_data) + self.full = self.update_course(self.full, full_data) + + self.login(instructor_email, instructor_password) + self.enroll(self.course, True) + self.enroll(self.full, True) print '=== Testing staff access....' # now also make the instructor staff @@ -356,8 +391,8 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): instructor.save() # and now should be able to load both - check_staff(self.course) - check_staff(self.full) + self._check_staff(self.course) + self._check_staff(self.full) def _do_test_enrollment_period(self): """ @@ -412,6 +447,9 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.unenroll(self.course) self.assertTrue(self.enroll(self.course)) + #from courseware.access import MITX_FEATURES + + #@patch.dict(MITX_FEATURES, {'DISABLE_START_DATES': True}) def _do_test_beta_period(self): """ Actually test beta periods, relying on settings to be right. From 3a8f591fe5280146b66918e55daabd674999b507 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Mon, 24 Jun 2013 16:36:49 -0400 Subject: [PATCH 039/161] Add tests for the diff coverage; fix one hidden unicode bug --- common/lib/xmodule/xmodule/capa_module.py | 2 +- .../xmodule/xmodule/tests/test_capa_module.py | 85 ++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d740a73946..bb06912f7a 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -412,7 +412,7 @@ class CapaModule(CapaFields, XModule): `err` is the Exception encountered while rendering the problem HTML. """ - log.exception(err) + log.exception(err.message) # TODO (vshnayder): another switch on DEBUG. if self.system.DEBUG: diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index c6ffd32e89..1e84174291 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -11,11 +11,12 @@ import datetime from mock import Mock, patch import unittest import random +import json import xmodule -from capa.responsetypes import StudentInputError, \ - LoncapaProblemError, ResponseError -from xmodule.capa_module import CapaModule +from capa.responsetypes import (StudentInputError, LoncapaProblemError, + ResponseError) +from xmodule.capa_module import CapaModule, ComplexEncoder from xmodule.modulestore import Location from django.http import QueryDict @@ -530,6 +531,32 @@ class CapaModuleTest(unittest.TestCase): # Expect that the number of attempts is NOT incremented self.assertEqual(module.attempts, 1) + def test_check_problem_other_errors(self): + """ + Test that errors other than the expected kinds give an appropriate message. + + See also `test_check_problem_error` for the "expected kinds" or errors. + """ + # Create the module + module = CapaFactory.create(attempts=1) + + # Ensure that the user is NOT staff + module.system.user_is_staff = False + + # Ensure that DEBUG is on + module.system.DEBUG = True + + # Simulate answering a problem that raises the exception + with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade: + error_msg = u"Superterrible error happened: ☠" + mock_grade.side_effect = Exception(error_msg) + + get_request_dict = {CapaFactory.input_key(): '3.14'} + result = module.check_problem(get_request_dict) + + # Expect an AJAX alert message in 'success' + self.assertTrue(error_msg in result['success']) + def test_check_problem_error_nonascii(self): # Try each exception that capa_module should handle @@ -1059,6 +1086,33 @@ class CapaModuleTest(unittest.TestCase): # Expect that the module has created a new dummy problem with the error self.assertNotEqual(original_problem, module.lcp) + def test_get_problem_html_error_w_debug(self): + """ + Test the html response when an error occurs with DEBUG on + """ + module = CapaFactory.create() + + # Simulate throwing an exception when the capa problem + # is asked to render itself as HTML + error_msg = u"Superterrible error happened: ☠" + module.lcp.get_html = Mock(side_effect=Exception(error_msg)) + + # Stub out the get_test_system rendering function + module.system.render_template = Mock(return_value="
Test Template HTML
") + + # Make sure DEBUG is on + module.system.DEBUG = True + + # Try to render the module with DEBUG turned on + html = module.get_problem_html() + + self.assertTrue(html is not None) + + # Check the rendering context + render_args, _ = module.system.render_template.call_args + context = render_args[1] + self.assertTrue(error_msg in context['problem']['html']) + def test_random_seed_no_change(self): # Run the test for each possible rerandomize value @@ -1164,3 +1218,28 @@ class CapaModuleTest(unittest.TestCase): for i in range(200): module = CapaFactory.create(rerandomize=rerandomize) assert 0 <= module.seed < 1000 + + @patch('xmodule.capa_module.log') + @patch('xmodule.capa_module.Progress') + def test_get_progress_error(self, mock_progress, mock_log): + """ + Check that an exception given in `Progress` produces a `log.exception` call. + """ + error_types = [TypeError, ValueError] + for error_type in error_types: + mock_progress.side_effect = error_type + module = CapaFactory.create() + self.assertIsNone(module.get_progress()) + mock_log.exception.assert_called_once_with('Got bad progress') + mock_log.reset_mock() + + +class ComplexEncoderTest(unittest.TestCase): + def test_default(self): + """ + Check that complex numbers can be encoded into JSON. + """ + complex_num = 1 - 1j + expected_str = '1-1*j' + json_str = json.dumps(complex_num, cls=ComplexEncoder) + self.assertEqual(expected_str, json_str[1:-1]) # ignore quotes From d5d495c24d622ca9e1b8b6d0b30164df024230a3 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 24 Jun 2013 16:57:14 -0400 Subject: [PATCH 040/161] Fix acceptance tests expecting outdated CSS. --- cms/djangoapps/contentstore/features/advanced-settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 2360baea5a..1661e1c391 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -27,7 +27,7 @@ def i_am_on_advanced_course_settings(step): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): - css = 'a.%s-button' % name.lower() + css = 'a.action-%s' % name.lower() # Save was clicked if either the save notification bar is gone, or we have a error notification # overlaying it (expected in the case of typing Object into display_name). From 986b63d85d9fda2135d239ad2caf84365b2f80d3 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 24 Jun 2013 17:07:43 -0400 Subject: [PATCH 041/161] Removed run_wrapped() and replaced its functionality with patch.dict(): --- lms/djangoapps/courseware/tests/helpers.py | 4 ++ .../courseware/tests/test_navigation.py | 1 - .../tests/test_view_authentication.py | 64 ++++++++----------- 3 files changed, 29 insertions(+), 40 deletions(-) diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index ce0603990b..1ceeb14433 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -44,6 +44,10 @@ def check_for_post_code(self, code, url, data={}): class LoginEnrollmentTestCase(TestCase): + """ + Provides support for user creation, + activation, login, and course enrollment. + """ def setup_user(self): """ diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index f4662f2ef5..f1aa7f5b31 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -21,7 +21,6 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): """ def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} self.course = CourseFactory.create() self.full = CourseFactory.create(display_name='Robot_Sub_Course') diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 8e03e2563b..1edeac58ed 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -10,7 +10,7 @@ from django.test.utils import override_settings # Need access to internal func to put users in the right group from courseware.access import (has_access, _course_staff_group_name, - course_beta_test_group_name) + course_beta_test_group_name, settings as access_settings) from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -20,6 +20,7 @@ from helpers import LoginEnrollmentTestCase, check_for_get_code from modulestore_config import TEST_DATA_MONGO_MODULESTORE +#@patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': True}) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ @@ -81,20 +82,13 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ List of urls that students should be able to see before launch. + `course` is an instance of CourseDescriptor. """ urls = self._reverse_urls(['about_course'], course) urls.append(reverse('courses')) return urls - def instructor_urls(self, course): - """ - List of urls that only instructors/staff should be able to see. - """ - urls = self._reverse_urls(['instructor_dashboard', - 'gradebook', 'grade_summary'], course) - return urls - def _check_non_staff_light(self, course): """ Check that non-staff have access to light urls. @@ -295,25 +289,12 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Make sure that before course start, students can't access course pages, but instructors can. """ - self.run_wrapped(self._do_test_dark_launch_enrolled_student) - self.run_wrapped(self._do_test_dark_launch_instructor) - self.run_wrapped(self._do_test_dark_launch_staff) - def test_enrollment_period(self): + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_dark_launch_enrolled_student(self): """ - Check that enrollment periods work. - """ - self.run_wrapped(self._do_test_enrollment_period) - - # def test_beta_period(self): - # """ - # Check that beta-test access works. - # """ - # self.run_wrapped(self._do_test_beta_period) - - def _do_test_dark_launch_enrolled_student(self): - """ - Actually do the test, relying on settings to be right. + Make sure that before course start, students can't access course + pages. """ student_email, student_password = self.ACCOUNT_INFO[0] @@ -328,7 +309,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertFalse(self.course.has_started()) self.assertFalse(self.full.has_started()) - self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) # First, try with an enrolled student print '=== Testing student access....' @@ -342,7 +322,12 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self._check_non_staff_light(self.full) self._check_non_staff_dark(self.full) - def _do_test_dark_launch_instructor(self): + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_dark_launch_instructor(self): + """ + Make sure that before course start instructors can access the + page for their course. + """ instructor_email, instructor_password = self.ACCOUNT_INFO[1] @@ -369,7 +354,12 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self._check_non_staff_dark(self.full) self._check_staff(self.course) - def _do_test_dark_launch_staff(self): + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_dark_launch_staff(self): + """ + Make sure that before course start staff can access + course pages. + """ instructor_email, instructor_password = self.ACCOUNT_INFO[1] @@ -394,9 +384,10 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self._check_staff(self.course) self._check_staff(self.full) - def _do_test_enrollment_period(self): + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_enrollment_period(self): """ - Actually do the test, relying on settings to be right. + Check that enrollment periods work. """ student_email, student_password = self.ACCOUNT_INFO[0] @@ -447,20 +438,15 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.unenroll(self.course) self.assertTrue(self.enroll(self.course)) - #from courseware.access import MITX_FEATURES - - #@patch.dict(MITX_FEATURES, {'DISABLE_START_DATES': True}) - def _do_test_beta_period(self): + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_beta_period(self): """ - Actually test beta periods, relying on settings to be right. + Check that beta-test access works. """ student_email, student_password = self.ACCOUNT_INFO[0] instructor_email, instructor_password = self.ACCOUNT_INFO[1] - # trust, but verify :) - self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES']) - # Make courses start in the future now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) From 7fd1190505fde49e24763927721701187bcceaf0 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 24 Jun 2013 17:13:33 -0400 Subject: [PATCH 042/161] Updated a doc string. --- lms/djangoapps/courseware/tests/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 1ceeb14433..a02a0dfe50 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -120,7 +120,7 @@ class LoginEnrollmentTestCase(TestCase): """ Try to enroll and return boolean indicating result. `course` is an instance of CourseDescriptor. - `verify` is an optional parameter specifying whether we + `verify` is an optional boolean parameter specifying whether we want to verify that the student was successfully enrolled in the course. """ From 1b344e4d4d14e7996ffff393a0d6d8d50f6aeae5 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 24 Jun 2013 17:20:59 -0400 Subject: [PATCH 043/161] Removed some unused functions. --- .../tests/test_view_authentication.py | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 1edeac58ed..6a6c539b60 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -29,27 +29,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] - @classmethod - def _instructor_urls(self, course): - """ - `course` is an instance of CourseDescriptor whose section URLs are to be returned. - - Returns a list of URLs corresponding to sections in the passed in course. - """ - - urls = [reverse(name, kwargs={'course_id': course.id}) for name in ( - 'instructor_dashboard', - 'gradebook', - 'grade_summary',)] - - email, _ = self.ACCOUNT_INFO[0] - student_id = User.objects.get(email=email).id - - urls.append(reverse('student_progress', - kwargs={'course_id': course.id, - 'student_id': student_id})) - return urls - @staticmethod def _reverse_urls(names, course): """ @@ -65,30 +44,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): return [reverse(name, kwargs={'course_id': course.id}) for name in names] - def _dark_student_urls(self, course): - """ - List of urls that students should be able to see only - after launch, but staff should see before - """ - urls = self._reverse_urls(['info', 'progress'], course) - urls.extend([ - reverse('book', kwargs={'course_id': course.id, - 'book_index': index}) - for index, book in enumerate(course.textbooks) - ]) - return urls - - def _light_student_urls(self, course): - """ - List of urls that students should be able to see before - launch. - `course` is an instance of CourseDescriptor. - """ - urls = self._reverse_urls(['about_course'], course) - urls.append(reverse('courses')) - - return urls - def _check_non_staff_light(self, course): """ Check that non-staff have access to light urls. From c4c68f516b796c8f9ac837ff30fab9ac59126771 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 24 Jun 2013 17:31:49 -0400 Subject: [PATCH 044/161] Removed some unnecessary imports. --- .../courseware/tests/test_navigation.py | 2 -- .../courseware/tests/test_view_authentication.py | 15 --------------- 2 files changed, 17 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index f1aa7f5b31..eaeb062504 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -5,8 +5,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -import xmodule.modulestore.django - from helpers import LoginEnrollmentTestCase, check_for_get_code from modulestore_config import TEST_DATA_MONGO_MODULESTORE diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 6a6c539b60..b118f99ca2 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -224,21 +224,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): print 'checking for 200 on {0}'.format(url) check_for_get_code(self, 200, url) - def run_wrapped(self, test): - """ - test.py turns off start dates. Enable them. - Because settings is global, be careful not to mess it up for other tests - (Can't use override_settings because we're only changing part of the - MITX_FEATURES dict) - """ - oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] - - try: - settings.MITX_FEATURES['DISABLE_START_DATES'] = False - test() - finally: - settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD - def test_dark_launch(self): """ Make sure that before course start, students can't access course From 332a440539928e7bd8d9d9fab3dd8d5f475f4b97 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 19 Jun 2013 18:09:18 -0400 Subject: [PATCH 045/161] Enable per-student background tasks. --- lms/djangoapps/instructor_task/api_helper.py | 7 +- lms/djangoapps/instructor_task/models.py | 21 +++++- .../instructor_task/tests/test_api.py | 2 +- .../instructor_task/tests/test_integration.py | 2 +- .../instructor_task/tests/test_tasks.py | 71 +++++++++++++++---- .../instructor_task/tests/test_views.py | 5 +- .../courseware/instructor_dashboard.html | 6 +- 7 files changed, 87 insertions(+), 27 deletions(-) diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py index f9febd17d7..2795fd08c1 100644 --- a/lms/djangoapps/instructor_task/api_helper.py +++ b/lms/djangoapps/instructor_task/api_helper.py @@ -2,8 +2,6 @@ import hashlib import json import logging -from django.db import transaction - from celery.result import AsyncResult from celery.states import READY_STATES, SUCCESS, FAILURE, REVOKED @@ -30,7 +28,6 @@ def _task_is_running(course_id, task_type, task_key): return len(runningTasks) > 0 -@transaction.autocommit def _reserve_task(course_id, task_type, task_key, task_input, requester): """ Creates a database entry to indicate that a task is in progress. @@ -39,9 +36,9 @@ def _reserve_task(course_id, task_type, task_key, task_input, requester): Includes the creation of an arbitrary value for task_id, to be submitted with the task call to celery. - Autocommit annotation makes sure the database entry is committed. + The InstructorTask.create method makes sure the InstructorTask entry is committed. When called from any view that is wrapped by TransactionMiddleware, - and thus in a "commit-on-success" transaction, this autocommit here + and thus in a "commit-on-success" transaction, an autocommit buried within here will cause any pending transaction to be committed by a successful save here. Any future database operations will take place in a separate transaction. diff --git a/lms/djangoapps/instructor_task/models.py b/lms/djangoapps/instructor_task/models.py index f01cc4e3ad..b28a9a3d83 100644 --- a/lms/djangoapps/instructor_task/models.py +++ b/lms/djangoapps/instructor_task/models.py @@ -72,6 +72,16 @@ class InstructorTask(models.Model): @classmethod def create(cls, course_id, task_type, task_key, task_input, requester): + """ + Create an instance of InstructorTask. + + The InstructorTask.save_now method makes sure the InstructorTask entry is committed. + When called from any view that is wrapped by TransactionMiddleware, + and thus in a "commit-on-success" transaction, an autocommit buried within here + will cause any pending transaction to be committed by a successful + save here. Any future database operations will take place in a + separate transaction. + """ # create the task_id here, and pass it into celery: task_id = str(uuid4()) @@ -99,7 +109,16 @@ class InstructorTask(models.Model): @transaction.autocommit def save_now(self): - """Writes InstructorTask immediately, ensuring the transaction is committed.""" + """ + Writes InstructorTask immediately, ensuring the transaction is committed. + + Autocommit annotation makes sure the database entry is committed. + When called from any view that is wrapped by TransactionMiddleware, + and thus in a "commit-on-success" transaction, this autocommit here + will cause any pending transaction to be committed by a successful + save here. Any future database operations will take place in a + separate transaction. + """ self.save() @staticmethod diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 841fdca8a0..1e40c51c4b 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -22,7 +22,7 @@ from instructor_task.tests.test_base import (InstructorTaskTestCase, class InstructorTaskReportTest(InstructorTaskTestCase): """ - Tests API and view methods that involve the reporting of status for background tasks. + Tests API methods that involve the reporting of status for background tasks. """ def test_get_running_instructor_tasks(self): diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py index 5a17e32329..9b56663753 100644 --- a/lms/djangoapps/instructor_task/tests/test_integration.py +++ b/lms/djangoapps/instructor_task/tests/test_integration.py @@ -1,5 +1,5 @@ """ -Integration Tests for LMS instructor-initiated background tasks +Integration Tests for LMS instructor-initiated background tasks. Runs tasks on answers to course problems to validate that code paths actually work. diff --git a/lms/djangoapps/instructor_task/tests/test_tasks.py b/lms/djangoapps/instructor_task/tests/test_tasks.py index c59a7065ae..090c114720 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks.py @@ -1,5 +1,5 @@ """ -Unit tests for LMS instructor-initiated background tasks, +Unit tests for LMS instructor-initiated background tasks. Runs tasks on answers to course problems to validate that code paths actually work. @@ -7,6 +7,7 @@ paths actually work. """ import json from uuid import uuid4 +from unittest import skip from mock import Mock, patch @@ -62,6 +63,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): } def _run_task_with_mock_celery(self, task_function, entry_id, task_id, expected_failure_message=None): + """Submit a task and mock how celery provides a current_task.""" self.current_task = Mock() self.current_task.request = Mock() self.current_task.request.id = task_id @@ -73,7 +75,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): return task_function(entry_id, self._get_xmodule_instance_args()) def _test_missing_current_task(self, task_function): - # run without (mock) Celery running + """Check that a task_function fails when celery doesn't provide a current_task.""" task_entry = self._create_input_entry() with self.assertRaises(UpdateProblemModuleStateError): task_function(task_entry.id, self._get_xmodule_instance_args()) @@ -88,7 +90,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self._test_missing_current_task(delete_problem_state) def _test_undefined_problem(self, task_function): - # run with celery, but no problem defined + """Run with celery, but no problem defined.""" task_entry = self._create_input_entry() with self.assertRaises(ItemNotFoundError): self._run_task_with_mock_celery(task_function, task_entry.id, task_entry.task_id) @@ -103,7 +105,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self._test_undefined_problem(delete_problem_state) def _test_run_with_task(self, task_function, action_name, expected_num_updated): - # run with some StudentModules for the problem + """Run a task and check the number of StudentModules processed.""" task_entry = self._create_input_entry() status = self._run_task_with_mock_celery(task_function, task_entry.id, task_entry.task_id) # check return value @@ -118,7 +120,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self.assertEquals(entry.task_state, SUCCESS) def _test_run_with_no_state(self, task_function, action_name): - # run with no StudentModules for the problem + """Run with no StudentModules defined for the current problem.""" self.define_option_problem(PROBLEM_URL_NAME) self._test_run_with_task(task_function, action_name, 0) @@ -185,7 +187,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): module_state_key=self.problem_url) def _test_reset_with_student(self, use_email): - # run with some StudentModules for the problem + """Run a reset task for one student, with several StudentModules for the problem defined.""" num_students = 10 initial_attempts = 3 input_state = json.dumps({'attempts': initial_attempts}) @@ -233,8 +235,7 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self._test_reset_with_student(True) def _test_run_with_failure(self, task_function, expected_message): - # run with no StudentModules for the problem, - # because we will fail before entering the loop. + """Run a task and trigger an artificial failure with give message.""" task_entry = self._create_input_entry() self.define_option_problem(PROBLEM_URL_NAME) with self.assertRaises(TestTaskFailure): @@ -256,8 +257,10 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self._test_run_with_failure(delete_problem_state, 'We expected this to fail') def _test_run_with_long_error_msg(self, task_function): - # run with an error message that is so long it will require - # truncation (as well as the jettisoning of the traceback). + """ + Run with an error message that is so long it will require + truncation (as well as the jettisoning of the traceback). + """ task_entry = self._create_input_entry() self.define_option_problem(PROBLEM_URL_NAME) expected_message = "x" * 1500 @@ -282,9 +285,11 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self._test_run_with_long_error_msg(delete_problem_state) def _test_run_with_short_error_msg(self, task_function): - # run with an error message that is short enough to fit - # in the output, but long enough that the traceback won't. - # Confirm that the traceback is truncated. + """ + Run with an error message that is short enough to fit + in the output, but long enough that the traceback won't. + Confirm that the traceback is truncated. + """ task_entry = self._create_input_entry() self.define_option_problem(PROBLEM_URL_NAME) expected_message = "x" * 900 @@ -330,3 +335,43 @@ class TestInstructorTasks(InstructorTaskModuleTestCase): self.assertEquals(output['exception'], 'ValueError') self.assertTrue("Length of task output is too long" in output['message']) self.assertTrue('traceback' not in output) + + @skip + def test_rescoring_unrescorable(self): + # TODO: this test needs to have Mako templates initialized + # to make sure that the creation of an XModule works. + input_state = json.dumps({'done': True}) + num_students = 1 + self._create_students_with_state(num_students, input_state) + task_entry = self._create_input_entry() + with self.assertRaises(UpdateProblemModuleStateError): + self._run_task_with_mock_celery(rescore_problem, task_entry.id, task_entry.task_id) + # check values stored in table: + entry = InstructorTask.objects.get(id=task_entry.id) + output = json.loads(entry.task_output) + self.assertEquals(output['exception'], "UpdateProblemModuleStateError") + self.assertEquals(output['message'], "Specified problem does not support rescoring.") + self.assertGreater(len(output['traceback']), 0) + + @skip + def test_rescoring_success(self): + # TODO: this test needs to have Mako templates initialized + # to make sure that the creation of an XModule works. + input_state = json.dumps({'done': True}) + num_students = 10 + self._create_students_with_state(num_students, input_state) + task_entry = self._create_input_entry() + mock_instance = Mock() + mock_instance.rescore_problem = Mock({'success': 'correct'}) + # TODO: figure out why this mock is not working.... + with patch('courseware.module_render.get_module_for_descriptor_internal') as mock_get_module: + mock_get_module.return_value = mock_instance + self._run_task_with_mock_celery(rescore_problem, task_entry.id, task_entry.task_id) + # check return value + entry = InstructorTask.objects.get(id=task_entry.id) + output = json.loads(entry.task_output) + self.assertEquals(output.get('attempted'), num_students) + self.assertEquals(output.get('updated'), num_students) + self.assertEquals(output.get('total'), num_students) + self.assertEquals(output.get('action_name'), 'rescored') + self.assertGreater('duration_ms', 0) diff --git a/lms/djangoapps/instructor_task/tests/test_views.py b/lms/djangoapps/instructor_task/tests/test_views.py index 9020bf6e60..41de314abd 100644 --- a/lms/djangoapps/instructor_task/tests/test_views.py +++ b/lms/djangoapps/instructor_task/tests/test_views.py @@ -1,6 +1,6 @@ """ -Test for LMS instructor background task queue management +Test for LMS instructor background task views. """ import json from celery.states import SUCCESS, FAILURE, REVOKED, PENDING @@ -18,7 +18,7 @@ from instructor_task.views import instructor_task_status, get_task_completion_in class InstructorTaskReportTest(InstructorTaskTestCase): """ - Tests API and view methods that involve the reporting of status for background tasks. + Tests view methods that involve the reporting of status for background tasks. """ def _get_instructor_task_status(self, task_id): @@ -263,4 +263,3 @@ class InstructorTaskReportTest(InstructorTaskTestCase): succeeded, message = get_task_completion_info(instructor_task) self.assertFalse(succeeded) self.assertEquals(message, "Problem rescored for 2 of 3 students (out of 5)") - diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index ef1eb174fc..d541962906 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -249,7 +249,7 @@ function goto( mode)

Then select an action: - %if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'): + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): %endif

@@ -260,9 +260,9 @@ function goto( mode)

%endif - %if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'): + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):

Rescoring runs in the background, and status for active tasks will appear in a table below. - To see status for all tasks submitted for this course and student, click on this button: + To see status for all tasks submitted for this problem and student, click on this button:

From ddc986f775e5eb2cf5bb30644ae7d934140805cb Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 25 Jun 2013 11:25:29 -0400 Subject: [PATCH 046/161] Call event.preventDefault() on notification action buttons But allow you to specify that the event should not be prevented --- .../coffee/spec/views/feedback_spec.coffee | 39 +++++++++++++++++++ cms/static/js/views/feedback.js | 14 ++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index e5916c5ed3..adec11e2a7 100644 --- a/cms/static/coffee/spec/views/feedback_spec.coffee +++ b/cms/static/coffee/spec/views/feedback_spec.coffee @@ -17,6 +17,16 @@ beforeEach -> return text.test(trimmedText) else return trimmedText.indexOf(text) != -1; + toHaveBeenPrevented: -> + # remove this when we upgrade jasmine-jquery + eventName = @actual.eventName + selector = @actual.selector + @message = -> + [ + "Expected event #{eventName} to have been prevented on #{selector}", + "Expected event #{eventName} not to have been prevented on #{selector}" + ] + return jasmine.JQuery.events.wasPrevented(selector, eventName) describe "CMS.Views.SystemFeedback", -> beforeEach -> @@ -123,6 +133,35 @@ describe "CMS.Views.SystemFeedback click events", -> it "should apply class to secondary action", -> expect(@view.$(".action-secondary")).toHaveClass("cancel-button") + it "should preventDefault on primary action", -> + spyOnEvent(".action-primary", "click") + @view.$(".action-primary").click() + expect("click").toHaveBeenPreventedOn(".action-primary") + + it "should preventDefault on secondary action", -> + spyOnEvent(".action-secondary", "click") + @view.$(".action-secondary").click() + expect("click").toHaveBeenPreventedOn(".action-secondary") + +describe "CMS.Views.SystemFeedback not preventing events", -> + beforeEach -> + @clickSpy = jasmine.createSpy('clickSpy') + @view = new CMS.Views.Alert.Confirmation( + title: "It's all good" + message: "No reason for this alert" + actions: + primary: + text: "Whatever" + click: @clickSpy + preventDefault: false + ) + @view.show() + + it "should not preventDefault", -> + spyOnEvent(".action-primary", "click") + @view.$(".action-primary").click() + expect("click").not.toHaveBeenPreventedOn(".action-primary") + expect(@clickSpy).toHaveBeenCalled() describe "CMS.Views.SystemFeedback multiple secondary actions", -> beforeEach -> diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 3f161d5b1f..3bfeeb5af2 100644 --- a/cms/static/js/views/feedback.js +++ b/cms/static/js/views/feedback.js @@ -10,8 +10,12 @@ CMS.Views.SystemFeedback = Backbone.View.extend({ minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds) maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds) - /* could also have an "actions" hash: here is an example demonstrating - the expected structure + /* Could also have an "actions" hash: here is an example demonstrating + the expected structure. For each action, by default the framework + will call preventDefault on the click event before the function is + run; to make it not do that, just pass `preventDefault: false` in + the action object. + actions: { primary: { "text": "Save", @@ -106,6 +110,9 @@ CMS.Views.SystemFeedback = Backbone.View.extend({ if(!actions) { return; } var primary = actions.primary; if(!primary) { return; } + if(primary.preventDefault !== false) { + event.preventDefault(); + } if(primary.click) { primary.click.call(event.target, this, event); } @@ -121,6 +128,9 @@ CMS.Views.SystemFeedback = Backbone.View.extend({ i = _.indexOf(this.$(".action-secondary"), event.target); } var secondary = secondaryList[i]; + if(secondary.preventDefault !== false) { + event.preventDefault(); + } if(secondary.click) { secondary.click.call(event.target, this, event); } From a9a7f97d9b694078ccc23706d29a1fcb11dcc74a Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Tue, 25 Jun 2013 11:32:45 -0400 Subject: [PATCH 047/161] Update CHANGELOG for per-student problem rescoring. --- CHANGELOG.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb8eec738f..21b8c9f90b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,11 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Problem rescoring. Added options on the Grades tab of the +Instructor Dashboard to allow a particular student's submission for a +particular problem to be rescored. Provides an option to see a +history of background tasks for a given problem and student. + Blades: Small UX fix on capa multiple-choice problems. Make labels only as wide as the text to reduce accidental choice selections. From 8a9125f121a1b983b33d4c7f8cd16deeee8335cf Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Tue, 25 Jun 2013 11:33:46 -0400 Subject: [PATCH 048/161] Test Mongo database is now unique and destroyed in teardown --- common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index c5ef0d751a..44e69fb0ed 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -13,11 +13,12 @@ from xmodule.templates import update_templates from .test_modulestore import check_path_to_location from . import DATA_DIR +from uuid import uuid4 HOST = 'localhost' PORT = 27017 -DB = 'test' +DB = 'test_mongo_%s' % uuid4().hex COLLECTION = 'modulestore' FS_ROOT = DATA_DIR # TODO (vshnayder): will need a real fs_root for testing load_item DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor' @@ -39,7 +40,8 @@ class TestMongoModuleStore(object): @classmethod def teardownClass(cls): - pass + cls.connection = pymongo.connection.Connection(HOST, PORT) + cls.connection.drop_database(DB) @staticmethod def initdb(): From c0805c334d1d5bbfe6582e8042fae1ad5ae75f77 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 25 Jun 2013 13:23:13 -0400 Subject: [PATCH 049/161] Updated diff-cover to version 0.1.3 to fix a bug --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 5ce748e7b5..f64568dc10 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -10,4 +10,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@4d8735e883#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail --e git+https://github.com/edx/diff-cover.git@v0.1.2#egg=diff_cover +-e git+https://github.com/edx/diff-cover.git@v0.1.3#egg=diff_cover From 2f02496c8f14e51eaaa8180ee0acfec9f375cb3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 13 Jun 2013 13:54:51 -0400 Subject: [PATCH 050/161] Reorder imports on module_render --- lms/djangoapps/courseware/module_render.py | 30 ++++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 3ffb1d1b1d..15a6ad2dab 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -2,8 +2,6 @@ import json import logging import re import sys -import static_replace - from functools import partial from django.conf import settings @@ -15,27 +13,31 @@ from django.http import Http404 from django.http import HttpResponse, HttpResponseBadRequest from django.views.decorators.csrf import csrf_exempt +import pyparsing from requests.auth import HTTPBasicAuth +from statsd import statsd from capa.xqueue_interface import XQueueInterface -from courseware.masquerade import setup_masquerade -from courseware.access import has_access from mitxmako.shortcuts import render_to_string -from .models import StudentModule -from psychometrics.psychoanalyze import make_psychometrics_data_update_handler -from student.models import unique_id_for_user +from xblock.runtime import DbModel +from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor from xmodule.errortracker import exc_info_to_str from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from xmodule.x_module import ModuleSystem -from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor -from xblock.runtime import DbModel -from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule -from .model_data import LmsKeyValueStore, LmsUsage, ModelDataCache - from xmodule.modulestore.exceptions import ItemNotFoundError -from statsd import statsd +from xmodule.x_module import ModuleSystem +from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule + +import static_replace +from psychometrics.psychoanalyze import make_psychometrics_data_update_handler +from student.models import unique_id_for_user + +from courseware.access import has_access +from courseware.masquerade import setup_masquerade +from courseware.model_data import LmsKeyValueStore, LmsUsage, ModelDataCache +from courseware.models import StudentModule + log = logging.getLogger(__name__) From e4ee1c6c9b1527ade4cb7c584d7bb5c7fa1c6753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Mon, 24 Jun 2013 15:29:54 -0400 Subject: [PATCH 051/161] Rename arguments of modx_dispatch and handle_ajax related functions Refactor a bit modx_dispatch --- common/lib/capa/capa/capa_problem.py | 8 +- common/lib/capa/capa/inputtypes.py | 22 ++-- common/lib/capa/capa/tests/test_inputtypes.py | 8 +- common/lib/xmodule/xmodule/capa_module.py | 73 +++++++------ .../xmodule/combined_open_ended_module.py | 5 +- .../lib/xmodule/xmodule/conditional_module.py | 2 +- .../combined_open_ended_modulev1.py | 40 +++---- .../open_ended_module.py | 42 ++++---- .../openendedchild.py | 40 +++---- .../self_assessment_module.py | 34 +++--- .../xmodule/xmodule/peer_grading_module.py | 73 ++++++------- common/lib/xmodule/xmodule/poll_module.py | 4 +- common/lib/xmodule/xmodule/seq_module.py | 4 +- .../lib/xmodule/xmodule/tests/test_logic.py | 4 +- .../lib/xmodule/xmodule/timelimit_module.py | 3 +- common/lib/xmodule/xmodule/video_module.py | 4 +- .../lib/xmodule/xmodule/videoalpha_module.py | 4 +- .../lib/xmodule/xmodule/word_cloud_module.py | 6 +- common/lib/xmodule/xmodule/x_module.py | 4 +- lms/djangoapps/courseware/module_render.py | 100 +++++++++++------- lms/urls.py | 4 +- 21 files changed, 254 insertions(+), 230 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index d620bac60a..2c813f49d5 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -373,7 +373,7 @@ class LoncapaProblem(object): html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context) return html - def handle_input_ajax(self, get): + def handle_input_ajax(self, data): ''' InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data @@ -381,10 +381,10 @@ class LoncapaProblem(object): ''' # pull out the id - input_id = get['input_id'] + input_id = data['input_id'] if self.inputs[input_id]: - dispatch = get['dispatch'] - return self.inputs[input_id].handle_ajax(dispatch, get) + dispatch = data['dispatch'] + return self.inputs[input_id].handle_ajax(dispatch, data) else: log.warning("Could not find matching input for id: %s" % input_id) return {} diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index f026568da1..4c40a2cd3e 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -223,13 +223,13 @@ class InputTypeBase(object): """ pass - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """ 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 + data: a dictionary containing the data that was sent with the ajax call Output: a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. @@ -677,20 +677,20 @@ class MatlabInput(CodeInput): self.queue_len = 1 self.msg = self.plot_submitted_msg - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): ''' Handle AJAX calls directed to this input Args: - dispatch (str) - indicates how we want this ajax call to be handled - - get (dict) - dictionary of key-value pairs that contain useful data + - data (dict) - dictionary of key-value pairs that contain useful data Returns: dict - 'success' - whether or not we successfully queued this submission - 'message' - message to be rendered in case of error ''' if dispatch == 'plot': - return self._plot_data(get) + return self._plot_data(data) return {} def ungraded_response(self, queue_msg, queuekey): @@ -751,7 +751,7 @@ class MatlabInput(CodeInput): msg = result['msg'] return msg - def _plot_data(self, get): + def _plot_data(self, data): ''' AJAX handler for the plot button Args: @@ -765,7 +765,7 @@ class MatlabInput(CodeInput): return {'success': False, 'message': 'Cannot connect to the queue'} # pull relevant info out of get - response = get['submission'] + response = data['submission'] # construct xqueue headers qinterface = self.system.xqueue['interface'] @@ -951,16 +951,16 @@ class ChemicalEquationInput(InputTypeBase): """ return {'previewer': '/static/js/capa/chemical_equation_preview.js', } - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): ''' 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': - return self.preview_chemcalc(get) + return self.preview_chemcalc(data) return {} - def preview_chemcalc(self, get): + def preview_chemcalc(self, data): """ Render an html preview of a chemical formula or equation. get should contain a key 'formula' and value 'some formula string'. @@ -974,7 +974,7 @@ class ChemicalEquationInput(InputTypeBase): result = {'preview': '', 'error': ''} - formula = get['formula'] + formula = data['formula'] if formula is None: result['error'] = "No formula specified." return result diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 313eb28249..1b52d41890 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -467,8 +467,8 @@ class MatlabTest(unittest.TestCase): self.assertEqual(context, expected) def test_plot_data(self): - get = {'submission': 'x = 1234;'} - response = self.the_input.handle_ajax("plot", get) + data = {'submission': 'x = 1234;'} + response = self.the_input.handle_ajax("plot", data) test_system().xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY) @@ -477,10 +477,10 @@ class MatlabTest(unittest.TestCase): self.assertEqual(self.the_input.input_state['queuestate'], 'queued') def test_plot_data_failure(self): - get = {'submission': 'x = 1234;'} + data = {'submission': 'x = 1234;'} error_message = 'Error message!' test_system().xqueue['interface'].send_to_queue.return_value = (1, error_message) - response = self.the_input.handle_ajax("plot", get) + response = self.the_input.handle_ajax("plot", data) self.assertFalse(response['success']) self.assertEqual(response['message'], error_message) self.assertTrue('queuekey' not in self.the_input.input_state) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index bb06912f7a..eeb8f19439 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -519,11 +519,11 @@ class CapaModule(CapaFields, XModule): # now do the substitutions which are filesystem based, e.g. '/static/' prefixes return self.system.replace_urls(html) - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """ This is called by courseware.module_render, to handle an AJAX call. - `get` is request.POST. + `data` is request.POST. Returns a json dictionary: { 'progress_changed' : True/False, @@ -547,18 +547,19 @@ class CapaModule(CapaFields, XModule): before = self.get_progress() try: - d = handlers[dispatch](get) - + result = handlers[dispatch](data) except Exception as err: _, _, traceback_obj = sys.exc_info() - raise ProcessingError, err.message, traceback_obj + raise ProcessingError(err.message, traceback_obj) after = self.get_progress() - d.update({ + + result.update({ 'progress_changed': after != before, 'progress_status': Progress.to_js_status_str(after), }) - return json.dumps(d, cls=ComplexEncoder) + + return json.dumps(result, cls=ComplexEncoder) def is_past_due(self): """ @@ -633,32 +634,32 @@ class CapaModule(CapaFields, XModule): return False - def update_score(self, get): + def update_score(self, data): """ Delivers grading response (e.g. from asynchronous code checking) to the capa problem, so its score can be updated - `get` must have a field `response` which is a string that contains the + 'data' must have a key 'response' which is a string that contains the grader's response No ajax return is needed. Return empty dict. """ - queuekey = get['queuekey'] - score_msg = get['xqueue_body'] + queuekey = data['queuekey'] + score_msg = data['xqueue_body'] self.lcp.update_score(score_msg, queuekey) self.set_state_from_lcp() self.publish_grade() return dict() # No AJAX return is needed - def handle_ungraded_response(self, get): + def handle_ungraded_response(self, data): """ Delivers a response from the XQueue to the capa problem The score of the problem will not be updated Args: - - get (dict) must contain keys: + - data (dict) must contain keys: queuekey - a key specific to this response xqueue_body - the body of the response Returns: @@ -666,28 +667,30 @@ class CapaModule(CapaFields, XModule): No ajax return is needed, so an empty dict is returned """ - queuekey = get['queuekey'] - score_msg = get['xqueue_body'] + queuekey = data['queuekey'] + score_msg = data['xqueue_body'] + # pass along the xqueue message to the problem self.lcp.ungraded_response(score_msg, queuekey) self.set_state_from_lcp() return dict() - def handle_input_ajax(self, get): + def handle_input_ajax(self, data): """ Handle ajax calls meant for a particular input in the problem Args: - - get (dict) - data that should be passed to the input + - data (dict) - data that should be passed to the input Returns: - dict containing the response from the input """ - response = self.lcp.handle_input_ajax(get) + response = self.lcp.handle_input_ajax(data) + # save any state changes that may occur self.set_state_from_lcp() return response - def get_answer(self, get): + def get_answer(self, data): """ For the "show answer" button. @@ -717,10 +720,9 @@ class CapaModule(CapaFields, XModule): return {'answers': new_answers} # Figure out if we should move these to capa_problem? - def get_problem(self, get): + def get_problem(self, _data): """ Return results of get_problem_html, as a simple dict for json-ing. - { 'html': } Used if we want to reconfirm we have the right thing e.g. after @@ -729,27 +731,27 @@ class CapaModule(CapaFields, XModule): return {'html': self.get_problem_html(encapsulate=False)} @staticmethod - def make_dict_of_responses(get): + def make_dict_of_responses(data): """ Make dictionary of student responses (aka "answers") - `get` is POST dictionary (Django QueryDict). + `data` is POST dictionary (Django QueryDict). - The `get` dict has keys of the form 'x_y', which are mapped + The `data` dict has keys of the form 'x_y', which are mapped to key 'y' in the returned dict. For example, 'input_1_2_3' would be mapped to '1_2_3' in the returned dict. Some inputs always expect a list in the returned dict (e.g. checkbox inputs). The convention is that - keys in the `get` dict that end with '[]' will always + keys in the `data` dict that end with '[]' will always have list values in the returned dict. - For example, if the `get` dict contains {'input_1[]': 'test' } + For example, if the `data` dict contains {'input_1[]': 'test' } then the output dict would contain {'1': ['test'] } (the value is a list). Raises an exception if: - -A key in the `get` dictionary does not contain at least one underscore + -A key in the `data` dictionary does not contain at least one underscore (e.g. "input" is invalid, but "input_1" is valid) -Two keys end up with the same name in the returned dict. @@ -758,7 +760,7 @@ class CapaModule(CapaFields, XModule): """ answers = dict() - for key in get: + for key in data: # e.g. input_resistor_1 ==> resistor_1 _, _, name = key.partition('_') @@ -777,9 +779,9 @@ class CapaModule(CapaFields, XModule): name = name[:-2] if is_list_key else name if is_list_key: - val = get.getlist(key) + val = data.getlist(key) else: - val = get[key] + val = data[key] # If the name already exists, then we don't want # to override it. Raise an error instead @@ -801,7 +803,7 @@ class CapaModule(CapaFields, XModule): 'max_value': score['total'], }) - def check_problem(self, get): + def check_problem(self, data): """ Checks whether answers to a problem are correct @@ -813,8 +815,9 @@ class CapaModule(CapaFields, XModule): event_info['state'] = self.lcp.get_state() event_info['problem_id'] = self.location.url() - answers = self.make_dict_of_responses(get) + answers = self.make_dict_of_responses(data) event_info['answers'] = convert_files_to_filenames(answers) + # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' @@ -972,7 +975,7 @@ class CapaModule(CapaFields, XModule): return {'success': success} - def save_problem(self, get): + def save_problem(self, data): """ Save the passed in answers. Returns a dict { 'success' : bool, 'msg' : message } @@ -982,7 +985,7 @@ class CapaModule(CapaFields, XModule): event_info['state'] = self.lcp.get_state() event_info['problem_id'] = self.location.url() - answers = self.make_dict_of_responses(get) + answers = self.make_dict_of_responses(data) event_info['answers'] = answers # Too late. Cannot submit @@ -1011,7 +1014,7 @@ class CapaModule(CapaFields, XModule): return {'success': True, 'msg': msg} - def reset_problem(self, get): + def reset_problem(self, _data): """ Changes problem state to unfinished -- removes student answers, and causes problem to rerender itself. diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 68285cae0d..52d98f032e 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -204,9 +204,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): return_value = self.child_module.get_html() return return_value - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): self.save_instance_data() - return_value = self.child_module.handle_ajax(dispatch, get) + return_value = self.child_module.handle_ajax(dispatch, data) self.save_instance_data() return return_value @@ -266,4 +266,3 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor): non_editable_fields.extend([CombinedOpenEndedDescriptor.due, CombinedOpenEndedDescriptor.graceperiod, CombinedOpenEndedDescriptor.markdown, CombinedOpenEndedDescriptor.version]) return non_editable_fields - diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index 6dc86880ae..5bdc8e7797 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -135,7 +135,7 @@ class ConditionalModule(ConditionalFields, XModule): 'depends': ';'.join(self.required_html_ids) }) - def handle_ajax(self, dispatch, post): + def handle_ajax(self, _dispatch, _data): """This is called by courseware.moduleodule_render, to handle an AJAX call. """ diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 9fc438d4c0..538901890c 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -500,10 +500,10 @@ class CombinedOpenEndedV1Module(): pass return return_html - def get_rubric(self, get): + def get_rubric(self, _data): """ Gets the results of a given grader via ajax. - Input: AJAX get dictionary + Input: AJAX data dictionary Output: Dictionary to be rendered via ajax that contains the result html. """ all_responses = [] @@ -532,10 +532,10 @@ class CombinedOpenEndedV1Module(): html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} - def get_legend(self, get): + def get_legend(self, _data): """ Gets the results of a given grader via ajax. - Input: AJAX get dictionary + Input: AJAX data dictionary Output: Dictionary to be rendered via ajax that contains the result html. """ context = { @@ -544,10 +544,10 @@ class CombinedOpenEndedV1Module(): html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} - def get_results(self, get): + def get_results(self, _data): """ Gets the results of a given grader via ajax. - Input: AJAX get dictionary + Input: AJAX data dictionary Output: Dictionary to be rendered via ajax that contains the result html. """ self.update_task_states() @@ -588,19 +588,19 @@ class CombinedOpenEndedV1Module(): html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} - def get_status_ajax(self, get): + def get_status_ajax(self, _data): """ Gets the results of a given grader via ajax. - Input: AJAX get dictionary + Input: AJAX data dictionary Output: Dictionary to be rendered via ajax that contains the result html. """ html = self.get_status(True) return {'html': html, 'success': True} - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """ This is called by courseware.module_render, to handle an AJAX call. - "get" is request.POST. + "data" is request.POST. Returns a json dictionary: { 'progress_changed' : True/False, @@ -618,30 +618,30 @@ class CombinedOpenEndedV1Module(): } if dispatch not in handlers: - return_html = self.current_task.handle_ajax(dispatch, get, self.system) + return_html = self.current_task.handle_ajax(dispatch, data, self.system) return self.update_task_states_ajax(return_html) - d = handlers[dispatch](get) + d = handlers[dispatch](data) return json.dumps(d, cls=ComplexEncoder) - def next_problem(self, get): + def next_problem(self, _data): """ Called via ajax to advance to the next problem. - Input: AJAX get request. + Input: AJAX data request. Output: Dictionary to be rendered """ self.update_task_states() return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.ready_to_reset} - def reset(self, get): + def reset(self, data): """ If resetting is allowed, reset the state of the combined open ended module. - Input: AJAX get dictionary + Input: AJAX data dictionary Output: AJAX dictionary to tbe rendered """ if self.state != self.DONE: if not self.ready_to_reset: - return self.out_of_sync_error(get) + return self.out_of_sync_error(data) if self.student_attempts > self.attempts: return { @@ -789,13 +789,13 @@ class CombinedOpenEndedV1Module(): return progress_object - def out_of_sync_error(self, get, msg=''): + def out_of_sync_error(self, data, msg=''): """ return dict out-of-sync error message, and also log. """ #This is a dev_facing_error - log.warning("Combined module state out sync. state: %r, get: %r. %s", - self.state, get, msg) + log.warning("Combined module state out sync. state: %r, data: %r. %s", + self.state, data, msg) #This is a student_facing_error return {'success': False, 'error': 'The problem state got out-of-sync. Please try reloading the page.'} diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 2ac55a8318..0f0851fbf7 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -122,17 +122,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild): self.payload = {'grader_payload': updated_grader_payload} - def skip_post_assessment(self, get, system): + def skip_post_assessment(self, _data, system): """ Ajax function that allows one to skip the post assessment phase - @param get: AJAX dictionary + @param data: AJAX dictionary @param system: ModuleSystem @return: Success indicator """ self.child_state = self.DONE return {'success': True} - def message_post(self, get, system): + def message_post(self, data, system): """ Handles a student message post (a reaction to the grade they received from an open ended grader type) Returns a boolean success/fail and an error message @@ -141,7 +141,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): event_info = dict() event_info['problem_id'] = self.location_string event_info['student_id'] = system.anonymous_student_id - event_info['survey_responses'] = get + event_info['survey_responses'] = data survey_responses = event_info['survey_responses'] for tag in ['feedback', 'submission_id', 'grader_id', 'score']: @@ -587,10 +587,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild): html = system.render_template('{0}/open_ended_evaluation.html'.format(self.TEMPLATE_DIR), context) return html - def handle_ajax(self, dispatch, get, system): + def handle_ajax(self, dispatch, data, system): ''' This is called by courseware.module_render, to handle an AJAX call. - "get" is request.POST. + "data" is request.POST. Returns a json dictionary: { 'progress_changed' : True/False, @@ -612,7 +612,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) before = self.get_progress() - d = handlers[dispatch](get, system) + d = handlers[dispatch](data, system) after = self.get_progress() d.update({ 'progress_changed': after != before, @@ -620,20 +620,20 @@ class OpenEndedModule(openendedchild.OpenEndedChild): }) return json.dumps(d, cls=ComplexEncoder) - def check_for_score(self, get, system): + def check_for_score(self, _data, system): """ Checks to see if a score has been received yet. - @param get: AJAX get dictionary + @param data: AJAX dictionary @param system: Modulesystem (needed to align with other ajax functions) @return: Returns the current state """ state = self.child_state return {'state': state} - def save_answer(self, get, system): + def save_answer(self, data, system): """ Saves a student answer - @param get: AJAX get dictionary + @param data: AJAX dictionary @param system: modulesystem @return: Success indicator """ @@ -644,17 +644,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return msg if self.child_state != self.INITIAL: - return self.out_of_sync_error(get) + return self.out_of_sync_error(data) # add new history element with answer and empty score and hint. - success, get = self.append_image_to_student_answer(get) + success, data = self.append_image_to_student_answer(data) error_message = "" if success: success, allowed_to_submit, error_message = self.check_if_student_can_submit() if allowed_to_submit: - get['student_answer'] = OpenEndedModule.sanitize_html(get['student_answer']) - self.new_history_entry(get['student_answer']) - self.send_to_grader(get['student_answer'], system) + data['student_answer'] = OpenEndedModule.sanitize_html(data['student_answer']) + self.new_history_entry(data['student_answer']) + self.send_to_grader(data['student_answer'], system) self.change_state(self.ASSESSING) else: # Error message already defined @@ -666,17 +666,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return { 'success': success, 'error': error_message, - 'student_response': get['student_answer'] + 'student_response': data['student_answer'] } - def update_score(self, get, system): + def update_score(self, data, system): """ Updates the current score via ajax. Called by xqueue. - Input: AJAX get dictionary, modulesystem + Input: AJAX data dictionary, modulesystem Output: None """ - queuekey = get['queuekey'] - score_msg = get['xqueue_body'] + queuekey = data['queuekey'] + score_msg = data['xqueue_body'] # TODO: Remove need for cmap self._update_score(score_msg, queuekey, system) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 4f524d2cd7..047ab0244c 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -272,13 +272,13 @@ class OpenEndedChild(object): return None return None - def out_of_sync_error(self, get, msg=''): + def out_of_sync_error(self, data, msg=''): """ return dict out-of-sync error message, and also log. """ # This is a dev_facing_error - log.warning("Open ended child state out sync. state: %r, get: %r. %s", - self.child_state, get, msg) + log.warning("Open ended child state out sync. state: %r, data: %r. %s", + self.child_state, data, msg) # This is a student_facing_error return {'success': False, 'error': 'The problem state got out-of-sync. Please try reloading the page.'} @@ -345,24 +345,24 @@ class OpenEndedChild(object): return success, image_ok, s3_public_url - def check_for_image_and_upload(self, get_data): + def check_for_image_and_upload(self, data): """ Checks to see if an image was passed back in the AJAX query. If so, it will upload it to S3 - @param get_data: AJAX get data - @return: Success, whether or not a file was in the get dictionary, + @param data: AJAX data + @return: Success, whether or not a file was in the data dictionary, and the html corresponding to the uploaded image """ has_file_to_upload = False uploaded_to_s3 = False image_tag = "" image_ok = False - if 'can_upload_files' in get_data: - if get_data['can_upload_files'] in ['true', '1']: + if 'can_upload_files' in data: + if data['can_upload_files'] in ['true', '1']: has_file_to_upload = True - file = get_data['student_file'][0] - uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file) + student_file = data['student_file'][0] + uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(student_file) if uploaded_to_s3: - image_tag = self.generate_image_tag_from_url(s3_public_url, file.name) + image_tag = self.generate_image_tag_from_url(s3_public_url, student_file.name) return has_file_to_upload, uploaded_to_s3, image_ok, image_tag @@ -371,27 +371,27 @@ class OpenEndedChild(object): Makes an image tag from a given URL @param s3_public_url: URL of the image @param image_name: Name of the image - @return: Boolean success, updated AJAX get data + @return: Boolean success, updated AJAX data """ image_template = """ {1} """.format(s3_public_url, image_name) return image_template - def append_image_to_student_answer(self, get_data): + def append_image_to_student_answer(self, data): """ Adds an image to a student answer after uploading it to S3 - @param get_data: AJAx get data - @return: Boolean success, updated AJAX get data + @param data: AJAx data + @return: Boolean success, updated AJAX data """ overall_success = False if not self.accept_file_upload: # If the question does not accept file uploads, do not do anything - return True, get_data + return True, data - has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(get_data) + has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(data) if uploaded_to_s3 and has_file_to_upload and image_ok: - get_data['student_answer'] += image_tag + data['student_answer'] += image_tag overall_success = True elif has_file_to_upload and not uploaded_to_s3 and image_ok: # In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely @@ -403,12 +403,12 @@ class OpenEndedChild(object): overall_success = True elif not has_file_to_upload: # If there is no file to upload, probably the student has embedded the link in the answer text - success, get_data['student_answer'] = self.check_for_url_in_text(get_data['student_answer']) + success, data['student_answer'] = self.check_for_url_in_text(data['student_answer']) overall_success = success # log.debug("Has file: {0} Uploaded: {1} Image Ok: {2}".format(has_file_to_upload, uploaded_to_s3, image_ok)) - return overall_success, get_data + return overall_success, data def check_for_url_in_text(self, string): """ diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index 7beca7a72f..a5498289e2 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -75,10 +75,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): html = system.render_template('{0}/self_assessment_prompt.html'.format(self.TEMPLATE_DIR), context) return html - def handle_ajax(self, dispatch, get, system): + def handle_ajax(self, dispatch, data, system): """ This is called by courseware.module_render, to handle an AJAX call. - "get" is request.POST. + "data" is request.POST. Returns a json dictionary: { 'progress_changed' : True/False, @@ -99,7 +99,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) before = self.get_progress() - d = handlers[dispatch](get, system) + d = handlers[dispatch](data, system) after = self.get_progress() d.update({ 'progress_changed': after != before, @@ -160,12 +160,12 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context) - def save_answer(self, get, system): + def save_answer(self, data, system): """ After the answer is submitted, show the rubric. Args: - get: the GET dictionary passed to the ajax request. Should contain + data: the request dictionary passed to the ajax request. Should contain a key 'student_answer' Returns: @@ -178,16 +178,16 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): return msg if self.child_state != self.INITIAL: - return self.out_of_sync_error(get) + return self.out_of_sync_error(data) error_message = "" # add new history element with answer and empty score and hint. - success, get = self.append_image_to_student_answer(get) + success, data = self.append_image_to_student_answer(data) if success: success, allowed_to_submit, error_message = self.check_if_student_can_submit() if allowed_to_submit: - get['student_answer'] = SelfAssessmentModule.sanitize_html(get['student_answer']) - self.new_history_entry(get['student_answer']) + data['student_answer'] = SelfAssessmentModule.sanitize_html(data['student_answer']) + self.new_history_entry(data['student_answer']) self.change_state(self.ASSESSING) else: # Error message already defined @@ -200,10 +200,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'success': success, 'rubric_html': self.get_rubric_html(system), 'error': error_message, - 'student_response': get['student_answer'], + 'student_response': data['student_answer'], } - def save_assessment(self, get, system): + def save_assessment(self, data, _system): """ Save the assessment. If the student said they're right, don't ask for a hint, and go straight to the done state. Otherwise, do ask for a hint. @@ -219,11 +219,11 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): """ if self.child_state != self.ASSESSING: - return self.out_of_sync_error(get) + return self.out_of_sync_error(data) try: - score = int(get['assessment']) - score_list = get.getlist('score_list[]') + score = int(data['assessment']) + score_list = data.getlist('score_list[]') for i in xrange(0, len(score_list)): score_list[i] = int(score_list[i]) except ValueError: @@ -244,7 +244,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): d['state'] = self.child_state return d - def save_hint(self, get, system): + def save_hint(self, data, _system): ''' Not used currently, as hints have been removed from the system. Save the hint. @@ -258,9 +258,9 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): if self.child_state != self.POST_ASSESSMENT: # Note: because we only ask for hints on wrong answers, may not have # the same number of hints and answers. - return self.out_of_sync_error(get) + return self.out_of_sync_error(data) - self.record_latest_post_assessment(get['hint']) + self.record_latest_post_assessment(data['hint']) self.change_state(self.DONE) return {'success': True, diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index a13fef8e40..7df444a892 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -133,8 +133,8 @@ class PeerGradingModule(PeerGradingFields, XModule): """ return {'success': False, 'error': msg} - def _check_required(self, get, required): - actual = set(get.keys()) + def _check_required(self, data, required): + actual = set(data.keys()) missing = required - actual if len(missing) > 0: return False, "Missing required keys: {0}".format(', '.join(missing)) @@ -153,7 +153,7 @@ class PeerGradingModule(PeerGradingFields, XModule): else: return self.peer_grading_problem({'location': self.link_to_location})['html'] - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """ Needs to be implemented by child modules. Handles AJAX events. @return: @@ -173,7 +173,7 @@ class PeerGradingModule(PeerGradingFields, XModule): # This is a dev_facing_error return json.dumps({'error': 'Error handling action. Please try again.', 'success': False}) - d = handlers[dispatch](get) + d = handlers[dispatch](data) return json.dumps(d, cls=ComplexEncoder) @@ -244,7 +244,7 @@ class PeerGradingModule(PeerGradingFields, XModule): max_grade = self.max_grade return max_grade - def get_next_submission(self, get): + def get_next_submission(self, data): """ Makes a call to the grading controller for the next essay that should be graded Returns a json dict with the following keys: @@ -263,11 +263,11 @@ class PeerGradingModule(PeerGradingFields, XModule): 'error': if success is False, will have an error message with more info. """ required = set(['location']) - success, message = self._check_required(get, required) + success, message = self._check_required(data, required) if not success: return self._err_response(message) grader_id = self.system.anonymous_student_id - location = get['location'] + location = data['location'] try: response = self.peer_gs.get_next_submission(location, grader_id) @@ -280,7 +280,7 @@ class PeerGradingModule(PeerGradingFields, XModule): return {'success': False, 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR} - def save_grade(self, get): + def save_grade(self, data): """ Saves the grade of a given submission. Input: @@ -298,18 +298,18 @@ class PeerGradingModule(PeerGradingFields, XModule): required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', 'submission_flagged']) - success, message = self._check_required(get, required) + success, message = self._check_required(data, required) if not success: return self._err_response(message) grader_id = self.system.anonymous_student_id - location = get.get('location') - submission_id = get.get('submission_id') - score = get.get('score') - feedback = get.get('feedback') - submission_key = get.get('submission_key') - rubric_scores = get.getlist('rubric_scores[]') - submission_flagged = get.get('submission_flagged') + location = data.get('location') + submission_id = data.get('submission_id') + score = data.get('score') + feedback = data.get('feedback') + submission_key = data.get('submission_key') + rubric_scores = data.getlist('rubric_scores[]') + submission_flagged = data.get('submission_flagged') try: response = self.peer_gs.save_grade(location, grader_id, submission_id, @@ -328,7 +328,7 @@ class PeerGradingModule(PeerGradingFields, XModule): 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR } - def is_student_calibrated(self, get): + def is_student_calibrated(self, data): """ Calls the grading controller to see if the given student is calibrated on the given problem @@ -347,12 +347,12 @@ class PeerGradingModule(PeerGradingFields, XModule): """ required = set(['location']) - success, message = self._check_required(get, required) + success, message = self._check_required(data, required) if not success: return self._err_response(message) grader_id = self.system.anonymous_student_id - location = get['location'] + location = data['location'] try: response = self.peer_gs.is_student_calibrated(location, grader_id) @@ -367,7 +367,7 @@ class PeerGradingModule(PeerGradingFields, XModule): 'error': EXTERNAL_GRADER_NO_CONTACT_ERROR } - def show_calibration_essay(self, get): + def show_calibration_essay(self, data): """ Fetch the next calibration essay from the grading controller and return it Inputs: @@ -392,13 +392,13 @@ class PeerGradingModule(PeerGradingFields, XModule): """ required = set(['location']) - success, message = self._check_required(get, required) + success, message = self._check_required(data, required) if not success: return self._err_response(message) grader_id = self.system.anonymous_student_id - location = get['location'] + location = data['location'] try: response = self.peer_gs.show_calibration_essay(location, grader_id) return response @@ -417,8 +417,7 @@ class PeerGradingModule(PeerGradingFields, XModule): return {'success': False, 'error': 'Error displaying submission. Please notify course staff.'} - - def save_calibration_essay(self, get): + def save_calibration_essay(self, data): """ Saves the grader's grade of a given calibration. Input: @@ -437,17 +436,17 @@ class PeerGradingModule(PeerGradingFields, XModule): """ required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]']) - success, message = self._check_required(get, required) + success, message = self._check_required(data, required) if not success: return self._err_response(message) grader_id = self.system.anonymous_student_id - location = get.get('location') - calibration_essay_id = get.get('submission_id') - submission_key = get.get('submission_key') - score = get.get('score') - feedback = get.get('feedback') - rubric_scores = get.getlist('rubric_scores[]') + location = data.get('location') + calibration_essay_id = data.get('submission_id') + submission_key = data.get('submission_key') + score = data.get('score') + feedback = data.get('feedback') + rubric_scores = data.getlist('rubric_scores[]') try: response = self.peer_gs.save_calibration_essay(location, grader_id, calibration_essay_id, @@ -473,8 +472,7 @@ class PeerGradingModule(PeerGradingFields, XModule): }) return html - - def peer_grading(self, get=None): + def peer_grading(self, _data=None): ''' Show a peer grading interface ''' @@ -553,11 +551,11 @@ class PeerGradingModule(PeerGradingFields, XModule): return html - def peer_grading_problem(self, get=None): + def peer_grading_problem(self, data=None): ''' Show individual problem interface ''' - if get is None or get.get('location') is None: + if data is None or data.get('location') is None: if not self.use_for_single_location: # This is an error case, because it must be set to use a single location to be called without get parameters # This is a dev_facing_error @@ -566,8 +564,8 @@ class PeerGradingModule(PeerGradingFields, XModule): return {'html': "", 'success': False} problem_location = self.link_to_location - elif get.get('location') is not None: - problem_location = get.get('location') + elif data.get('location') is not None: + problem_location = data.get('location') ajax_url = self.ajax_url html = self.system.render_template('peer_grading/peer_grading_problem.html', { @@ -617,4 +615,3 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor): non_editable_fields.extend([PeerGradingFields.due_date, PeerGradingFields.grace_period_string, PeerGradingFields.max_grade]) return non_editable_fields - diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py index 9f2359865a..ca12f239ab 100644 --- a/common/lib/xmodule/xmodule/poll_module.py +++ b/common/lib/xmodule/xmodule/poll_module.py @@ -47,12 +47,12 @@ class PollModule(PollFields, XModule): css = {'scss': [resource_string(__name__, 'css/poll/display.scss')]} js_module_name = "Poll" - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """Ajax handler. Args: dispatch: string request slug - get: dict request get parameters + data: dict request data parameters Returns: json string diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 580f51f6dd..088967ebc0 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -62,10 +62,10 @@ class SequenceModule(SequenceFields, XModule): progress = reduce(Progress.add_counts, progresses) return progress - def handle_ajax(self, dispatch, get): # TODO: bounds checking + def handle_ajax(self, dispatch, data): # TODO: bounds checking ''' get = request.POST instance ''' if dispatch == 'goto_position': - self.position = int(get['position']) + self.position = int(data['position']) return json.dumps({'success': True}) raise NotFoundError('Unexpected dispatch type') diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py index e62b9a1cee..9be533885c 100644 --- a/common/lib/xmodule/xmodule/tests/test_logic.py +++ b/common/lib/xmodule/xmodule/tests/test_logic.py @@ -40,9 +40,9 @@ class LogicTest(unittest.TestCase): self.raw_model_data ) - def ajax_request(self, dispatch, get): + def ajax_request(self, dispatch, data): """Call Xmodule.handle_ajax.""" - return json.loads(self.xmodule.handle_ajax(dispatch, get)) + return json.loads(self.xmodule.handle_ajax(dispatch, data)) class PollModuleTest(LogicTest): diff --git a/common/lib/xmodule/xmodule/timelimit_module.py b/common/lib/xmodule/xmodule/timelimit_module.py index 9446176f01..3f52ae0baa 100644 --- a/common/lib/xmodule/xmodule/timelimit_module.py +++ b/common/lib/xmodule/xmodule/timelimit_module.py @@ -98,7 +98,7 @@ class TimeLimitModule(TimeLimitFields, XModule): progress = reduce(Progress.add_counts, progresses) return progress - def handle_ajax(self, dispatch, get): + def handle_ajax(self, _dispatch, _data): raise NotFoundError('Unexpected dispatch type') def render(self): @@ -141,4 +141,3 @@ class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor): xml_object.append( etree.fromstring(child.export_to_xml(resource_fs))) return xml_object - diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 6344da7994..04daaea3f2 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -54,9 +54,9 @@ class VideoModule(VideoFields, XModule): def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """This is not being called right now and we raise 404 error.""" - log.debug(u"GET {0}".format(get)) + log.debug(u"GET {0}".format(data)) log.debug(u"DISPATCH {0}".format(dispatch)) raise Http404() diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index a64e094a58..6b27bcda2b 100644 --- a/common/lib/xmodule/xmodule/videoalpha_module.py +++ b/common/lib/xmodule/xmodule/videoalpha_module.py @@ -125,9 +125,9 @@ class VideoAlphaModule(VideoAlphaFields, XModule): return parse_time(xmltree.get('start_time')), parse_time(xmltree.get('end_time')) - def handle_ajax(self, dispatch, get): + def handle_ajax(self, dispatch, data): """This is not being called right now and we raise 404 error.""" - log.debug(u"GET {0}".format(get)) + log.debug(u"GET {0}".format(data)) log.debug(u"DISPATCH {0}".format(dispatch)) raise Http404() diff --git a/common/lib/xmodule/xmodule/word_cloud_module.py b/common/lib/xmodule/xmodule/word_cloud_module.py index ac5b3051de..a7f3f92795 100644 --- a/common/lib/xmodule/xmodule/word_cloud_module.py +++ b/common/lib/xmodule/xmodule/word_cloud_module.py @@ -168,12 +168,12 @@ class WordCloudModule(WordCloudFields, XModule): )[:amount] ) - def handle_ajax(self, dispatch, post): + def handle_ajax(self, dispatch, data): """Ajax handler. Args: dispatch: string request slug - post: dict request get parameters + data: dict request get parameters Returns: json string @@ -187,7 +187,7 @@ class WordCloudModule(WordCloudFields, XModule): # Student words from client. # FIXME: we must use raw JSON, not a post data (multipart/form-data) - raw_student_words = post.getlist('student_words[]') + raw_student_words = data.getlist('student_words[]') student_words = filter(None, map(self.good_word, raw_student_words)) self.student_words = student_words diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index f5705bf662..0f5bbf4f2e 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -272,9 +272,9 @@ class XModule(XModuleFields, HTMLSnippet, XBlock): ''' return None - def handle_ajax(self, _dispatch, _get): + def handle_ajax(self, _dispatch, _data): ''' dispatch is last part of the URL. - get is a dictionary-like object ''' + data is a dictionary-like object with the content of the request''' return "" diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 15a6ad2dab..d17efa3697 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -223,7 +223,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours relative_xqueue_callback_url = reverse('xqueue_callback', kwargs=dict(course_id=course_id, userid=str(user.id), - id=descriptor.location.url(), + mod_id=descriptor.location.url(), dispatch=dispatch), ) return xqueue_callback_url_prefix + relative_xqueue_callback_url @@ -399,40 +399,47 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours @csrf_exempt -def xqueue_callback(request, course_id, userid, id, dispatch): +def xqueue_callback(request, course_id, userid, mod_id, dispatch): ''' Entry point for graded results from the queueing system. ''' + data = request.POST.copy() + # Test xqueue package, which we expect to be: # xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}), # 'xqueue_body' : 'Message from grader'} - get = request.POST.copy() for key in ['xqueue_header', 'xqueue_body']: - if not get.has_key(key): + if key not in data: raise Http404 - header = json.loads(get['xqueue_header']) - if not isinstance(header, dict) or not header.has_key('lms_key'): + + header = json.loads(data['xqueue_header']) + if not isinstance(header, dict) or 'lms_key' not in header: raise Http404 # Retrieve target StudentModule user = User.objects.get(id=userid) - - model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, - user, modulestore().get_instance(course_id, id), depth=0, select_for_update=True) - instance = get_module(user, request, id, model_data_cache, course_id, grade_bucket_type='xqueue') + model_data_cache = ModelDataCache.cache_for_descriptor_descendents( + course_id, + user, + modulestore().get_instance(course_id, mod_id), + depth=0, + select_for_update=True + ) + instance = get_module(user, request, mod_id, model_data_cache, course_id, grade_bucket_type='xqueue') if instance is None: - log.debug("No module {0} for user {1}--access denied?".format(id, user)) + msg = "No module {0} for user {1}--access denied?".format(mod_id, user) + log.debug(msg) raise Http404 - # Transfer 'queuekey' from xqueue response header to 'get'. This is required to - # use the interface defined by 'handle_ajax' - get.update({'queuekey': header['lms_key']}) + # Transfer 'queuekey' from xqueue response header to the data. + # This is required to use the interface defined by 'handle_ajax' + data.update({'queuekey': header['lms_key']}) # We go through the "AJAX" path - # So far, the only dispatch from xqueue will be 'score_update' + # So far, the only dispatch from xqueue will be 'score_update' try: # Can ignore the return value--not used for xqueue_callback - instance.handle_ajax(dispatch, get) + instance.handle_ajax(dispatch, data) except: log.exception("error processing ajax call") raise @@ -466,23 +473,15 @@ def modx_dispatch(request, dispatch, location, course_id): if not request.user.is_authenticated(): raise PermissionDenied - # Check for submitted files and basic file size checks - p = request.POST.copy() - if request.FILES: - for fileinput_id in request.FILES.keys(): - inputfiles = request.FILES.getlist(fileinput_id) + # Get the submitted data + data = request.POST.copy() - if len(inputfiles) > settings.MAX_FILEUPLOADS_PER_INPUT: - too_many_files_msg = 'Submission aborted! Maximum %d files may be submitted at once' % \ - settings.MAX_FILEUPLOADS_PER_INPUT - return HttpResponse(json.dumps({'success': too_many_files_msg})) - - for inputfile in inputfiles: - if inputfile.size > settings.STUDENT_FILEUPLOAD_MAX_SIZE: # Bytes - file_too_big_msg = 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % \ - (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2)) - return HttpResponse(json.dumps({'success': file_too_big_msg})) - p[fileinput_id] = inputfiles + # Get and check submitted files + files = request.FILES or {} + error_msg = _check_files_limits(files) + if error_msg: + return HttpResponse(json.dumps({'success': error_msg})) + data.update(files) # Merge files into data dictionary try: descriptor = modulestore().get_instance(course_id, location) @@ -495,8 +494,11 @@ def modx_dispatch(request, dispatch, location, course_id): ) raise Http404 - model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, - request.user, descriptor) + model_data_cache = ModelDataCache.cache_for_descriptor_descendents( + course_id, + request.user, + descriptor + ) instance = get_module(request.user, request, location, model_data_cache, course_id, grade_bucket_type='ajax') if instance is None: @@ -507,7 +509,7 @@ def modx_dispatch(request, dispatch, location, course_id): # Let the module handle the AJAX try: - ajax_return = instance.handle_ajax(dispatch, p) + ajax_return = instance.handle_ajax(dispatch, data) # If we can't find the module, respond with a 404 except NotFoundError: @@ -529,7 +531,6 @@ def modx_dispatch(request, dispatch, location, course_id): return HttpResponse(ajax_return) - def get_score_bucket(grade, max_grade): """ Function to split arbitrary score ranges into 3 buckets. @@ -542,3 +543,30 @@ def get_score_bucket(grade, max_grade): score_bucket = "correct" return score_bucket + + +def _check_files_limits(files): + """ + Check if the files in a request are under the limits defined by + `settings.MAX_FILEUPLOADS_PER_INPUT` and + `settings.STUDENT_FILEUPLOAD_MAX_SIZE`. + + Returns None if files are correct or an error messages otherwise. + """ + for fileinput_id in files.keys(): + inputfiles = files.getlist(fileinput_id) + + # Check number of files submitted + if len(inputfiles) > settings.MAX_FILEUPLOADS_PER_INPUT: + msg = 'Submission aborted! Maximum %d files may be submitted at once' %\ + settings.MAX_FILEUPLOADS_PER_INPUT + return msg + + # Check file sizes + for inputfile in inputfiles: + if inputfile.size > settings.STUDENT_FILEUPLOAD_MAX_SIZE: # Bytes + msg = 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %\ + (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2)) + return msg + + return None diff --git a/lms/urls.py b/lms/urls.py index 52ce539f73..88916bd334 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -188,7 +188,7 @@ if settings.COURSEWARE_ENABLED: # into the database. url(r'^software-licenses$', 'licenses.views.user_software_license', name="user_software_license"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$', 'courseware.module_render.xqueue_callback', name='xqueue_callback'), url(r'^change_setting$', 'student.views.change_setting', @@ -438,5 +438,3 @@ if settings.DEBUG: #Custom error pages handler404 = 'static_template_view.views.render_404' handler500 = 'static_template_view.views.render_500' - - From ed62c5a6f944f7d917fba7819f243da0c9fac23f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 25 Jun 2013 14:23:16 -0400 Subject: [PATCH 052/161] Fix LMS-500: Random class in random module was None Deleting the module object isn't needed to replace it, and deleting a module object causes all of its attributes to be set to None. --- common/lib/capa/capa/safe_exec/safe_exec.py | 1 - .../lib/capa/capa/tests/test_responsetypes.py | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/common/lib/capa/capa/safe_exec/safe_exec.py b/common/lib/capa/capa/safe_exec/safe_exec.py index 3ab8f0bf9e..be33bcaa5b 100644 --- a/common/lib/capa/capa/safe_exec/safe_exec.py +++ b/common/lib/capa/capa/safe_exec/safe_exec.py @@ -18,7 +18,6 @@ import random as random_module import sys random = random_module.Random(%r) random.Random = random_module.Random -del random_module sys.modules['random'] = random """ diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 68be54b6af..594e2ca629 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -1266,6 +1266,24 @@ class CustomResponseTest(ResponseTest): msg = correct_map.get_msg('1_2_1') self.assertEqual(msg, self._get_random_number_result(problem.seed)) + def test_random_isnt_none(self): + # Bug LMS-500 says random.seed(10) fails with: + # File "", line 61, in + # File "/usr/lib/python2.7/random.py", line 116, in seed + # super(Random, self).seed(a) + # TypeError: must be type, not None + + r = random.Random() + r.seed(10) + num = r.randint(0, 1e9) + + script = textwrap.dedent(""" + random.seed(10) + num = random.randint(0, 1e9) + """) + problem = self.build_problem(script=script) + self.assertEqual(problem.context['num'], num) + def test_module_imports_inline(self): ''' Check that the correct modules are available to custom From bcbce3eff0bec489c718ea8cf414f3c38f54527a Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Mon, 24 Jun 2013 16:53:01 -0400 Subject: [PATCH 053/161] Add handful of events to the Segment.io whitelist --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 8 ++++---- common/static/coffee/src/logger.coffee | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index f773fc81c4..6b355459e9 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -138,7 +138,7 @@ class @Problem # maybe preferable to consolidate all dispatches to use FormData ### check_fd: => - Logger.log 'problem_check', @answers + Logger.log 'problem_check', answers: @answers # If there are no file inputs in the problem, we can fall back on @check if $('input:file').length == 0 @@ -212,7 +212,7 @@ class @Problem $.ajaxWithPrefix("#{@url}/problem_check", settings) check: => - Logger.log 'problem_check', @answers + Logger.log 'problem_check', answers: @answers $.postWithPrefix "#{@url}/problem_check", @answers, (response) => switch response.success when 'incorrect', 'correct' @@ -224,7 +224,7 @@ class @Problem @gentle_alert response.success reset: => - Logger.log 'problem_reset', @answers + Logger.log 'problem_reset', answers: @answers $.postWithPrefix "#{@url}/problem_reset", id: @id, (response) => @render(response.html) @updateProgress response @@ -284,7 +284,7 @@ class @Problem @el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700) save: => - Logger.log 'problem_save', @answers + Logger.log 'problem_save', answers: @answers $.postWithPrefix "#{@url}/problem_save", @answers, (response) => saveMessage = response.msg @gentle_alert saveMessage diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index 6da4929fb0..dbc2b8e004 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -1,6 +1,6 @@ class @Logger # events we want sent to Segment.io for tracking - SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev"] + SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"] @log: (event_type, data) -> if event_type in SEGMENT_IO_WHITELIST From 84f4361d522c9898b69fa3e16c0540126673b5cc Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Tue, 25 Jun 2013 15:15:28 -0400 Subject: [PATCH 054/161] Avoid changing format of data sent to our logs, and prevent problem_check event from firing twice --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 9 +++++---- common/static/coffee/src/logger.coffee | 8 ++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 6b355459e9..bf6aba0a21 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -138,7 +138,7 @@ class @Problem # maybe preferable to consolidate all dispatches to use FormData ### check_fd: => - Logger.log 'problem_check', answers: @answers + Logger.log 'problem_check', @answers # If there are no file inputs in the problem, we can fall back on @check if $('input:file').length == 0 @@ -212,7 +212,8 @@ class @Problem $.ajaxWithPrefix("#{@url}/problem_check", settings) check: => - Logger.log 'problem_check', answers: @answers + # Calling check from within check_fd will result in firing the 'problem_check' event twice + # Logger.log 'problem_check', @answers $.postWithPrefix "#{@url}/problem_check", @answers, (response) => switch response.success when 'incorrect', 'correct' @@ -224,7 +225,7 @@ class @Problem @gentle_alert response.success reset: => - Logger.log 'problem_reset', answers: @answers + Logger.log 'problem_reset', @answers $.postWithPrefix "#{@url}/problem_reset", id: @id, (response) => @render(response.html) @updateProgress response @@ -284,7 +285,7 @@ class @Problem @el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700) save: => - Logger.log 'problem_save', answers: @answers + Logger.log 'problem_save', @answers $.postWithPrefix "#{@url}/problem_save", @answers, (response) => saveMessage = response.msg @gentle_alert saveMessage diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index dbc2b8e004..f2dfef5132 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -3,9 +3,13 @@ class @Logger SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"] @log: (event_type, data) -> + # Segment.io event tracking if event_type in SEGMENT_IO_WHITELIST - # Segment.io event tracking - analytics.track event_type, data + # to avoid changing the format of data sent to our servers, we only massage it here + if typeof data isnt 'object' or data is null + analytics.track event_type, value: data + else + analytics.track event_type, data $.getWithPrefix '/event', event_type: event_type From 401dc82c46085c6663c67e60d6da32291afb8028 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 25 Jun 2013 15:35:47 -0400 Subject: [PATCH 055/161] Don't mention edge in the subject line; use same message for edx and edge. --- cms/templates/emails/activation_email_subject.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/emails/activation_email_subject.txt b/cms/templates/emails/activation_email_subject.txt index 0b0fb2ffe9..f4ffdccb14 100644 --- a/cms/templates/emails/activation_email_subject.txt +++ b/cms/templates/emails/activation_email_subject.txt @@ -1 +1 @@ -Your account for edX edge +Your account for edX Studio From 881d63dae7177adc4ff9e0cad595eac37d18a604 Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Tue, 25 Jun 2013 16:04:00 -0400 Subject: [PATCH 056/161] Fixed Jasmine tests in light of Logger changes, and wrote test to cover case where data passed is not a dictionary --- common/static/coffee/spec/logger_spec.coffee | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/common/static/coffee/spec/logger_spec.coffee b/common/static/coffee/spec/logger_spec.coffee index 8866daa570..7fe734d8b5 100644 --- a/common/static/coffee/spec/logger_spec.coffee +++ b/common/static/coffee/spec/logger_spec.coffee @@ -3,10 +3,15 @@ describe 'Logger', -> expect(window.log_event).toBe Logger.log describe 'log', -> - it 'sends an event to Segment.io, if the event is whitelisted', -> + it 'sends an event to Segment.io, if the event is whitelisted and the data is not a dictionary', -> spyOn(analytics, 'track') Logger.log 'seq_goto', 'data' - expect(analytics.track).toHaveBeenCalledWith 'seq_goto', 'data' + expect(analytics.track).toHaveBeenCalledWith 'seq_goto', value: 'data' + + it 'sends an event to Segment.io, if the event is whitelisted and the data is a dictionary', -> + spyOn(analytics, 'track') + Logger.log 'seq_goto', value: 'data' + expect(analytics.track).toHaveBeenCalledWith 'seq_goto', value: 'data' it 'send a request to log event', -> spyOn $, 'getWithPrefix' From 6c66736e3c51a3340e2322957e5885207bcf48dc Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 25 Jun 2013 16:56:36 -0400 Subject: [PATCH 057/161] Specify a different xcontent mongo db for the acceptance tests --- cms/envs/acceptance.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 6293219f43..871b744282 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -40,6 +40,21 @@ MODULESTORE = { 'OPTIONS': MODULESTORE_OPTIONS } } + +CONTENTSTORE = { + 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', + 'OPTIONS': { + 'host': 'localhost', + 'db': 'acceptance_xcontent', + }, + # allow for additional options that can be keyed on a name, e.g. 'trashcan' + 'ADDITIONAL_OPTIONS': { + 'trashcan': { + 'bucket': 'trash_fs' + } + } +} + # Set this up so that rake lms[acceptance] and running the # harvest command both use the same (test) database # which they can flush without messing up your dev db From 3f49da385f1275f5cf24959008103e89a29e050d Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Tue, 25 Jun 2013 17:22:05 -0400 Subject: [PATCH 058/161] Swap Logger call from check_fd to check --- common/lib/xmodule/xmodule/js/src/capa/display.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index bf6aba0a21..1f3be9e5e9 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -138,7 +138,8 @@ class @Problem # maybe preferable to consolidate all dispatches to use FormData ### check_fd: => - Logger.log 'problem_check', @answers + # Calling check from check_fd will result in firing the 'problem_check' event twice, since it is also called in the check function. + #Logger.log 'problem_check', @answers # If there are no file inputs in the problem, we can fall back on @check if $('input:file').length == 0 @@ -212,8 +213,7 @@ class @Problem $.ajaxWithPrefix("#{@url}/problem_check", settings) check: => - # Calling check from within check_fd will result in firing the 'problem_check' event twice - # Logger.log 'problem_check', @answers + Logger.log 'problem_check', @answers $.postWithPrefix "#{@url}/problem_check", @answers, (response) => switch response.success when 'incorrect', 'correct' From 8b23eeca7e8992ac56db892d19cf909c10f9b717 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 15:53:24 -0400 Subject: [PATCH 059/161] Minor pylint/whitespace changes --- cms/djangoapps/contentstore/features/video-editor.py | 2 +- cms/djangoapps/contentstore/utils.py | 2 +- cms/templates/overview.html | 3 ++- common/lib/xmodule/xmodule/modulestore/draft.py | 4 ++-- common/lib/xmodule/xmodule/modulestore/mongo.py | 2 +- .../combined_open_ended_modulev1.py | 2 +- common/lib/xmodule/xmodule/tests/test_import.py | 10 ++++++---- common/lib/xmodule/xmodule/tests/test_xml_module.py | 4 ++-- common/lib/xmodule/xmodule/xml_module.py | 9 ++++----- lms/djangoapps/courseware/access.py | 4 +--- lms/djangoapps/courseware/features/problems.py | 6 +++--- lms/djangoapps/courseware/module_render.py | 2 +- 12 files changed, 25 insertions(+), 25 deletions(-) diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 987b4959b8..a6865fdd6d 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -1,5 +1,5 @@ # disable missing docstring -#pylint: disable=C0111 +# pylint: disable=C0111 from lettuce import world, step diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 0bfa70e4f5..c9c40ab95d 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -10,7 +10,7 @@ from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES log = logging.getLogger(__name__) -#In order to instantiate an open ended tab automatically, need to have this data +# In order to instantiate an open ended tab automatically, need to have this data OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"} NOTES_PANEL = {"name": "My Notes", "type": "notes"} EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]]) diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 43d0afc263..a504d50019 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -167,7 +167,8 @@ %else: Will Release: ${date_utils.get_default_time_display(section.lms.start)} - Edit + Edit %endif diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 94823b0be4..41c8e2ec1e 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -101,12 +101,12 @@ class DraftModuleStore(ModuleStoreBase): draft_items = super(DraftModuleStore, self).get_items(draft_loc, course_id=course_id, depth=depth) items = super(DraftModuleStore, self).get_items(location, course_id=course_id, depth=depth) - draft_locs_found = set(item.location._replace(revision=None) for item in draft_items) + draft_locs_found = set(item.location.replace(revision=None) for item in draft_items) non_draft_items = [ item for item in items if (item.location.revision != DRAFT - and item.location._replace(revision=None) not in draft_locs_found) + and item.location.replace(revision=None) not in draft_locs_found) ] return [wrap_draft(item) for item in draft_items + non_draft_items] diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 40288a933b..32323d5892 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -195,7 +195,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): if self.cached_metadata is not None: # parent container pointers don't differentiate between draft and non-draft # so when we do the lookup, we should do so with a non-draft location - non_draft_loc = location._replace(revision=None) + non_draft_loc = location.replace(revision=None) metadata_to_inherit = self.cached_metadata.get(non_draft_loc.url(), {}) inherit_metadata(module, metadata_to_inherit) return module diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 538901890c..1fe62035e6 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -646,7 +646,7 @@ class CombinedOpenEndedV1Module(): if self.student_attempts > self.attempts: return { 'success': False, - #This is a student_facing_error + # This is a student_facing_error 'error': ( 'You have attempted this question {0} times. ' 'You are only allowed to attempt it {1} times.' diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 4e9a9f9600..79b49c65ae 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -157,9 +157,10 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(child.lms.due, ImportTestCase.date.from_json(v)) self.assertEqual(child._inheritable_metadata, child._inherited_metadata) self.assertEqual(2, len(child._inherited_metadata)) - self.assertLessEqual(ImportTestCase.date.from_json( - child._inherited_metadata['start']), - datetime.datetime.now(UTC())) + self.assertLessEqual( + ImportTestCase.date.from_json(child._inherited_metadata['start']), + datetime.datetime.now(UTC()) + ) self.assertEqual(v, child._inherited_metadata['due']) # Now export and check things @@ -221,7 +222,8 @@ class ImportTestCase(BaseCourseTestCase): # why do these tests look in the internal structure v just calling child.start? self.assertLessEqual( ImportTestCase.date.from_json(child._inherited_metadata['start']), - datetime.datetime.now(UTC())) + datetime.datetime.now(UTC()) + ) def test_metadata_override_default(self): """ diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py index 6ec82275af..6581ce58f6 100644 --- a/common/lib/xmodule/xmodule/tests/test_xml_module.py +++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py @@ -248,7 +248,7 @@ class TestDeserializeFloat(TestDeserialize): test_field = Float def test_deserialize(self): - self.assertDeserializeEqual( -2, '-2') + self.assertDeserializeEqual(-2, '-2') self.assertDeserializeEqual("450", '"450"') self.assertDeserializeEqual(-2.78, '-2.78') self.assertDeserializeEqual("0.45", '"0.45"') @@ -256,7 +256,7 @@ class TestDeserializeFloat(TestDeserialize): # False can be parsed as a float (converts to 0) self.assertDeserializeEqual(False, 'false') # True can be parsed as a float (converts to 1) - self.assertDeserializeEqual( True, 'true') + self.assertDeserializeEqual(True, 'true') def test_deserialize_unsupported_types(self): self.assertDeserializeEqual('[3]', '[3]') diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 33120ec180..2a7a15d434 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -141,9 +141,9 @@ class XmlDescriptor(XModuleDescriptor): # Related: What's the right behavior for clean_metadata? metadata_attributes = ('format', 'graceperiod', 'showanswer', 'rerandomize', 'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc', - 'ispublic', # if True, then course is listed for all users; see - 'xqa_key', # for xqaa server access - 'giturl', # url of git server for origin of file + 'ispublic', # if True, then course is listed for all users; see + 'xqa_key', # for xqaa server access + 'giturl', # url of git server for origin of file # information about testcenter exams is a dict (of dicts), not a string, # so it cannot be easily exportable as a course element's attribute. 'testcenter_info', @@ -347,7 +347,7 @@ class XmlDescriptor(XModuleDescriptor): model_data['children'] = children model_data['xml_attributes'] = {} - model_data['xml_attributes']['filename'] = definition.get('filename', ['', None]) # for git link + model_data['xml_attributes']['filename'] = definition.get('filename', ['', None]) # for git link for key, value in metadata.items(): if key not in set(f.name for f in cls.fields + cls.lms.fields): model_data['xml_attributes'][key] = value @@ -409,7 +409,6 @@ class XmlDescriptor(XModuleDescriptor): # don't want e.g. data_dir if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy: val = val_for_xml(attr) - #logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr)) try: xml_object.set(attr, val) except Exception, e: diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index ec90260928..50b536d444 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -523,10 +523,8 @@ def _adjust_start_date_for_beta_testers(user, descriptor): beta_group = course_beta_test_group_name(descriptor.location) if beta_group in user_groups: debug("Adjust start time: user in group %s", beta_group) - start_as_datetime = descriptor.lms.start delta = timedelta(descriptor.lms.days_early_for_beta) - effective = start_as_datetime - delta - # ...and back to time_struct + effective = descriptor.lms.start - delta return effective return descriptor.lms.start diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 08c5207303..39b99214c8 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -2,8 +2,8 @@ Steps for problem.feature lettuce tests ''' -#pylint: disable=C0111 -#pylint: disable=W0621 +# pylint: disable=C0111 +# pylint: disable=W0621 from lettuce import world, step from lettuce.django import django_url @@ -135,7 +135,7 @@ def action_button_present(_step, buttonname, doesnt_appear): @step(u'the button with the label "([^"]*)" does( not)? appear') -def button_with_label_present(step, buttonname, doesnt_appear): +def button_with_label_present(_step, buttonname, doesnt_appear): if doesnt_appear: assert world.browser.is_text_not_present(buttonname, wait_time=5) else: diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index d17efa3697..5c12725d0a 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -354,7 +354,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours system.set('position', position) system.set('DEBUG', settings.DEBUG) if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS'): - system.set('psychometrics_handler', # set callback for updating PsychometricsData + system.set('psychometrics_handler', # set callback for updating PsychometricsData make_psychometrics_data_update_handler(course_id, user, descriptor.location.url())) try: From b42fe277d4f84dd0ec7192f09f172c641ef16a32 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 12:56:03 -0400 Subject: [PATCH 060/161] Add serial commas to modulestore definitions --- cms/envs/acceptance.py | 2 +- cms/envs/dev.py | 2 +- cms/envs/test.py | 2 +- lms/djangoapps/courseware/tests/tests.py | 2 +- lms/envs/acceptance.py | 2 +- lms/envs/cms/dev.py | 2 +- lms/envs/dev_mongo.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 871b744282..c70ca98902 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -23,7 +23,7 @@ MODULESTORE_OPTIONS = { 'db': 'test_xmodule', 'collection': 'acceptance_modulestore', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 2dcb3640ca..26d633484e 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -22,7 +22,7 @@ modulestore_options = { 'db': 'xmodule', 'collection': 'modulestore', 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/cms/envs/test.py b/cms/envs/test.py index 89813dd937..bd833426d6 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -48,7 +48,7 @@ MODULESTORE_OPTIONS = { 'db': 'test_xmodule', 'collection': 'test_modulestore', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 056a73e7c8..e862ed62c3 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -65,7 +65,7 @@ def mongo_store_config(data_dir): 'db': 'test_xmodule', 'collection': 'modulestore_%s' % uuid4().hex, 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } } } diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 700fc89670..3b87bb4326 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -24,7 +24,7 @@ modulestore_options = { 'db': 'test_xmodule', 'collection': 'acceptance_modulestore', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/lms/envs/cms/dev.py b/lms/envs/cms/dev.py index f8c43148b0..e55c6d61b5 100644 --- a/lms/envs/cms/dev.py +++ b/lms/envs/cms/dev.py @@ -21,7 +21,7 @@ modulestore_options = { 'db': 'xmodule', 'collection': 'modulestore', 'fs_root': DATA_DIR, - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/lms/envs/dev_mongo.py b/lms/envs/dev_mongo.py index 1f6b5899f1..dfbf473b45 100644 --- a/lms/envs/dev_mongo.py +++ b/lms/envs/dev_mongo.py @@ -19,7 +19,7 @@ MODULESTORE = { 'db': 'xmodule', 'collection': 'modulestore', 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'mitxmako.shortcuts.render_to_string', } } } From d9575a0874eb4fefa20face16f70565b52a1da3d Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 12:57:23 -0400 Subject: [PATCH 061/161] Remove traling commas to make json valid --- common/test/data/graphic_slider_tool/policies/2012_Fall.json | 4 ++-- common/test/data/self_assessment/policies/2012_Fall.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/test/data/graphic_slider_tool/policies/2012_Fall.json b/common/test/data/graphic_slider_tool/policies/2012_Fall.json index 6958f8432c..9058481dc8 100644 --- a/common/test/data/graphic_slider_tool/policies/2012_Fall.json +++ b/common/test/data/graphic_slider_tool/policies/2012_Fall.json @@ -9,6 +9,6 @@ "display_name": "Overview" }, "graphical_slider_tool/sample_gst": { - "display_name": "Sample GST", - }, + "display_name": "Sample GST" + } } diff --git a/common/test/data/self_assessment/policies/2012_Fall.json b/common/test/data/self_assessment/policies/2012_Fall.json index aae4670296..46529abcee 100644 --- a/common/test/data/self_assessment/policies/2012_Fall.json +++ b/common/test/data/self_assessment/policies/2012_Fall.json @@ -9,6 +9,6 @@ "display_name": "Overview" }, "selfassessment/SampleQuestion": { - "display_name": "Sample Question", - }, + "display_name": "Sample Question" + } } From 734440f4b9e7f6596d870a246dac1c1ca63c2544 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 25 Jun 2013 20:21:20 -0700 Subject: [PATCH 062/161] Refactored tests --- common/djangoapps/student/tests/tests.py | 55 ++++++++++++++---------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 10836122b8..844ddb536e 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -15,8 +15,11 @@ from django.test import TestCase from django.test.client import RequestFactory from django.contrib.auth.models import User from django.contrib.auth.hashers import UNUSABLE_PASSWORD +from django.contrib.auth.tokens import default_token_generator from django.template.loader import render_to_string, get_template, TemplateDoesNotExist from django.core.urlresolvers import is_valid_path +from django.utils.http import int_to_base36 + from mock import Mock, patch from textwrap import dedent @@ -46,36 +49,40 @@ class ResetPasswordTests(TestCase): self.user = UserFactory.create() self.user.is_active = False self.user.save() + self.token = default_token_generator.make_token(self.user) + self.uidb36 = int_to_base36(self.user.id) self.user_bad_passwd = UserFactory.create() self.user_bad_passwd.is_active = False self.user_bad_passwd.password = UNUSABLE_PASSWORD self.user_bad_passwd.save() + def test_user_bad_password_reset(self): + """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" - @unittest.skipUnless(project_uses_password_reset, dedent("""Skipping Test because CMS has not provided - necessary templates for password reset. If this message is in LMS tests, that is a bug and needs to be fixed.""")) - @patch('student.views.password_reset_confirm') - @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_reset_password_email(self, send_email, reset_confirm): - """Tests sending of reset password email""" - - #First test the bad password user, mainly for diff-cover sake bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_resp = password_reset(bad_pwd_req) self.assertEquals(bad_pwd_resp.status_code, 200) self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, 'error': 'Invalid e-mail or user'})) - #Now test the exception cases with invalid email. + def test_nonexist_email_password_reset(self): + """Now test the exception cases with of reset_password called with invalid email.""" + bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) bad_email_resp = password_reset(bad_email_req) self.assertEquals(bad_email_resp.status_code, 200) self.assertEquals(bad_email_resp.content, json.dumps({'success': False, 'error': 'Invalid e-mail or user'})) - #Now test the legit case where email should have been sent + @unittest.skipUnless(project_uses_password_reset, + dedent("""Skipping Test because CMS has not provided necessary templates for password reset. + If LMS tests print this message, that needs to be fixed.""")) + @patch('django.core.mail.send_mail') + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_reset_password_email(self, send_email): + """Tests contents of reset password email, and that user is not active""" + good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) good_resp = password_reset(good_req) self.assertEquals(good_resp.status_code, 200) @@ -91,33 +98,35 @@ class ResetPasswordTests(TestCase): self.assertIn(self.user.email, to_addrs) #test that the user is not active - #it's a bit unsettling that we have to reload the user from the db for this test to work - #but I guess the user is cached here in the instance of ResetPasswordTests - #so the update in the view does not know to update this class. self.user = User.objects.get(pk=self.user.pk) self.assertFalse(self.user.is_active) + reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() + + @patch('student.views.password_reset_confirm') + def test_reset_password_bad_token(self, reset_confirm): + """Tests bad token and uidb36 in password reset""" - #now try to activate the user in the password reset phase bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') - bad_reset_resp = password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') + password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') (confirm_args, confirm_kwargs) = reset_confirm.call_args self.assertEquals(confirm_kwargs['uidb36'], 'NO') self.assertEquals(confirm_kwargs['token'], 'OP') self.user = User.objects.get(pk=self.user.pk) self.assertFalse(self.user.is_active) - reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() - good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(reset_match['uidb36'], - reset_match['token'])) - good_reset_resp = password_reset_confirm_wrapper(good_reset_req, reset_match['uidb36'], reset_match['token']) + @patch('student.views.password_reset_confirm') + def test_reset_password_good_token(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], reset_match['uidb36']) - self.assertEquals(confirm_kwargs['token'], reset_match['token']) + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) self.user = User.objects.get(pk=self.user.pk) self.assertTrue(self.user.is_active) - class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" From c41c102b7a467e108f44de4dede411eef057dd54 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 25 Jun 2013 21:37:20 -0600 Subject: [PATCH 063/161] Update CHANGELOG.rst --- CHANGELOG.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 21b8c9f90b..4fea30a5c5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Users are no longer auto-activated if they click "reset password" +This is now done when they click on the link in the reset password +email they receive (along with usual path through activation email). + LMS: Problem rescoring. Added options on the Grades tab of the Instructor Dashboard to allow a particular student's submission for a particular problem to be rescored. Provides an option to see a From 72aa56a45e0c0dc9632ec529501002b9dde23820 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Wed, 26 Jun 2013 13:00:15 +0300 Subject: [PATCH 064/161] updated CHANGELOG.rst --- CHANGELOG.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb8eec738f..6e3e02cd3c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Common: Add tests for documentation generation to test suite + +Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems + Blades: Small UX fix on capa multiple-choice problems. Make labels only as wide as the text to reduce accidental choice selections. From 46788f31c8b198d5a73542419ad6657edb1969b8 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 26 Jun 2013 10:12:40 -0400 Subject: [PATCH 065/161] Remove obsolete and distracting SCSS file --- .../sass/course/_discussions-inline.scss | 535 ------------------ 1 file changed, 535 deletions(-) delete mode 100644 lms/static/sass/course/_discussions-inline.scss diff --git a/lms/static/sass/course/_discussions-inline.scss b/lms/static/sass/course/_discussions-inline.scss deleted file mode 100644 index f9569a80ff..0000000000 --- a/lms/static/sass/course/_discussions-inline.scss +++ /dev/null @@ -1,535 +0,0 @@ -.discussion-module { - @extend .discussion-body; - margin: 20px 0; - padding: 20px 20px 28px 20px; - background: #f6f6f6 !important; - border-radius: 3px; - - .responses { - margin-top: 40px; - - > li { - margin: 0 20px 30px; - } - } - - .discussion-show { - display: block; - width: 200px; - margin: auto; - font-size: 14px; - text-align: center; - - &.shown { - .show-hide-discussion-icon { - background-position: 0 0; - } - } - - .show-hide-discussion-icon { - display: inline-block; - position: relative; - top: 5px; - margin-right: 6px; - width: 21px; - height: 19px; - background: url(../images/show-hide-discussion-icon.png) no-repeat; - background-position: -21px 0; - } - } - - .new-post-btn { - display: inline-block; - } - - section.discussion { - margin-top: 20px; - - .threads { - margin-top: 20px; - } - - /* Course content p has a default margin-bottom of 1.416em, this is just to reset that */ - .discussion-thread { - padding: 0; - @include transition(all .25s); - - .dogear, - .vote-btn { - display: none; - } - - &.expanded { - padding: 20px 0; - - .dogear, - .vote-btn { - display: block; - } - - .discussion-article { - border: 1px solid #b2b2b2; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - border-radius: 3px; - } - } - - p { - margin-bottom: 0em; - } - - .discussion-article { - border: 1px solid #ddd; - border-bottom-width: 0; - background: #fff; - min-height: 0; - padding: 10px 10px 15px 10px; - box-shadow: 0 1px 0 #ddd; - @include transition(all .2s); - - .discussion-post { - padding: 12px 20px 0 20px; - @include clearfix; - - header { - padding-bottom: 0; - margin-bottom: 15px; - - h3 { - font-size: 19px; - font-weight: 700; - margin-bottom: 0px; - } - - h4 { - font-size: 16px; - } - } - - .post-body { - font-size: 14px; - clear: both; - } - } - - .post-tools { - margin-left: 20px; - - a { - display: block; - font-size: 12px; - line-height: 30px; - - &.expand-post:before { - content: 'â–¾ '; - } - - &.collapse-post:before { - content: 'â–´ '; - } - - &.collapse-post { - display: none; - } - } - } - - .responses { - margin-top: 10px; - - header { - padding-bottom: 0em; - margin-bottom: 5px; - - .posted-by { - font-size: 0.8em; - } - } - .response-body { - margin-bottom: 0.2em; - font-size: 14px; - } - } - - .discussion-reply-new { - .wmd-input { - height: 120px; - } - } - - // Content that is hidden by default in the inline view - .post-extended-content{ - display: none; - } - - - } - } - } - - .new-post-article { - display: none; - margin-top: 20px; - - .inner-wrapper { - max-width: 1180px; - min-width: 760px; - margin: auto; - } - - .new-post-form { - width: 100%; - margin-bottom: 20px; - padding: 30px; - border-radius: 3px; - background: rgba(0, 0, 0, .55); - color: #fff; - box-shadow: none; - @include clearfix; - @include box-sizing(border-box); - - .form-row { - margin-bottom: 20px; - } - - .new-post-body .wmd-input { - @include discussion-wmd-input; - position: relative; - width: 100%; - height: 200px; - z-index: 1; - padding: 10px; - box-sizing: border-box; - border: 1px solid #333; - border-radius: 3px 3px 0 0; - background: #fff; - font-family: 'Monaco', monospace; - font-size: 13px; - line-height: 1.6; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .new-post-body .wmd-preview { - @include discussion-wmd-preview; - position: relative; - width: 100%; - //height: 50px; - margin-top: -1px; - padding: 25px 20px 10px 20px; - box-sizing: border-box; - border: 1px solid #333; - border-radius: 0 0 3px 3px; - background: #e6e6e6; - color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .new-post-preview-label { - position: absolute; - top: 4px; - left: 4px; - font-size: 11px; - color: #aaa; - text-transform: uppercase; - } - - .new-post-title, - .new-post-tags { - width: 100%; - height: 40px; - padding: 0 10px; - box-sizing: border-box; - border-radius: 3px; - border: 1px solid #333; - font-size: 16px; - font-family: 'Open Sans', sans-serif; - color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - } - - .new-post-title { - font-weight: 700; - } - - .tagsinput { - padding: 10px; - box-sizing: border-box; - border: 1px solid #333; - border-radius: 3px; - background: #fff; - font-family: 'Monaco', monospace; - font-size: 13px; - line-height: 1.6; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3) inset; - - span.tag { - margin-bottom: 0; - } - } - - .submit { - @include blue-button; - float: left; - height: 37px; - margin-top: 10px; - padding-bottom: 2px; - border-color: #333; - - &:hover { - border-color: #222; - } - } - - .new-post-cancel { - @include white-button; - float: left; - margin: 10px 0 0 15px; - border-color: #444; - } - - .options { - margin-top: 5px; - - label { - display: inline; - margin-left: 8px; - font-size: 15px; - color: #fff; - text-shadow: none; - } - } - } - - .thread-tags { - margin-top: 20px; - } - - .thread-tag { - padding: 3px 10px 6px; - border-radius: 3px; - color: #333; - background: #c5eeff; - border: 1px solid #90c4d7; - font-size: 13px; - } - - .thread-title { - display: block; - margin-bottom: 20px; - font-size: 21px; - color: #333; - font-weight: 700; - } - } - - .new-post-btn { - @include blue-button; - display: inline-block; - font-size: 13px; - margin-right: 4px; - } - - .new-post-icon { - display: block; - float: left; - width: 16px; - height: 17px; - margin: 8px 7px 0 0; - background: url(../images/new-post-icon.png) no-repeat; - } - - .moderator-actions { - padding-left: 0 !important; - } - - section.pagination { - margin-top: 30px; - - nav.discussion-paginator { - float: right; - - ol { - li { - list-style: none; - display: inline-block; - padding-right: 0.5em; - a { - @include white-button; - } - } - - li.current-page{ - height: 35px; - padding: 0 15px; - border: 1px solid #ccc; - border-radius: 3px; - font-size: 13px; - font-weight: 700; - line-height: 32px; - color: #333; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); - } - } - } - } - - .new-post-body { - .wmd-panel { - width: 100%; - min-width: 500px; - } - - .wmd-button-bar { - width: 100%; - } - - .wmd-input { - height: 150px; - width: 100%; - background-color: #e9e9e9; - border: 1px solid #c8c8c8; - font-family: Monaco, 'Lucida Console', monospace; - font-style: normal; - font-size: 0.8em; - line-height: 1.6em; - @include border-radius(3px 3px 0 0); - - &::-webkit-input-placeholder { - color: #888; - } - } - - .wmd-preview { - position: relative; - font-family: $sans-serif; - padding: 25px 20px 10px 20px; - margin-bottom: 5px; - box-sizing: border-box; - border: 1px solid #c8c8c8; - border-top-width: 0; - @include border-radius(0 0 3px 3px); - overflow: hidden; - @include transition(all, .2s, easeOut); - - &:before { - content: 'PREVIEW'; - position: absolute; - top: 3px; - left: 5px; - font-size: 11px; - color: #bbb; - } - - p { - font-family: $sans-serif; - } - background-color: #fafafa; - } - - .wmd-button-row { - position: relative; - margin-left: 5px; - margin-right: 5px; - margin-bottom: 5px; - margin-top: 10px; - padding: 0px; - height: 20px; - overflow: hidden; - @include transition(all, .2s, easeOut); - } - - .wmd-spacer { - width: 1px; - height: 20px; - margin-left: 14px; - - position: absolute; - background-color: Silver; - display: inline-block; - list-style: none; - } - - .wmd-button { - width: 20px; - height: 20px; - padding-left: 2px; - padding-right: 3px; - position: absolute; - display: inline-block; - list-style: none; - cursor: pointer; - background: none; - } - - .wmd-button > span { - display: inline-block; - background-image: url(../images/new-post-icons-full.png); - background-repeat: no-repeat; - background-position: 0px 0px; - width: 20px; - height: 20px; - } - - .wmd-spacer1 { - left: 50px; - } - .wmd-spacer2 { - left: 175px; - } - - .wmd-spacer3 { - left: 300px; - } - - .wmd-prompt-background { - background-color: Black; - } - - .wmd-prompt-dialog { - @extend .modal; - background: #fff; - } - - .wmd-prompt-dialog { - padding: 20px; - - > div { - font-size: 0.8em; - font-family: arial, helvetica, sans-serif; - } - - b { - font-size: 16px; - } - - > form > input[type="text"] { - border-radius: 3px; - color: #333; - } - - > form > input[type="button"] { - border: 1px solid #888; - font-family: $sans-serif; - font-size: 14px; - } - - > form > input[type="file"] { - margin-bottom: 18px; - } - } - } - - .wmd-button-row { - // this is being hidden now because the inline styles to position the icons are not being written - display: none; - position: relative; - height: 12px; - } - - .wmd-button { - span { - background-image: url("/static/images/wmd-buttons.png"); - display: inline-block; - } - } -} \ No newline at end of file From 318372f2c0a3c838fb4b785637375e1e632bf143 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 25 Jun 2013 15:11:55 -0400 Subject: [PATCH 066/161] Introduce course creator group. --- cms/djangoapps/auth/authz.py | 87 ++++++++++++++++--- cms/djangoapps/auth/tests/test_authz.py | 78 +++++++++++++++++ .../contentstore/tests/test_contentstore.py | 73 ++++++++++++---- cms/djangoapps/contentstore/views/course.py | 4 +- 4 files changed, 209 insertions(+), 33 deletions(-) create mode 100644 cms/djangoapps/auth/tests/test_authz.py diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 58b63abd23..f27d2fe559 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User, Group from django.core.exceptions import PermissionDenied +from django.conf import settings from xmodule.modulestore import Location @@ -12,6 +13,9 @@ but this implementation should be data compatible with the LMS implementation INSTRUCTOR_ROLE_NAME = 'instructor' STAFF_ROLE_NAME = 'staff' +# This is the group of people who have permission to create new courses on edge or edx. +COURSE_CREATOR_GROUP_NAME = "course_creator_group" + # we're just making a Django group for each location/role combo # to do this we're just creating a Group name which is a formatted string # of those two variables @@ -36,10 +40,10 @@ def get_users_in_course_group_by_role(location, role): return group.user_set.all() -''' -Create all permission groups for a new course and subscribe the caller into those roles -''' def create_all_course_groups(creator, location): + """ + Create all permission groups for a new course and subscribe the caller into those roles + """ create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME) @@ -56,10 +60,10 @@ def create_new_course_group(creator, location, role): return def _delete_course_group(location): - ''' + """ This is to be called only by either a command line code path or through a app which has already asserted permissions - ''' + """ # remove all memberships instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -72,10 +76,10 @@ def _delete_course_group(location): user.save() def _copy_course_group(source, dest): - ''' + """ This is to be called only by either a command line code path or through an app which has already asserted permissions to do this action - ''' + """ instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -94,10 +98,29 @@ def add_user_to_course_group(caller, user, location, role): if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): raise PermissionDenied - if user.is_active and user.is_authenticated: - groupname = get_course_groupname_for_role(location, role) + group = Group.objects.get(name=get_course_groupname_for_role(location, role)) + return _add_user_to_group(user, group) - group = Group.objects.get(name=groupname) + +def add_user_to_creator_group(user): + """ + Adds the user to the group of course creators. + + Note that on the edX site, we currently limit course creators to edX staff, and this + method is a no-op in that environment. + """ + (group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) + if created: + group.save() + return _add_user_to_group(user, group) + + +def _add_user_to_group(user, group): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ + if user.is_active and user.is_authenticated: user.groups.add(group) user.save() return True @@ -123,11 +146,24 @@ def remove_user_from_course_group(caller, user, location, role): # see if the user is actually in that role, if not then we don't have to do anything if is_user_in_course_group_role(user, location, role): - groupname = get_course_groupname_for_role(location, role) + _remove_user_from_group(user, get_course_groupname_for_role(location, role)) - group = Group.objects.get(name=groupname) - user.groups.remove(group) - user.save() + +def remove_user_from_creator_group(user): + """ + Removes user from the course creator group. + """ + _remove_user_from_group(user, COURSE_CREATOR_GROUP_NAME) + + +def _remove_user_from_group(user, group_name): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ + group = Group.objects.get(name=group_name) + user.groups.remove(group) + user.save() def is_user_in_course_group_role(user, location, role): @@ -136,3 +172,26 @@ def is_user_in_course_group_role(user, location, role): return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return False + + +def is_user_in_creator_group(user): + """ + Returns true if the user has permissions to create a course. + + Will always return True if user.is_staff is True. + + Note that on the edX site, we currently limit course creators to edX staff. On + other sites, this method checks that the user is in the course creator group. + """ + if user.is_staff: + return True + + # On edx, we only allow edX staff to create courses. This may be relaxed in the future. + if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + return False + + # Feature flag for using the creator group setting. Will be removed once the feature is complete. + if settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0 + + return True diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py new file mode 100644 index 0000000000..4e44471ebf --- /dev/null +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -0,0 +1,78 @@ +""" +Tests authz.py +""" +import mock + +from django.test import TestCase +from django.contrib.auth.models import User + +from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group + +class CreatorGroupTest(TestCase): + """ + Tests for the course creator group. + """ + def setUp(self): + """ Test case setup """ + self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo') + + def test_creator_group_not_enabled(self): + """ + Tests that is_user_in_creator_group always returns True if ENABLE_CREATOR_GROUP + and DISABLE_COURSE_CREATION are both not turned on. + """ + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_creator_group_enabled_but_empty(self): + """ Tests creator group feature on, but group empty. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + self.assertFalse(is_user_in_creator_group(self.user)) + + # Make user staff. This will cause is_user_in_creator_group to return True. + self.user.is_staff = True + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_creator_group_enabled_nonempty(self): + """ Tests creator group feature on, user added. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + self.assertTrue(add_user_to_creator_group(self.user)) + self.assertTrue(is_user_in_creator_group(self.user)) + + # check that a user who has not been added to the group still returns false + user_not_added = User.objects.create_user('testuser2', 'test+courses2@edx.org', 'foo2') + self.assertFalse(is_user_in_creator_group(user_not_added)) + + # remove first user from the group and verify that is_user_in_creator_group now returns false + remove_user_from_creator_group(self.user) + self.assertFalse(is_user_in_creator_group(self.user)) + + def test_add_user_not_authenticated(self): + """ + Tests that adding to creator group fails if user is not authenticated + """ + self.user.is_authenticated = False + self.assertFalse(add_user_to_creator_group(self.user)) + + def test_add_user_not_active(self): + """ + Tests that adding to creator group fails if user is not active + """ + self.user.is_active = False + self.assertFalse(add_user_to_creator_group(self.user)) + + def test_course_creation_disabled(self): + """ Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP" : True}): + # Add user to creator group. + self.assertTrue(add_user_to_creator_group(self.user)) + + # DISABLE_COURSE_CREATION overrides (user is not marked as staff). + self.assertFalse(is_user_in_creator_group(self.user)) + + # Mark as staff. Now is_user_in_creator_group returns true. + self.user.is_staff = True + self.assertTrue(is_user_in_creator_group(self.user)) + + # Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True + remove_user_from_creator_group(self.user) + self.assertTrue(is_user_in_creator_group(self.user)) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 66fead562e..093532c71d 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,5 +1,6 @@ import json import shutil +import mock from django.test.client import Client from django.test.utils import override_settings from django.conf import settings @@ -16,6 +17,8 @@ from django.dispatch import Signal from contentstore.utils import get_modulestore from contentstore.tests.utils import parse_json +from auth.authz import add_user_to_creator_group + from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -860,6 +863,12 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course(self): """Test new course creation - happy path""" + self.assert_created_course() + + def assert_created_course(self): + """ + Checks that the course was created properly. + """ resp = self.client.post(reverse('create_new_course'), self.course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) @@ -867,41 +876,71 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_check_forum_seeding(self): """Test new course creation and verify forum seeding """ - resp = self.client.post(reverse('create_new_course'), self.course_data) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assert_created_course() self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course')) def test_create_course_duplicate_course(self): """Test new course creation - error path""" self.client.post(reverse('create_new_course'), self.course_data) + self.assert_course_creation_failed('There is already a course defined with this name.') + + def assert_course_creation_failed(self, error_message): + """ + Checks that the course did not get created + """ resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.') + data = parse_json(resp) + self.assertEqual(data['ErrMsg'], error_message) def test_create_course_duplicate_number(self): """Test new course creation - error path""" self.client.post(reverse('create_new_course'), self.course_data) self.course_data['display_name'] = 'Robot Super Course Two' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - 'There is already a course defined with the same organization and course number.') + self.assert_course_creation_failed('There is already a course defined with the same organization and course number.') def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" self.course_data['org'] = 'University of California, Berkeley' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) + self.assert_course_creation_failed("Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") + def test_create_course_with_course_creation_disabled_staff(self): + """Test new course creation -- course creation disabled, but staff access.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + self.assert_created_course() + + def test_create_course_with_course_creation_disabled_not_staff(self): + """Test new course creation -- error path for course creation disabled, not staff access.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + self.user.is_staff = False + self.user.save() + self.assert_course_permission_denied() + + def test_create_course_no_course_creators_staff(self): + """Test new course creation -- course creation group enabled, staff, group is empty.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}): + self.assert_created_course() + + def test_create_course_no_course_creators_not_staff(self): + """Test new course creation -- error path for course creator group enabled, not staff, group is empty.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + self.user.is_staff = False + self.user.save() + self.assert_course_permission_denied() + + def test_create_course_with_course_creator(self): + """Test new course creation -- use course creator group""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + add_user_to_creator_group(self.user) + self.assert_created_course() + + def assert_course_permission_denied(self): + """ + Checks that the course did not get created due to a PermissionError. + """ + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 403) def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index dd7573bad5..8862115c45 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -21,7 +21,7 @@ from contentstore.utils import get_lms_link_for_item, add_extra_panel_tab, remov from models.settings.course_details import CourseDetails, CourseSettingsEncoder from models.settings.course_grading import CourseGradingModel from models.settings.course_metadata import CourseMetadata -from auth.authz import create_all_course_groups +from auth.authz import create_all_course_groups, is_user_in_creator_group from util.json_request import expect_json from .access import has_access, get_location_and_verify_access @@ -81,7 +81,7 @@ def course_index(request, org, course, name): @expect_json def create_new_course(request): - if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff: + if not is_user_in_creator_group(request.user): raise PermissionDenied() # This logic is repeated in xmodule/modulestore/tests/factories.py From 2c60a7dbc142b1fbd973eca5931b8f0b00f6b397 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 25 Jun 2013 15:46:08 -0400 Subject: [PATCH 067/161] pep8 cleanup --- cms/djangoapps/auth/tests/test_authz.py | 9 +++++--- .../contentstore/tests/test_contentstore.py | 21 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index 4e44471ebf..8ecf3689b3 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -8,10 +8,12 @@ from django.contrib.auth.models import User from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group + class CreatorGroupTest(TestCase): """ Tests for the course creator group. """ + def setUp(self): """ Test case setup """ self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo') @@ -25,7 +27,7 @@ class CreatorGroupTest(TestCase): def test_creator_group_enabled_but_empty(self): """ Tests creator group feature on, but group empty. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertFalse(is_user_in_creator_group(self.user)) # Make user staff. This will cause is_user_in_creator_group to return True. @@ -34,7 +36,7 @@ class CreatorGroupTest(TestCase): def test_creator_group_enabled_nonempty(self): """ Tests creator group feature on, user added. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertTrue(add_user_to_creator_group(self.user)) self.assertTrue(is_user_in_creator_group(self.user)) @@ -62,7 +64,8 @@ class CreatorGroupTest(TestCase): def test_course_creation_disabled(self): """ Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP" : True}): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', + {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}): # Add user to creator group. self.assertTrue(add_user_to_creator_group(self.user)) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 093532c71d..ea4b0bbb5b 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -531,7 +531,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(len(all_assets), 0) # make sure we have some thumbnails in our trashcan - all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) + _all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) # # cdodge: temporarily comment out assertion on thumbnails because many environments # will not have the jpeg converter installed and this test will fail @@ -592,20 +592,18 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): location = Location('i4x://MITx/999/chapter/neuvo') self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', - location) + location) direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) - self.assertRaises(InvalidVersionError, draft_store.clone_item, location, - location) + self.assertRaises(InvalidVersionError, draft_store.clone_item, location, location) - self.assertRaises(InvalidVersionError, draft_store.update_item, location, - 'chapter data') + self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data') # taking advantage of update_children and other functions never checking that the ids are valid self.assertRaises(InvalidVersionError, draft_store.update_children, location, - ['i4x://MITx/999/problem/doesntexist']) + ['i4x://MITx/999/problem/doesntexist']) self.assertRaises(InvalidVersionError, draft_store.update_metadata, location, - {'due': datetime.datetime.now(UTC)}) + {'due': datetime.datetime.now(UTC)}) self.assertRaises(InvalidVersionError, draft_store.unpublish, location) @@ -903,7 +901,8 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" self.course_data['org'] = 'University of California, Berkeley' - self.assert_course_creation_failed("Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") + self.assert_course_creation_failed( + "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") def test_create_course_with_course_creation_disabled_staff(self): """Test new course creation -- course creation disabled, but staff access.""" @@ -924,14 +923,14 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_no_course_creators_not_staff(self): """Test new course creation -- error path for course creator group enabled, not staff, group is empty.""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.user.is_staff = False self.user.save() self.assert_course_permission_denied() def test_create_course_with_course_creator(self): """Test new course creation -- use course creator group""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP" : True}): + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): add_user_to_creator_group(self.user) self.assert_created_course() From 190c07d9540c44ee671a7c50322412079c3c0eff Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 25 Jun 2013 16:15:47 -0400 Subject: [PATCH 068/161] Add smoke coverage for add and remove of course group permissions. --- cms/djangoapps/auth/tests/test_authz.py | 62 ++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index 8ecf3689b3..61ac682908 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -5,8 +5,11 @@ import mock from django.test import TestCase from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied -from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group +from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group,\ + create_all_course_groups, add_user_to_course_group, STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,\ + is_user_in_course_group_role, remove_user_from_course_group class CreatorGroupTest(TestCase): @@ -79,3 +82,60 @@ class CreatorGroupTest(TestCase): # Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True remove_user_from_creator_group(self.user) self.assertTrue(is_user_in_creator_group(self.user)) + + +class CourseGroupTest(TestCase): + """ + Tests for instructor and staff groups for a particular course. + """ + + def setUp(self): + """ Test case setup """ + self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo') + self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo') + self.location = 'i4x', 'mitX', '101', 'course', 'test' + + def test_add_user_to_course_group(self): + """ + Tests adding user to course group (happy path). + """ + # Create groups for a new course (and assign instructor role to the creator). + self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + create_all_course_groups(self.creator, self.location) + self.assertTrue(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + + # Add another user to the staff role. + self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + def test_add_user_to_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role. + """ + create_all_course_groups(self.creator, self.location) + with self.assertRaises(PermissionDenied): + add_user_to_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) + + def remove_user_from_course_group(self): + """ + Tests removing user from course group (happy path). + """ + create_all_course_groups(self.creator, self.location) + + self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + remove_user_from_course_group(self.creator, self.location, self.staff, STAFF_ROLE_NAME) + self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + remove_user_from_course_group(self.creator, self.location, self.creator, INSTRUCTOR_ROLE_NAME) + self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + + def test_remove_user_from_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role. + """ + create_all_course_groups(self.creator, self.location) + with self.assertRaises(PermissionDenied): + remove_user_from_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) From 4a697a8da171dad2a1056791607e91ad3e1c6dec Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 25 Jun 2013 17:07:51 -0400 Subject: [PATCH 069/161] Verify that caller of add or remove from creator group is staff. --- cms/djangoapps/auth/authz.py | 14 ++++++- cms/djangoapps/auth/tests/test_authz.py | 53 ++++++++++++++++++++----- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index f27d2fe559..a544906875 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -102,13 +102,18 @@ def add_user_to_course_group(caller, user, location, role): return _add_user_to_group(user, group) -def add_user_to_creator_group(user): +def add_user_to_creator_group(caller, user): """ Adds the user to the group of course creators. + The caller must have staff access to perform this operation. + Note that on the edX site, we currently limit course creators to edX staff, and this method is a no-op in that environment. """ + if not caller.is_active or not caller.is_authenticated or not caller.is_staff: + raise PermissionDenied + (group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) if created: group.save() @@ -149,10 +154,15 @@ def remove_user_from_course_group(caller, user, location, role): _remove_user_from_group(user, get_course_groupname_for_role(location, role)) -def remove_user_from_creator_group(user): +def remove_user_from_creator_group(caller, user): """ Removes user from the course creator group. + + The caller must have staff access to perform this operation. """ + if not caller.is_active or not caller.is_authenticated or not caller.is_staff: + raise PermissionDenied + _remove_user_from_group(user, COURSE_CREATOR_GROUP_NAME) diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index 61ac682908..173155df4c 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -20,6 +20,8 @@ class CreatorGroupTest(TestCase): def setUp(self): """ Test case setup """ self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo') + self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo') + self.admin.is_staff = True def test_creator_group_not_enabled(self): """ @@ -40,7 +42,7 @@ class CreatorGroupTest(TestCase): def test_creator_group_enabled_nonempty(self): """ Tests creator group feature on, user added. """ with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): - self.assertTrue(add_user_to_creator_group(self.user)) + self.assertTrue(add_user_to_creator_group(self.admin, self.user)) self.assertTrue(is_user_in_creator_group(self.user)) # check that a user who has not been added to the group still returns false @@ -48,7 +50,7 @@ class CreatorGroupTest(TestCase): self.assertFalse(is_user_in_creator_group(user_not_added)) # remove first user from the group and verify that is_user_in_creator_group now returns false - remove_user_from_creator_group(self.user) + remove_user_from_creator_group(self.admin, self.user) self.assertFalse(is_user_in_creator_group(self.user)) def test_add_user_not_authenticated(self): @@ -56,21 +58,21 @@ class CreatorGroupTest(TestCase): Tests that adding to creator group fails if user is not authenticated """ self.user.is_authenticated = False - self.assertFalse(add_user_to_creator_group(self.user)) + self.assertFalse(add_user_to_creator_group(self.admin, self.user)) def test_add_user_not_active(self): """ Tests that adding to creator group fails if user is not active """ self.user.is_active = False - self.assertFalse(add_user_to_creator_group(self.user)) + self.assertFalse(add_user_to_creator_group(self.admin, self.user)) def test_course_creation_disabled(self): """ Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """ with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}): # Add user to creator group. - self.assertTrue(add_user_to_creator_group(self.user)) + self.assertTrue(add_user_to_creator_group(self.admin, self.user)) # DISABLE_COURSE_CREATION overrides (user is not marked as staff). self.assertFalse(is_user_in_creator_group(self.user)) @@ -80,9 +82,42 @@ class CreatorGroupTest(TestCase): self.assertTrue(is_user_in_creator_group(self.user)) # Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True - remove_user_from_creator_group(self.user) + remove_user_from_creator_group(self.admin, self.user) self.assertTrue(is_user_in_creator_group(self.user)) + def test_add_user_to_group_requires_staff_access(self): + with self.assertRaises(PermissionDenied): + self.admin.is_staff = False + add_user_to_creator_group(self.admin, self.user) + + with self.assertRaises(PermissionDenied): + add_user_to_creator_group(self.user, self.user) + + def test_add_user_to_group_requires_active(self): + with self.assertRaises(PermissionDenied): + self.admin.is_active = False + add_user_to_creator_group(self.admin, self.user) + + def test_add_user_to_group_requires_authenticated(self): + with self.assertRaises(PermissionDenied): + self.admin.is_authenticated = False + add_user_to_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_staff_access(self): + with self.assertRaises(PermissionDenied): + self.admin.is_staff = False + remove_user_from_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_active(self): + with self.assertRaises(PermissionDenied): + self.admin.is_active = False + remove_user_from_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_authenticated(self): + with self.assertRaises(PermissionDenied): + self.admin.is_authenticated = False + remove_user_from_creator_group(self.admin, self.user) + class CourseGroupTest(TestCase): """ @@ -117,7 +152,7 @@ class CourseGroupTest(TestCase): with self.assertRaises(PermissionDenied): add_user_to_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) - def remove_user_from_course_group(self): + def test_remove_user_from_course_group(self): """ Tests removing user from course group (happy path). """ @@ -126,10 +161,10 @@ class CourseGroupTest(TestCase): self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) - remove_user_from_course_group(self.creator, self.location, self.staff, STAFF_ROLE_NAME) + remove_user_from_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) - remove_user_from_course_group(self.creator, self.location, self.creator, INSTRUCTOR_ROLE_NAME) + remove_user_from_course_group(self.creator, self.creator, self.location, INSTRUCTOR_ROLE_NAME) self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) def test_remove_user_from_course_group_permission_denied(self): From e487521289f4d89dc8164b377b40969cd8912236 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 26 Jun 2013 09:06:12 -0400 Subject: [PATCH 070/161] Update for change in add_user_to_creator_group API. --- cms/djangoapps/contentstore/tests/test_contentstore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index ea4b0bbb5b..b946aac6bb 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -931,7 +931,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_with_course_creator(self): """Test new course creation -- use course creator group""" with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): - add_user_to_creator_group(self.user) + add_user_to_creator_group(self.user, self.user) self.assert_created_course() def assert_course_permission_denied(self): From bb8c62d84ce8949a19c08bd4ab8472628fc692ab Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 26 Jun 2013 12:33:55 -0400 Subject: [PATCH 071/161] Make the problem handle empty fields and non-integers correctly. --- .../templates/problem/customgrader.yaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml b/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml index b5b0d71f4d..48feef481b 100644 --- a/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/customgrader.yaml @@ -13,15 +13,16 @@ data: | @@ -40,7 +41,7 @@ data: |

Explanation

-

Any set of values on the line \(y = 10 - x\) and \(y = 20 - x\) satisfy these constraints.

+

Any set of integers on the line \(y = 10 - x\) and \(y = 20 - x\) satisfy these constraints.

From 50f837d9d80498b1704c7cdf910c452f1008660a Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Wed, 26 Jun 2013 10:22:59 -0700 Subject: [PATCH 072/161] move marketing iframe breakout script to inside existing conditional --- lms/templates/main.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lms/templates/main.html b/lms/templates/main.html index b00446d190..5c0c383b84 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -21,17 +21,17 @@ Home | class.stanford.edu % else: edX + + % endif - From 391ed8c96475b45ff7d8f86313241692ed9c543d Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:56:55 -0400 Subject: [PATCH 073/161] Add docstring to CourseDescriptor.__init__ --- common/lib/xmodule/xmodule/course_module.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 62ebe12a03..02b44bd018 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -212,6 +212,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): template_dir_name = 'course' def __init__(self, *args, **kwargs): + """ + Expects the same arguments as XModuleDescriptor.__init__ + """ super(CourseDescriptor, self).__init__(*args, **kwargs) if self.wiki_slug is None: From ff6ba014ce8bc51ef110cdf135608b74e4a9d70b Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 14:48:45 -0400 Subject: [PATCH 074/161] Remove noop if statement --- cms/djangoapps/contentstore/module_info_model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index e361c97875..726d4bb0ce 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -5,10 +5,7 @@ from xmodule.modulestore import Location def get_module_info(store, location, parent_location=None, rewrite_static_links=False): try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) + module = store.get_item(location) except ItemNotFoundError: # create a new one template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) From 65b3bcdba689f7f0212d0f38d06948f409344471 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:21:17 -0400 Subject: [PATCH 075/161] Clean up variable naming --- cms/djangoapps/contentstore/views/assets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 400013b59b..41077abd8f 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -240,13 +240,13 @@ def import_course(request, org, course, name): # find the 'course.xml' file for dirpath, _dirnames, filenames in os.walk(course_dir): - for files in filenames: - if files == 'course.xml': + for filename in filenames: + if filename == 'course.xml': break - if files == 'course.xml': + if filename == 'course.xml': break - if files != 'course.xml': + if filename != 'course.xml': return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'})) logging.debug('found course.xml at {0}'.format(dirpath)) From 1344b1e521afd83494f99930260c00f679e883d1 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:23:07 -0400 Subject: [PATCH 076/161] Make SessionKeyValueStore variable names clearer --- .../contentstore/views/session_kv_store.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py index 309518c27d..54ab25ff54 100644 --- a/cms/djangoapps/contentstore/views/session_kv_store.py +++ b/cms/djangoapps/contentstore/views/session_kv_store.py @@ -2,27 +2,27 @@ from xblock.runtime import KeyValueStore, InvalidScopeError class SessionKeyValueStore(KeyValueStore): - def __init__(self, request, model_data): - self._model_data = model_data + def __init__(self, request, descriptor_model_data): + self._descriptor_model_data = descriptor_model_data self._session = request.session def get(self, key): try: - return self._model_data[key.field_name] + return self._descriptor_model_data[key.field_name] except (KeyError, InvalidScopeError): return self._session[tuple(key)] def set(self, key, value): try: - self._model_data[key.field_name] = value + self._descriptor_model_data[key.field_name] = value except (KeyError, InvalidScopeError): self._session[tuple(key)] = value def delete(self, key): try: - del self._model_data[key.field_name] + del self._descriptor_model_data[key.field_name] except (KeyError, InvalidScopeError): del self._session[tuple(key)] def has(self, key): - return key in self._model_data or key in self._session + return key in self._descriptor_model_data or key in self._session From b985a7f128851171dde1a3f0927701efa2bb404d Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:26:02 -0400 Subject: [PATCH 077/161] Remove unused template --- cms/templates/new_item.html | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 cms/templates/new_item.html diff --git a/cms/templates/new_item.html b/cms/templates/new_item.html deleted file mode 100644 index 45cb157845..0000000000 --- a/cms/templates/new_item.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
${parent_name}
-
${parent_location}
- -
- % for module_type, module_templates in templates: -
-
${module_type}
-
- % for template in module_templates: - ${template.display_name_with_default} - % endfor -
-
- % endfor -
- Cancel -
- From f7d6d149461bdad797082cb58f667d8260da8f29 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Tue, 25 Jun 2013 09:42:28 -0400 Subject: [PATCH 078/161] Catch InvalidLocationError --- lms/djangoapps/courseware/courses.py | 6 +++--- lms/djangoapps/courseware/tests/test_courses.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/courseware/tests/test_courses.py diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 71c9630964..ef1b786645 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -12,12 +12,11 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent from xmodule.modulestore.xml import XMLModuleStore -from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from courseware.model_data import ModelDataCache from static_replace import replace_static_urls from courseware.access import has_access import branding -from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger(__name__) @@ -49,7 +48,8 @@ def get_course_by_id(course_id, depth=0): return modulestore().get_instance(course_id, course_loc, depth=depth) except (KeyError, ItemNotFoundError): raise Http404("Course not found.") - + except InvalidLocationError: + raise Http404("Invalid location") def get_course_with_access(user, course_id, action, depth=0): """ diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py new file mode 100644 index 0000000000..60594602a4 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from django.test import TestCase +from django.http import Http404 +from courseware.courses import get_course_by_id + +class CoursesTest(TestCase): + def test_get_course_by_id_invalid_chars(self): + """ + Test that `get_course_by_id` throws a 404, rather than + an exception, when faced with unexpected characters + (such as unicode characters, and symbols such as = and ' ') + """ + with self.assertRaises(Http404): + get_course_by_id('MITx/foobar/statistics=introduction') + get_course_by_id('MITx/foobar/business and management') + get_course_by_id('MITx/foobar/NiñøJoséMaríáßç') From 621777e6ae877a2a4914ad8492fdc969935b20ec Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Wed, 26 Jun 2013 16:01:31 -0400 Subject: [PATCH 079/161] Explicitly test the creation and deletion of every component type The testing of all settings/text editing have been done elsewhere and should be done when testing a new component --- .../contentstore/features/component.feature | 71 ++++++++++ .../contentstore/features/component.py | 126 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 cms/djangoapps/contentstore/features/component.feature create mode 100644 cms/djangoapps/contentstore/features/component.py diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature new file mode 100644 index 0000000000..b3d446fc3b --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.feature @@ -0,0 +1,71 @@ +Feature: Component Adding + As a course author, I want to be able to add a wide variety of components + + Scenario: I can add components + Given I have opened a new course in studio + And I am on a new unit + When I add the following components: + | Component | + | Discussion | + | Announcement | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + Then I see the following components: + | Component | + | Discussion | + | Announcement | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + + + Scenario: I can delete Components + Given I have opened a new course in studio + And I am on a new unit + And I add the following components: + | Component | + | Discussion | + | Announcement | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + When I will confirm all alerts + And I delete all components + Then I see no components diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py new file mode 100644 index 0000000000..7366adf07c --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.py @@ -0,0 +1,126 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step + +data_location = 'i4x://edx/templates' + + +@step(u'I am on a new unit') +def add_unit(step): + section_css = 'a.new-courseware-section-button' + world.css_click(section_css) + save_section_css = 'input.new-section-name-save' + world.css_click(save_section_css) + subsection_css = 'a.new-subsection-item' + world.css_click(subsection_css) + save_subsection_css = 'input.new-subsection-name-save' + world.css_click(save_subsection_css) + expand_css = 'div.section-item a.expand-collapse-icon' + world.css_click(expand_css) + unit_css = 'a.new-unit-item' + world.css_click(unit_css) + + +@step(u'I add the following components:') +def add_components(step): + for component in step.hashes: + #due to the way lettuce stores the dictionary + component = component['Component'] + #from pdb import set_trace; set_trace() + assert component in component_dictionary + how_to_add = component_dictionary[component]['steps'] + for css in how_to_add: + world.css_click(css) + + +@step(u'I see the following components') +def check_components(step): + for component in step.hashes: + component = component['Component'] + assert component in component_dictionary + assert component_dictionary[component]['found']() + + +@step(u'I delete all components') +def delete_all_components(step): + components_num = len(component_dictionary) + for delete in range(0, components_num): + world.css_click('a.delete-button') + + +@step(u'I see no components') +def see_no_components(steps): + assert world.is_css_not_present('li.component') + + +component_dictionary = { + 'Discussion': { + 'steps': ['a[data-type="discussion"]'], + 'found': lambda: world.is_css_present('section.xmodule_DiscussionModule', wait_time=2) + }, + 'Announcement': { + 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Announcement"]' % data_location], + 'found': lambda: world.browser.is_text_present('Heading of document') + }, + 'Blank HTML': { + 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Blank_HTML_Page"]' % data_location], + 'found': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] + }, + 'LaTex': { + 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/E-text_Written_in_LaTeX"]' % data_location], + 'found': lambda: world.browser.is_text_present('EXAMPLE: E-TEXT PAGE', wait_time=2) + }, + 'Blank Problem': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Blank_Common_Problem"]' % data_location], + 'found': lambda: world.browser.is_text_present('BLANK COMMON PROBLEM', wait_time=2) + }, + 'Dropdown': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Dropdown"]' % data_location], + 'found': lambda: world.browser.is_text_present('DROPDOWN', wait_time=2) + }, + 'Multi Choice': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Multiple_Choice"]' % data_location], + 'found': lambda: world.browser.is_text_present('MULTIPLE CHOICE', wait_time=2) + }, + 'Numerical': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Numerical_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('NUMERICAL INPUT', wait_time=2) + }, + 'Text Input': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Text_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('TEXT INPUT', wait_time=2) + }, + 'Advanced': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Blank_Advanced_Problem"]' % data_location], + 'found': lambda: world.browser.is_text_present('BLANK ADVANCED PROBLEM', wait_time=2) + }, + 'Circuit': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Circuit_Schematic_Builder"]' % data_location], + 'found': lambda: world.browser.is_text_present('CIRCUIT SCHEMATIC BUILDER', wait_time=2) + }, + 'Custom Python': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Custom_Python-Evaluated_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('CUSTOM PYTHON-EVALUATED INPUT', wait_time=2) + }, + 'Image Mapped': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Image_Mapped_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('IMAGE MAPPED INPUT', wait_time=2) + }, + 'Math Input': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Math_Expression_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('MATH EXPRESSION INPUT', wait_time=2) + }, + 'Problem LaTex': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_Written_in_LaTeX"]' % data_location], + 'found': lambda: world.browser.is_text_present('PROBLEM WRITTEN IN LATEX', wait_time=2) + }, + 'Adaptive Hint': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_with_Adaptive_Hint"]' % data_location], + 'found': lambda: world.browser.is_text_present('PROBLEM WITH ADAPTIVE HINT', wait_time=2) + }, + 'Video': { + 'steps': ['a[data-type="video"]'], + 'found': lambda: world.is_css_present('section.xmodule_VideoModule', wait_time=2) + } +} From dec20d76bc33cb185640c424d39f876f724e5e2f Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 26 Jun 2013 16:25:44 -0400 Subject: [PATCH 080/161] Use separate venv for parallel builds --- jenkins/test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/test.sh b/jenkins/test.sh index 05f978e32f..70a9e168bc 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -60,7 +60,7 @@ fi export PIP_DOWNLOAD_CACHE=/mnt/pip-cache -source /mnt/virtualenvs/"$JOB_NAME"/bin/activate +source $VIRTUALENV_DIR/bin/activate bundle install From ee5389800ad8300a21c90c78976aa61eebadbba8 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Wed, 26 Jun 2013 16:24:04 -0400 Subject: [PATCH 081/161] Fix Lyla showing up everywhere. Previously XML data wasn't parsed in VideoDescriptor.__init__, leading to the defaults being used for video settings. --- .../xmodule/tests/test_video_module.py | 28 ++++++ common/lib/xmodule/xmodule/video_module.py | 90 +++++++++++-------- 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_video_module.py b/common/lib/xmodule/xmodule/tests/test_video_module.py index f516e1a179..e11686176a 100644 --- a/common/lib/xmodule/xmodule/tests/test_video_module.py +++ b/common/lib/xmodule/xmodule/tests/test_video_module.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import unittest +from xmodule.modulestore import Location from xmodule.video_module import VideoDescriptor from .test_import import DummySystem @@ -10,6 +11,33 @@ class VideoDescriptorImportTestCase(unittest.TestCase): Make sure that VideoDescriptor can import an old XML-based video correctly. """ + def test_constructor(self): + sample_xml = ''' + + ''' + location = Location(["i4x", "edX", "video", "default", + "SampleProblem1"]) + model_data = {'data': sample_xml, + 'location': location} + system = DummySystem(load_error_modules=True) + descriptor = VideoDescriptor(system, model_data) + self.assertEquals(descriptor.youtube_id_0_75, 'izygArpw-Qo') + self.assertEquals(descriptor.youtube_id_1_0, 'p2Q6BrNhdh8') + self.assertEquals(descriptor.youtube_id_1_25, '1EeWXzPdhSA') + self.assertEquals(descriptor.youtube_id_1_5, 'rABDYkeK0x8') + self.assertEquals(descriptor.show_captions, False) + self.assertEquals(descriptor.start_time, 1.0) + self.assertEquals(descriptor.end_time, 60) + self.assertEquals(descriptor.track, 'http://www.example.com/track') + self.assertEquals(descriptor.source, 'http://www.example.com/source.mp4') + def test_from_xml(self): module_system = DummySystem(load_error_modules=True) xml_data = ''' diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 04daaea3f2..3c6203107d 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -88,6 +88,13 @@ class VideoDescriptor(VideoFields, module_class = VideoModule template_dir_name = "video" + def __init__(self, *args, **kwargs): + super(VideoDescriptor, self).__init__(*args, **kwargs) + # If we don't have a `youtube_id_1_0`, this is an XML course + # and we parse out the fields. + if self.data and 'youtube_id_1_0' not in self._model_data: + _parse_video_xml(self, self.data) + @property def non_editable_metadata_fields(self): non_editable_fields = super(MetadataOnlyEditingDescriptor, self).non_editable_metadata_fields @@ -108,47 +115,54 @@ class VideoDescriptor(VideoFields, url identifiers """ video = super(VideoDescriptor, cls).from_xml(xml_data, system, org, course) - xml = etree.fromstring(xml_data) - - display_name = xml.get('display_name') - if display_name: - video.display_name = display_name - - youtube = xml.get('youtube') - if youtube: - speeds = _parse_youtube(youtube) - if speeds['0.75']: - video.youtube_id_0_75 = speeds['0.75'] - if speeds['1.00']: - video.youtube_id_1_0 = speeds['1.00'] - if speeds['1.25']: - video.youtube_id_1_25 = speeds['1.25'] - if speeds['1.50']: - video.youtube_id_1_5 = speeds['1.50'] - - show_captions = xml.get('show_captions') - if show_captions: - video.show_captions = json.loads(show_captions) - - source = _get_first_external(xml, 'source') - if source: - video.source = source - - track = _get_first_external(xml, 'track') - if track: - video.track = track - - start_time = _parse_time(xml.get('from')) - if start_time: - video.start_time = start_time - - end_time = _parse_time(xml.get('to')) - if end_time: - video.end_time = end_time - + _parse_video_xml(video, xml_data) return video +def _parse_video_xml(video, xml_data): + """ + Parse video fields out of xml_data. The fields are set if they are + present in the XML. + """ + xml = etree.fromstring(xml_data) + + display_name = xml.get('display_name') + if display_name: + video.display_name = display_name + + youtube = xml.get('youtube') + if youtube: + speeds = _parse_youtube(youtube) + if speeds['0.75']: + video.youtube_id_0_75 = speeds['0.75'] + if speeds['1.00']: + video.youtube_id_1_0 = speeds['1.00'] + if speeds['1.25']: + video.youtube_id_1_25 = speeds['1.25'] + if speeds['1.50']: + video.youtube_id_1_5 = speeds['1.50'] + + show_captions = xml.get('show_captions') + if show_captions: + video.show_captions = json.loads(show_captions) + + source = _get_first_external(xml, 'source') + if source: + video.source = source + + track = _get_first_external(xml, 'track') + if track: + video.track = track + + start_time = _parse_time(xml.get('from')) + if start_time: + video.start_time = start_time + + end_time = _parse_time(xml.get('to')) + if end_time: + video.end_time = end_time + + def _get_first_external(xmltree, tag): """ Returns the src attribute of the nested `tag` in `xmltree`, if it From 2a49d087de272c6724a2f0d1b9aa775d0653784a Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 20 Jun 2013 11:21:43 -0400 Subject: [PATCH 082/161] studio - revises transparent Sass color vars to use rgba() method --- cms/static/sass/_variables.scss | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 14c215c7fd..bad87952d6 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -24,16 +24,16 @@ $f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace; // colors - new for re-org $black: rgb(0,0,0); -$black-t0: rgba(0,0,0,0.125); -$black-t1: rgba(0,0,0,0.25); -$black-t2: rgba(0,0,0,0.50); -$black-t3: rgba(0,0,0,0.75); +$black-t0: rgba($black, 0.125); +$black-t1: rgba($black, 0.25); +$black-t2: rgba($black, 0.5); +$black-t3: rgba($black, 0.75); $white: rgb(255,255,255); -$white-t0: rgba(255,255,255,0.125); -$white-t1: rgba(255,255,255,0.25); -$white-t2: rgba(255,255,255,0.50); -$white-t3: rgba(255,255,255,0.75); +$white-t0: rgba($white, 0.125); +$white-t1: rgba($white, 0.25); +$white-t2: rgba($white, 0.5); +$white-t3: rgba($white, 0.75); $gray: rgb(127,127,127); $gray-l1: tint($gray,20%); @@ -63,10 +63,10 @@ $blue-s3: saturate($blue,45%); $blue-u1: desaturate($blue,15%); $blue-u2: desaturate($blue,30%); $blue-u3: desaturate($blue,45%); -$blue-t0: rgba(85, 151, 221,0.125); -$blue-t1: rgba(85, 151, 221,0.25); -$blue-t2: rgba(85, 151, 221,0.50); -$blue-t3: rgba(85, 151, 221,0.75); +$blue-t0: rgba($blue, 0.125); +$blue-t1: rgba($blue, 0.25); +$blue-t2: rgba($blue, 0.50); +$blue-t3: rgba($blue, 0.75); $pink: rgb(183, 37, 103); $pink-l1: tint($pink,20%); @@ -153,10 +153,11 @@ $orange-u1: desaturate($orange,15%); $orange-u2: desaturate($orange,30%); $orange-u3: desaturate($orange,45%); -$shadow: rgba(0,0,0,0.2); -$shadow-l1: rgba(0,0,0,0.1); -$shadow-l2: rgba(0,0,0,0.05); -$shadow-d1: rgba(0,0,0,0.4); +$shadow: rgba($black, 0.2); +$shadow-l1: rgba($black, 0.1); +$shadow-l2: rgba($black, 0.05); +$shadow-d1: rgba($black, 0.4); +$shadow-d2: rgba($black, 0.6); // ==================== @@ -186,4 +187,3 @@ $error-red: rgb(253, 87, 87); // type $sans-serif: $f-sans-serif; $body-line-height: golden-ratio(.875em, 1); - From 9336ffde94e5e626081a2e72356b2a4b8d210cdd Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 26 Jun 2013 17:14:12 -0400 Subject: [PATCH 083/161] Added clean reports dependency to rake JavaScript test tasks. This ensures that `rake test` will clean the report directories *before* running the JS tests. --- rakelib/jasmine.rake | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rakelib/jasmine.rake b/rakelib/jasmine.rake index 0f532fdf6f..ff72161937 100644 --- a/rakelib/jasmine.rake +++ b/rakelib/jasmine.rake @@ -80,7 +80,7 @@ end namespace :jasmine do namespace system do desc "Open jasmine tests for #{system} in your default browser" - task :browser do + task :browser => [:clean_reports_dir] do Rake::Task[:assets].invoke(system, 'jasmine') django_for_jasmine(system, true) do |jasmine_url| jasmine_browser(jasmine_url) @@ -88,7 +88,7 @@ end end desc "Open jasmine tests for #{system} in your default browser, and dynamically recompile coffeescript" - task :'browser:watch' => :'assets:coffee:_watch' do + task :'browser:watch' => [:clean_reports_dir, :'assets:coffee:_watch'] do django_for_jasmine(system, true) do |jasmine_url| jasmine_browser(jasmine_url, jitter=0, wait=0) end @@ -97,7 +97,7 @@ end end desc "Use phantomjs to run jasmine tests for #{system} from the console" - task :phantomjs do + task :phantomjs => [:clean_reports_dir] do Rake::Task[:assets].invoke(system, 'jasmine') phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' django_for_jasmine(system, false) do |jasmine_url| @@ -122,7 +122,7 @@ static_js_dirs.each do |dir| namespace :jasmine do namespace dir do desc "Open jasmine tests for #{dir} in your default browser" - task :browser do + task :browser => [:clean_reports_dir] do # We need to use either CMS or LMS to preprocess files. Use LMS by default Rake::Task['assets:coffee'].invoke('lms', 'jasmine') template_jasmine_runner(dir) do |f| @@ -131,7 +131,7 @@ static_js_dirs.each do |dir| end desc "Use phantomjs to run jasmine tests for #{dir} from the console" - task :phantomjs do + task :phantomjs => [:clean_reports_dir] do # We need to use either CMS or LMS to preprocess files. Use LMS by default Rake::Task[:assets].invoke('lms', 'jasmine') template_jasmine_runner(dir) do |f| From 7f017d0ca98c8b1c1b40b380c0e97186e9bbf9bf Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Wed, 26 Jun 2013 14:50:16 -0400 Subject: [PATCH 084/161] Addressed pull request feedback --- common/djangoapps/student/views.py | 2 +- .../xmodule/modulestore/tests/django_utils.py | 46 ++++----- .../xmodule/modulestore/tests/factories.py | 8 +- lms/djangoapps/courseware/access.py | 1 - lms/djangoapps/courseware/tests/helpers.py | 27 ++---- .../courseware/tests/modulestore_config.py | 30 +++--- .../courseware/tests/test_navigation.py | 10 +- .../tests/test_view_authentication.py | 94 ++++++------------- 8 files changed, 78 insertions(+), 140 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 82c17c0e67..6b9c9104c5 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -357,7 +357,6 @@ def change_enrollment(request): return HttpResponseBadRequest("Course id not specified") if action == "enroll": - # Make sure the course exists # We don't do this check on unenroll, or a bad course id can't be unenrolled from try: @@ -366,6 +365,7 @@ def change_enrollment(request): log.warning("User {0} tried to enroll in non-existent course {1}" .format(user.username, course_id)) return HttpResponseBadRequest("Course id is invalid") + if not has_access(user, course, 'enroll'): return HttpResponseBadRequest("Enrollment is closed") diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index a2306a5c6b..27f0d9cf2a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -17,30 +17,24 @@ class ModuleStoreTestCase(TestCase): @staticmethod def update_course(course, data): """ - Updates the version of course in the mongo modulestore - with the metadata in data and returns the updated version. + Updates the version of course in the modulestore + with the metadata in 'data' and returns the updated version. 'course' is an instance of CourseDescriptor for which we want to update metadata. 'data' is a dictionary with an entry for each CourseField we want to update. """ - store = xmodule.modulestore.django.modulestore() - - store.update_item(course.location, data) - store.update_metadata(course.location, data) - updated_course = store.get_instance(course.id, course.location) - return updated_course @staticmethod def flush_mongo_except_templates(): - ''' - Delete everything in the module store except templates - ''' + """ + Delete everything in the module store except templates. + """ modulestore = xmodule.modulestore.django.modulestore() # This query means: every item in the collection @@ -52,11 +46,11 @@ class ModuleStoreTestCase(TestCase): @staticmethod def load_templates_if_necessary(): - ''' + """ Load templates into the direct modulestore only if they do not already exist. We need the templates, because they are copied to create - XModules such as sections and problems - ''' + XModules such as sections and problems. + """ modulestore = xmodule.modulestore.django.modulestore('direct') # Count the number of templates @@ -68,9 +62,9 @@ class ModuleStoreTestCase(TestCase): @classmethod def setUpClass(cls): - ''' - Flush the mongo store and set up templates - ''' + """ + Flush the mongo store and set up templates. + """ # Use a uuid to differentiate # the mongo collections on jenkins. @@ -88,9 +82,9 @@ class ModuleStoreTestCase(TestCase): @classmethod def tearDownClass(cls): - ''' - Revert to the old modulestore settings - ''' + """ + Revert to the old modulestore settings. + """ # Clean up by dropping the collection modulestore = xmodule.modulestore.django.modulestore() @@ -102,9 +96,9 @@ class ModuleStoreTestCase(TestCase): settings.MODULESTORE = cls.orig_modulestore def _pre_setup(self): - ''' - Remove everything but the templates before each test - ''' + """ + Remove everything but the templates before each test. + """ # Flush anything that is not a template ModuleStoreTestCase.flush_mongo_except_templates() @@ -116,9 +110,9 @@ class ModuleStoreTestCase(TestCase): super(ModuleStoreTestCase, self)._pre_setup() def _post_teardown(self): - ''' - Flush everything we created except the templates - ''' + """ + Flush everything we created except the templates. + """ # Flush anything that is not a template ModuleStoreTestCase.flush_mongo_except_templates() diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 82ff61204a..a7f0a71a59 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -60,8 +60,8 @@ class XModuleCourseFactory(Factory): if data is not None: store.update_item(new_course.location, data) - #update_item updates the the course as it exists in the modulestore, but doesn't - #update the instance we are working with, so have to refetch the course after updating it. + # update_item updates the the course as it exists in the modulestore, but doesn't + # update the instance we are working with, so have to refetch the course after updating it. new_course = store.get_instance(new_course.id, new_course.location) return new_course @@ -152,8 +152,8 @@ class XModuleItemFactory(Factory): if new_item.location.category not in DETACHED_CATEGORIES: store.update_children(parent_location, parent.children + [new_item.location.url()]) - #update_children updates the the item as it exists in the modulestore, but doesn't - #update the instance we are working with, so have to refetch the item after updating it. + # update_children updates the the item as it exists in the modulestore, but doesn't + # update the instance we are working with, so have to refetch the item after updating it. new_item = store.get_item(new_item.location) return new_item diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 8150d380c5..762020c421 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -526,7 +526,6 @@ def _adjust_start_date_for_beta_testers(user, descriptor): start_as_datetime = descriptor.lms.start delta = timedelta(descriptor.lms.days_early_for_beta) effective = start_as_datetime - delta - # ...and back to time_struct return effective diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index a02a0dfe50..6890a6df2a 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -48,7 +48,6 @@ class LoginEnrollmentTestCase(TestCase): Provides support for user creation, activation, login, and course enrollment. """ - def setup_user(self): """ Create a user account, activate, and log in. @@ -67,8 +66,8 @@ class LoginEnrollmentTestCase(TestCase): """ Login, check that the corresponding view's response has a 200 status code. """ - resp = resp = self.client.post(reverse('login'), - {'email': email, 'password': password}) + resp = self.client.post(reverse('login'), + {'email': email, 'password': password}) self.assertEqual(resp.status_code, 200) data = json.loads(resp.content) self.assertTrue(data['success']) @@ -78,15 +77,14 @@ class LoginEnrollmentTestCase(TestCase): Logout; check that the HTTP response code indicates redirection as expected. """ - resp = self.client.get(reverse('logout'), {}) # should redirect - self.assertEqual(resp.status_code, 302) + check_for_get_code(self, 302, reverse('logout')) def create_account(self, username, email, password): """ Create the account and check that it worked. """ - resp = self.client.post(reverse('create_account'), { + resp = check_for_post_code(self, 200, reverse('create_account'), { 'username': username, 'email': email, 'password': password, @@ -94,10 +92,8 @@ class LoginEnrollmentTestCase(TestCase): 'terms_of_service': 'true', 'honor_code': 'true', }) - self.assertEqual(resp.status_code, 200) data = json.loads(resp.content) self.assertEqual(data['success'], True) - # Check both that the user is created, and inactive self.assertFalse(User.objects.get(email=email).is_active) @@ -107,12 +103,8 @@ class LoginEnrollmentTestCase(TestCase): No error checking. """ activation_key = Registration.objects.get(user__email=email).activation_key - # and now we try to activate - url = reverse('activate', kwargs={'key': activation_key}) - - resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) + check_for_get_code(self, 200, reverse('activate', kwargs={'key': activation_key})) # Now make sure that the user is now actually activated self.assertTrue(User.objects.get(email=email).is_active) @@ -128,8 +120,6 @@ class LoginEnrollmentTestCase(TestCase): 'enrollment_action': 'enroll', 'course_id': course.id, }) - print ('Enrollment in %s result status code: %s' - % (course.location.url(), str(resp.status_code))) result = resp.status_code == 200 if verify: self.assertTrue(result) @@ -140,8 +130,5 @@ class LoginEnrollmentTestCase(TestCase): Unenroll the currently logged-in user, and check that it worked. `course` is an instance of CourseDescriptor. """ - resp = self.client.post(reverse('change_enrollment'), { - 'enrollment_action': 'unenroll', - 'course_id': course.id, - }) - self.assertEqual(resp.status_code, 200) + check_for_post_code(self, 200, reverse('change_enrollment'), {'enrollment_action': 'unenroll', + 'course_id': course.id}) diff --git a/lms/djangoapps/courseware/tests/modulestore_config.py b/lms/djangoapps/courseware/tests/modulestore_config.py index c3c4ce4e5b..9515e449f9 100644 --- a/lms/djangoapps/courseware/tests/modulestore_config.py +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -16,7 +16,7 @@ def mongo_store_config(data_dir): 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, + 'collection': 'modulestore', 'fs_root': data_dir, 'render_template': 'mitxmako.shortcuts.render_to_string' } @@ -30,28 +30,24 @@ def draft_mongo_store_config(data_dir): """ Defines default module store using DraftMongoModuleStore. """ + + modulestore_options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string' + } + return { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } + 'OPTIONS': modulestore_options }, 'direct': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'test_xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', - } + 'OPTIONS': modulestore_options } } diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index eaeb062504..282f6383fc 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -20,8 +20,8 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): def setUp(self): - self.course = CourseFactory.create() - self.full = CourseFactory.create(display_name='Robot_Sub_Course') + self.test_course = CourseFactory.create(display_name='Robot_Sub_Course') + self.course = CourseFactory.create(display_name='Robot_Super_Course') self.chapter0 = ItemFactory.create(parent_location=self.course.location, display_name='Overview') self.chapter9 = ItemFactory.create(parent_location=self.course.location, @@ -46,7 +46,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): email, password = self.STUDENT_INFO[0] self.login(email, password) self.enroll(self.course, True) - self.enroll(self.full, True) + self.enroll(self.test_course, True) resp = self.client.get(reverse('courseware', kwargs={'course_id': self.course.id})) @@ -64,7 +64,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): email, password = self.STUDENT_INFO[0] self.login(email, password) self.enroll(self.course, True) - self.enroll(self.full, True) + self.enroll(self.test_course, True) self.client.get(reverse('courseware_section', kwargs={'course_id': self.course.id, 'chapter': 'Overview', @@ -84,7 +84,7 @@ class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): email, password = self.STUDENT_INFO[0] self.login(email, password) self.enroll(self.course, True) - self.enroll(self.full, True) + self.enroll(self.test_course, True) # Now we directly navigate to a section in a chapter other than 'Overview'. check_for_get_code(self, 200, reverse('courseware_section', diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index b118f99ca2..055c860fcc 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -4,7 +4,6 @@ import pytz from mock import patch from django.contrib.auth.models import User, Group -from django.conf import settings from django.core.urlresolvers import reverse from django.test.utils import override_settings @@ -20,7 +19,6 @@ from helpers import LoginEnrollmentTestCase, check_for_get_code from modulestore_config import TEST_DATA_MONGO_MODULESTORE -#@patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': True}) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ @@ -50,17 +48,14 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): `course` is an instance of CourseDescriptor. """ - print '=== Checking non-staff access for {0}'.format(course.id) urls = [reverse('about_course', kwargs={'course_id': course.id}), reverse('courses')] for url in urls: - print 'checking for 200 on {0}'.format(url) check_for_get_code(self, 200, url) def _check_non_staff_dark(self, course): """ Check that non-staff don't have access to dark urls. """ - print '=== Checking non-staff access for {0}'.format(course.id) names = ['courseware', 'instructor_dashboard', 'progress'] urls = self._reverse_urls(names, course) @@ -70,15 +65,12 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): for index, book in enumerate(course.textbooks) ]) for url in urls: - print 'checking for 404 on {0}'.format(url) check_for_get_code(self, 404, url) def _check_staff(self, course): """ Check that access is right for staff in course. """ - print '=== Checking staff access for {0}'.format(course.id) - names = ['about_course', 'instructor_dashboard', 'progress'] urls = self._reverse_urls(names, course) urls.extend([ @@ -87,7 +79,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): for index, book in enumerate(course.textbooks) ]) for url in urls: - print 'checking for 200 on {0}'.format(url) check_for_get_code(self, 200, url) # The student progress tab is not accessible to a student @@ -99,7 +90,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): url = reverse('student_progress', kwargs={'course_id': course.id, 'student_id': User.objects.get(email=self.ACCOUNT_INFO[0][0]).id}) - print 'checking for 404 on view-as-student: {0}'.format(url) check_for_get_code(self, 404, url) # The courseware url should redirect, not 200 @@ -108,11 +98,12 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): def setUp(self): - self.full = CourseFactory.create(number='666', display_name='Robot_Sub_Course') - self.course = CourseFactory.create() + self.course = CourseFactory.create(number='999', display_name='Robot_Super_Course') self.overview_chapter = ItemFactory.create(display_name='Overview') self.courseware_chapter = ItemFactory.create(display_name='courseware') - self.sub_courseware_chapter = ItemFactory.create(parent_location=self.full.location, + + self.test_course = CourseFactory.create(number='666', display_name='Robot_Sub_Course') + self.sub_courseware_chapter = ItemFactory.create(parent_location=self.test_course.location, display_name='courseware') self.sub_overview_chapter = ItemFactory.create(parent_location=self.sub_courseware_chapter.location, display_name='Overview') @@ -130,7 +121,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify unenrolled student is redirected to the 'about' section of the chapter instead of the 'Welcome' section after clicking on the courseware tab. """ - email, password = self.ACCOUNT_INFO[0] self.login(email, password) response = self.client.get(reverse('courseware', @@ -144,7 +134,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify enrolled student is redirected to the 'Welcome' section of the chapter after clicking on the courseware tab. """ - email, password = self.ACCOUNT_INFO[0] self.login(email, password) self.enroll(self.course) @@ -163,19 +152,17 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify non-staff cannot load the instructor dashboard, the grade views, and student profile pages. """ - email, password = self.ACCOUNT_INFO[0] self.login(email, password) self.enroll(self.course) - self.enroll(self.full) + self.enroll(self.test_course) urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.id}), - reverse('instructor_dashboard', kwargs={'course_id': self.full.id})] + reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id})] # Shouldn't be able to get to the instructor pages for url in urls: - print 'checking for 404 on {0}'.format(url) check_for_get_code(self, 404, url) def test_instructor_course_access(self): @@ -183,7 +170,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify instructor can load the instructor dashboard, the grade views, and student profile pages for their course. """ - email, password = self.ACCOUNT_INFO[1] # Make the instructor staff in self.course @@ -193,13 +179,11 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.login(email, password) - # Now should be able to get to self.course, but not self.full + # Now should be able to get to self.course, but not self.test_course url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) - print 'checking for 200 on {0}'.format(url) check_for_get_code(self, 200, url) - url = reverse('instructor_dashboard', kwargs={'course_id': self.full.id}) - print 'checking for 404 on {0}'.format(url) + url = reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id}) check_for_get_code(self, 404, url) def test_instructor_as_staff_access(self): @@ -207,7 +191,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Verify the instructor can load staff pages if he is given staff permissions. """ - email, password = self.ACCOUNT_INFO[1] self.login(email, password) @@ -218,18 +201,11 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # and now should be able to load both urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.id}), - reverse('instructor_dashboard', kwargs={'course_id': self.full.id})] + reverse('instructor_dashboard', kwargs={'course_id': self.test_course.id})] for url in urls: - print 'checking for 200 on {0}'.format(url) check_for_get_code(self, 200, url) - def test_dark_launch(self): - """ - Make sure that before course start, students can't access course - pages, but instructors can. - """ - @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) def test_dark_launch_enrolled_student(self): """ @@ -243,24 +219,23 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) course_data = {'start': tomorrow} - full_data = {'start': tomorrow} + test_course_data = {'start': tomorrow} self.course = self.update_course(self.course, course_data) - self.full = self.update_course(self.full, full_data) + self.test_course = self.update_course(self.test_course, test_course_data) self.assertFalse(self.course.has_started()) - self.assertFalse(self.full.has_started()) + self.assertFalse(self.test_course.has_started()) # First, try with an enrolled student - print '=== Testing student access....' self.login(student_email, student_password) self.enroll(self.course, True) - self.enroll(self.full, True) + self.enroll(self.test_course, True) # shouldn't be able to get to anything except the light pages self._check_non_staff_light(self.course) self._check_non_staff_dark(self.course) - self._check_non_staff_light(self.full) - self._check_non_staff_dark(self.full) + self._check_non_staff_light(self.test_course) + self._check_non_staff_dark(self.test_course) @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) def test_dark_launch_instructor(self): @@ -268,17 +243,15 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Make sure that before course start instructors can access the page for their course. """ - instructor_email, instructor_password = self.ACCOUNT_INFO[1] now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) course_data = {'start': tomorrow} - full_data = {'start': tomorrow} + test_course_data = {'start': tomorrow} self.course = self.update_course(self.course, course_data) - self.full = self.update_course(self.full, full_data) + self.test_course = self.update_course(self.test_course, test_course_data) - print '=== Testing course instructor access....' # Make the instructor staff in self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) @@ -287,11 +260,11 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): self.login(instructor_email, instructor_password) # Enroll in the classes---can't see courseware otherwise. self.enroll(self.course, True) - self.enroll(self.full, True) + self.enroll(self.test_course, True) # should now be able to get to everything for self.course - self._check_non_staff_light(self.full) - self._check_non_staff_dark(self.full) + self._check_non_staff_light(self.test_course) + self._check_non_staff_dark(self.test_course) self._check_staff(self.course) @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) @@ -300,21 +273,19 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): Make sure that before course start staff can access course pages. """ - instructor_email, instructor_password = self.ACCOUNT_INFO[1] now = datetime.datetime.now(pytz.UTC) tomorrow = now + datetime.timedelta(days=1) course_data = {'start': tomorrow} - full_data = {'start': tomorrow} + test_course_data = {'start': tomorrow} self.course = self.update_course(self.course, course_data) - self.full = self.update_course(self.full, full_data) + self.test_course = self.update_course(self.test_course, test_course_data) self.login(instructor_email, instructor_password) self.enroll(self.course, True) - self.enroll(self.full, True) + self.enroll(self.test_course, True) - print '=== Testing staff access....' # now also make the instructor staff instructor = User.objects.get(email=instructor_email) instructor.is_staff = True @@ -322,14 +293,13 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): # and now should be able to load both self._check_staff(self.course) - self._check_staff(self.full) + self._check_staff(self.test_course) @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) def test_enrollment_period(self): """ Check that enrollment periods work. """ - student_email, student_password = self.ACCOUNT_INFO[0] instructor_email, instructor_password = self.ACCOUNT_INFO[1] @@ -340,34 +310,27 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): yesterday = now - datetime.timedelta(days=1) course_data = {'enrollment_start': tomorrow, 'enrollment_end': nextday} - full_data = {'enrollment_start': yesterday, 'enrollment_end': tomorrow} + test_course_data = {'enrollment_start': yesterday, 'enrollment_end': tomorrow} - print "changing" # self.course's enrollment period hasn't started self.course = self.update_course(self.course, course_data) - # full course's has - self.full = self.update_course(self.full, full_data) + # test_course course's has + self.test_course = self.update_course(self.test_course, test_course_data) - print "login" # First, try with an enrolled student - print '=== Testing student access....' self.login(student_email, student_password) self.assertFalse(self.enroll(self.course)) - self.assertTrue(self.enroll(self.full)) + self.assertTrue(self.enroll(self.test_course)) - print '=== Testing course instructor access....' # Make the instructor staff in the self.course group_name = _course_staff_group_name(self.course.location) group = Group.objects.create(name=group_name) group.user_set.add(User.objects.get(email=instructor_email)) - print "logout/login" self.logout() self.login(instructor_email, instructor_password) - print "Instructor should be able to enroll in self.course" self.assertTrue(self.enroll(self.course)) - print '=== Testing staff access....' # now make the instructor global staff, but not in the instructor group group.user_set.remove(User.objects.get(email=instructor_email)) instructor = User.objects.get(email=instructor_email) @@ -383,7 +346,6 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that beta-test access works. """ - student_email, student_password = self.ACCOUNT_INFO[0] instructor_email, instructor_password = self.ACCOUNT_INFO[1] From 306ac482102970a3946e1e3736a6f5f01bb4143f Mon Sep 17 00:00:00 2001 From: dcadams Date: Wed, 26 Jun 2013 14:01:07 -0700 Subject: [PATCH 085/161] Email on enroll/un-enroll actions Optionally email students on enroll/un-enroll actions by instructor from enrollment tab in LMS. --- CHANGELOG.rst | 2 + common/djangoapps/terrain/factories.py | 2 +- .../instructor/tests/test_enrollment.py | 249 ++++++++++++------ lms/djangoapps/instructor/views.py | 137 +++++++++- .../courseware/instructor_dashboard.html | 2 + .../emails/enroll_email_allowedmessage.txt | 13 + .../emails/enroll_email_allowedsubject.txt | 1 + .../emails/enroll_email_enrolledmessage.txt | 8 + .../emails/enroll_email_enrolledsubject.txt | 1 + .../emails/unenroll_email_allowedmessage.txt | 6 + .../emails/unenroll_email_enrolledmessage.txt | 8 + .../emails/unenroll_email_subject.txt | 1 + 12 files changed, 330 insertions(+), 100 deletions(-) create mode 100644 lms/templates/emails/enroll_email_allowedmessage.txt create mode 100644 lms/templates/emails/enroll_email_allowedsubject.txt create mode 100644 lms/templates/emails/enroll_email_enrolledmessage.txt create mode 100644 lms/templates/emails/enroll_email_enrolledsubject.txt create mode 100644 lms/templates/emails/unenroll_email_allowedmessage.txt create mode 100644 lms/templates/emails/unenroll_email_enrolledmessage.txt create mode 100644 lms/templates/emails/unenroll_email_subject.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4fea30a5c5..8481f3a707 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -152,3 +152,5 @@ Common: Updated CodeJail. Common: Allow setting of authentication session cookie name. +LMS: Option to email students when enroll/un-enroll them. + diff --git a/common/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py index decce42368..2ed78aaa9f 100644 --- a/common/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -44,7 +44,7 @@ class GroupFactory(sf.GroupFactory): @world.absorb -class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowed): +class CourseEnrollmentAllowedFactory(sf.CourseEnrollmentAllowedFactory): """ Users allowed to enroll in the course outside of the usual window """ diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index 3ce82b700b..3b5bdc2ce9 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -1,177 +1,256 @@ -''' +""" Unit tests for enrollment methods in views.py -''' +""" from django.test.utils import override_settings -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import User from django.core.urlresolvers import reverse from courseware.access import _course_staff_group_name from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE, LoginEnrollmentTestCase from student.models import CourseEnrollment, CourseEnrollmentAllowed -from instructor.views import get_and_clean_student_list +from instructor.views import get_and_clean_student_list, send_mail_to_student +from django.core import mail + +USER_COUNT = 4 -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestInstructorEnrollsStudent(LoginEnrollmentTestCase): - ''' - Check Enrollment/Unenrollment with/without auto-enrollment on activation - ''' +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Check Enrollment/Unenrollment with/without auto-enrollment on activation and with/without email notification + """ def setUp(self): - self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - self.toy = modulestore().get_course("edX/toy/2012_Fall") + instructor = AdminFactory.create() + self.client.login(username=instructor.username, password='test') - #Create instructor and student accounts - self.instructor = 'instructor1@test.com' - self.student1 = 'student1@test.com' - self.student2 = 'student2@test.com' - self.password = 'foo' - self.create_account('it1', self.instructor, self.password) - self.create_account('st1', self.student1, self.password) - self.create_account('st2', self.student2, self.password) - self.activate_user(self.instructor) - self.activate_user(self.student1) - self.activate_user(self.student2) + self.course = CourseFactory.create() - def make_instructor(course): - group_name = _course_staff_group_name(course.location) - g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) + self.users = [ + UserFactory.create(username="student%d" % i, email="student%d@test.com" % i) + for i in xrange(USER_COUNT) + ] - make_instructor(self.toy) + for user in self.users: + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) - #Enroll Students - self.logout() - self.login(self.student1, self.password) - self.enroll(self.toy) + # Empty the test outbox + mail.outbox = [] - self.logout() - self.login(self.student2, self.password) - self.enroll(self.toy) + def test_unenrollment_email_off(self): + """ + Do un-enrollment email off test + """ - #Enroll Instructor - self.logout() - self.login(self.instructor, self.password) - self.enroll(self.toy) + course = self.course - def test_unenrollment(self): - ''' - Do un-enrollment test - ''' - - course = self.toy + #Run the Un-enroll students command url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) - response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student1@test.com, student2@test.com'}) + response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student0@test.com student1@test.com'}) - #Check the page output + #Check the page output + self.assertContains(response, 'student0@test.com') self.assertContains(response, 'student1@test.com') - self.assertContains(response, 'student2@test.com') self.assertContains(response, 'un-enrolled') #Check the enrollment table + user = User.objects.get(email='student0@test.com') + ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) + self.assertEqual(0, len(ce)) + user = User.objects.get(email='student1@test.com') ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) self.assertEqual(0, len(ce)) - user = User.objects.get(email='student2@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(0, len(ce)) + #Check the outbox + self.assertEqual(len(mail.outbox), 0) - def test_enrollment_new_student_autoenroll_on(self): - ''' - Do auto-enroll on test - ''' + def test_enrollment_new_student_autoenroll_on_email_off(self): + """ + Do auto-enroll on, email off test + """ + + course = self.course #Run the Enroll students command - course = self.toy url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) - response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test1_1@student.com, test1_2@student.com', 'auto_enroll': 'on'}) + response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student1_1@test.com, student1_2@test.com', 'auto_enroll': 'on'}) #Check the page output - self.assertContains(response, 'test1_1@student.com') - self.assertContains(response, 'test1_2@student.com') + self.assertContains(response, 'student1_1@test.com') + self.assertContains(response, 'student1_2@test.com') self.assertContains(response, 'user does not exist, enrollment allowed, pending with auto enrollment on') + #Check the outbox + self.assertEqual(len(mail.outbox), 0) + #Check the enrollmentallowed db entries - cea = CourseEnrollmentAllowed.objects.filter(email='test1_1@student.com', course_id=course.id) + cea = CourseEnrollmentAllowed.objects.filter(email='student1_1@test.com', course_id=course.id) self.assertEqual(1, cea[0].auto_enroll) - cea = CourseEnrollmentAllowed.objects.filter(email='test1_2@student.com', course_id=course.id) + cea = CourseEnrollmentAllowed.objects.filter(email='student1_2@test.com', course_id=course.id) self.assertEqual(1, cea[0].auto_enroll) - #Check there is no enrollment db entry other than for the setup instructor and students + #Check there is no enrollment db entry other than for the other students ce = CourseEnrollment.objects.filter(course_id=course.id) - self.assertEqual(3, len(ce)) + self.assertEqual(4, len(ce)) #Create and activate student accounts with same email - self.student1 = 'test1_1@student.com' + self.student1 = 'student1_1@test.com' self.password = 'bar' self.create_account('s1_1', self.student1, self.password) self.activate_user(self.student1) - self.student2 = 'test1_2@student.com' + self.student2 = 'student1_2@test.com' self.create_account('s1_2', self.student2, self.password) self.activate_user(self.student2) #Check students are enrolled - user = User.objects.get(email='test1_1@student.com') + user = User.objects.get(email='student1_1@test.com') ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) self.assertEqual(1, len(ce)) - user = User.objects.get(email='test1_2@student.com') + user = User.objects.get(email='student1_2@test.com') ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) self.assertEqual(1, len(ce)) - def test_enrollmemt_new_student_autoenroll_off(self): - ''' - Do auto-enroll off test - ''' + def test_repeat_enroll(self): + """ + Try to enroll an already enrolled student + """ + + course = self.course + + url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) + response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student0@test.com', 'auto_enroll': 'on'}) + self.assertContains(response, 'student0@test.com') + self.assertContains(response, 'already enrolled') + + def test_enrollmemt_new_student_autoenroll_off_email_off(self): + """ + Do auto-enroll off, email off test + """ + + course = self.course #Run the Enroll students command - course = self.toy url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) - response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test2_1@student.com, test2_2@student.com'}) + response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student2_1@test.com, student2_2@test.com'}) #Check the page output - self.assertContains(response, 'test2_1@student.com') - self.assertContains(response, 'test2_2@student.com') + self.assertContains(response, 'student2_1@test.com') + self.assertContains(response, 'student2_2@test.com') self.assertContains(response, 'user does not exist, enrollment allowed, pending with auto enrollment off') + #Check the outbox + self.assertEqual(len(mail.outbox), 0) + #Check the enrollmentallowed db entries - cea = CourseEnrollmentAllowed.objects.filter(email='test2_1@student.com', course_id=course.id) + cea = CourseEnrollmentAllowed.objects.filter(email='student2_1@test.com', course_id=course.id) self.assertEqual(0, cea[0].auto_enroll) - cea = CourseEnrollmentAllowed.objects.filter(email='test2_2@student.com', course_id=course.id) + cea = CourseEnrollmentAllowed.objects.filter(email='student2_2@test.com', course_id=course.id) self.assertEqual(0, cea[0].auto_enroll) #Check there is no enrollment db entry other than for the setup instructor and students ce = CourseEnrollment.objects.filter(course_id=course.id) - self.assertEqual(3, len(ce)) + self.assertEqual(4, len(ce)) #Create and activate student accounts with same email - self.student = 'test2_1@student.com' + self.student = 'student2_1@test.com' self.password = 'bar' self.create_account('s2_1', self.student, self.password) self.activate_user(self.student) - self.student = 'test2_2@student.com' + self.student = 'student2_2@test.com' self.create_account('s2_2', self.student, self.password) self.activate_user(self.student) #Check students are not enrolled - user = User.objects.get(email='test2_1@student.com') + user = User.objects.get(email='student2_1@test.com') ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) self.assertEqual(0, len(ce)) - user = User.objects.get(email='test2_2@student.com') + user = User.objects.get(email='student2_2@test.com') ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) self.assertEqual(0, len(ce)) def test_get_and_clean_student_list(self): - ''' + """ Clean user input test - ''' + """ - string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com " + string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com \n mno@test.com " cleaned_string, cleaned_string_lc = get_and_clean_student_list(string) - self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com']) + self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com', 'mno@test.com']) + + def test_enrollment_email_on(self): + """ + Do email on enroll test + """ + + course = self.course + + #Create activated, but not enrolled, user + UserFactory.create(username="student3_0", email="student3_0@test.com") + + url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) + response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'student3_0@test.com, student3_1@test.com, student3_2@test.com', 'auto_enroll': 'on', 'email_students': 'on'}) + + #Check the page output + self.assertContains(response, 'student3_0@test.com') + self.assertContains(response, 'student3_1@test.com') + self.assertContains(response, 'student3_2@test.com') + self.assertContains(response, 'added, email sent') + self.assertContains(response, 'user does not exist, enrollment allowed, pending with auto enrollment on, email sent') + + #Check the outbox + self.assertEqual(len(mail.outbox), 3) + self.assertEqual(mail.outbox[0].subject, 'You have been enrolled in MITx/999/Robot_Super_Course') + + self.assertEqual(mail.outbox[1].subject, 'You have been invited to register for MITx/999/Robot_Super_Course') + self.assertEqual(mail.outbox[1].body, "Dear student,\n\nYou have been invited to join MITx/999/Robot_Super_Course at edx.org by a member of the course staff.\n\n" + + "To finish your registration, please visit https://edx.org/register and fill out the registration form.\n" + + "Once you have registered and activated your account, you will see MITx/999/Robot_Super_Course listed on your dashboard.\n\n" + + "----\nThis email was automatically sent from edx.org to student3_1@test.com") + + def test_unenrollment_email_on(self): + """ + Do email on unenroll test + """ + + course = self.course + + #Create invited, but not registered, user + cea = CourseEnrollmentAllowed(email='student4_0@test.com', course_id=course.id) + cea.save() + + url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) + response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student4_0@test.com, student2@test.com, student3@test.com', 'email_students': 'on'}) + + #Check the page output + self.assertContains(response, 'student2@test.com') + self.assertContains(response, 'student3@test.com') + self.assertContains(response, 'un-enrolled, email sent') + + #Check the outbox + self.assertEqual(len(mail.outbox), 3) + self.assertEqual(mail.outbox[0].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course') + self.assertEqual(mail.outbox[0].body, "Dear Student,\n\nYou have been un-enrolled from course MITx/999/Robot_Super_Course by a member of the course staff. " + + "Please disregard the invitation previously sent.\n\n" + + "----\nThis email was automatically sent from edx.org to student4_0@test.com") + self.assertEqual(mail.outbox[1].subject, 'You have been un-enrolled from MITx/999/Robot_Super_Course') + + def test_send_mail_to_student(self): + """ + Do invalid mail template test + """ + + d = {'message': 'message_type_that_doesn\'t_exist'} + + send_mail_ret = send_mail_to_student('student0@test.com', d) + self.assertFalse(send_mail_ret) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index e9fff63698..ea96901bca 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -20,6 +20,8 @@ from django.http import HttpResponse from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from django.core.urlresolvers import reverse +from django.core.mail import send_mail + import xmodule.graders as xmgraders from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError @@ -45,6 +47,7 @@ from mitxmako.shortcuts import render_to_response from psychometrics import psychoanalyze from student.models import CourseEnrollment, CourseEnrollmentAllowed import track.views +from mitxmako.shortcuts import render_to_string log = logging.getLogger(__name__) @@ -634,13 +637,15 @@ def instructor_dashboard(request, course_id): students = request.POST.get('multiple_students', '') auto_enroll = bool(request.POST.get('auto_enroll')) - ret = _do_enroll_students(course, course_id, students, auto_enroll=auto_enroll) + email_students = bool(request.POST.get('email_students')) + ret = _do_enroll_students(course, course_id, students, auto_enroll=auto_enroll, email_students=email_students) datatable = ret['datatable'] elif action == 'Unenroll multiple students': students = request.POST.get('multiple_students', '') - ret = _do_unenroll_students(course_id, students) + email_students = bool(request.POST.get('email_students')) + ret = _do_unenroll_students(course_id, students, email_students=email_students) datatable = ret['datatable'] elif action == 'List sections available in remote gradebook': @@ -1068,9 +1073,17 @@ def grade_summary(request, course_id): #----------------------------------------------------------------------------- # enrollment -def _do_enroll_students(course, course_id, students, overload=False, auto_enroll=False): - """Do the actual work of enrolling multiple students, presented as a string - of emails separated by commas or returns""" +def _do_enroll_students(course, course_id, students, overload=False, auto_enroll=False, email_students=False): + """ + Do the actual work of enrolling multiple students, presented as a string + of emails separated by commas or returns + `course` is course object + `course_id` id of course (a `str`) + `students` string of student emails separated by commas or returns (a `str`) + `overload` un-enrolls all existing students (a `boolean`) + `auto_enroll` is user input preference (a `boolean`) + `email_students` is user input preference (a `boolean`) + """ new_students, new_students_lc = get_and_clean_student_list(students) status = dict([x, 'unprocessed'] for x in new_students) @@ -1088,12 +1101,22 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll status[cea.email] = 'removed from pending enrollment list' ceaset.delete() + if email_students: + registration_url = 'https://' + settings.SITE_NAME + reverse('student.views.register_user') + #Composition of email + d = {'site_name': settings.SITE_NAME, + 'registration_url': registration_url, + 'course_id': course_id, + 'auto_enroll': auto_enroll, + 'course_url': registration_url + '/courses/' + course_id, + } + for student in new_students: try: user = User.objects.get(email=student) except User.DoesNotExist: - #User not signed up yet, put in pending enrollment allowed table + #Student not signed up yet, put in pending enrollment allowed table cea = CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id) #If enrollmentallowed already exists, update auto_enroll flag to however it was set in UI @@ -1104,18 +1127,42 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll status[student] = 'user does not exist, enrollment already allowed, pending with auto enrollment ' \ + ('on' if auto_enroll else 'off') continue + + #EnrollmentAllowed doesn't exist so create it cea = CourseEnrollmentAllowed(email=student, course_id=course_id, auto_enroll=auto_enroll) cea.save() - status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' + ('on' if auto_enroll else 'off') + + status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' \ + + ('on' if auto_enroll else 'off') + + if email_students: + #User is allowed to enroll but has not signed up yet + d['email_address'] = student + d['message'] = 'allowed_enroll' + send_mail_ret = send_mail_to_student(student, d) + status[student] += (', email sent' if send_mail_ret else '') continue + #Student has already registered if CourseEnrollment.objects.filter(user=user, course_id=course_id): status[student] = 'already enrolled' continue + try: + #Not enrolled yet ce = CourseEnrollment(user=user, course_id=course_id) ce.save() status[student] = 'added' + + if email_students: + #User enrolled for first time, populate dict with user specific info + d['email_address'] = student + d['first_name'] = user.first_name + d['last_name'] = user.last_name + d['message'] = 'enrolled_enroll' + send_mail_ret = send_mail_to_student(student, d) + status[student] += (', email sent' if send_mail_ret else '') + except: status[student] = 'rejected' @@ -1133,13 +1180,23 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll #Unenrollment -def _do_unenroll_students(course_id, students): - """Do the actual work of un-enrolling multiple students, presented as a string - of emails separated by commas or returns""" +def _do_unenroll_students(course_id, students, email_students=False): + """ + Do the actual work of un-enrolling multiple students, presented as a string + of emails separated by commas or returns + `course_id` is id of course (a `str`) + `students` is string of student emails separated by commas or returns (a `str`) + `email_students` is user input preference (a `boolean`) + """ old_students, _ = get_and_clean_student_list(students) status = dict([x, 'unprocessed'] for x in old_students) + if email_students: + #Composition of email + d = {'site_name': settings.SITE_NAME, + 'course_id': course_id} + for student in old_students: isok = False @@ -1153,6 +1210,14 @@ def _do_unenroll_students(course_id, students): try: user = User.objects.get(email=student) except User.DoesNotExist: + + if isok and email_students: + #User was allowed to join but had not signed up yet + d['email_address'] = student + d['message'] = 'allowed_unenroll' + send_mail_ret = send_mail_to_student(student, d) + status[student] += (', email sent' if send_mail_ret else '') + continue ce = CourseEnrollment.objects.filter(user=user, course_id=course_id) @@ -1161,6 +1226,15 @@ def _do_unenroll_students(course_id, students): try: ce[0].delete() status[student] = "un-enrolled" + if email_students: + #User was enrolled + d['email_address'] = student + d['first_name'] = user.first_name + d['last_name'] = user.last_name + d['message'] = 'enrolled_unenroll' + send_mail_ret = send_mail_to_student(student, d) + status[student] += (', email sent' if send_mail_ret else '') + except Exception: if not isok: status[student] = "Error! Failed to un-enroll" @@ -1173,13 +1247,48 @@ def _do_unenroll_students(course_id, students): return data +def send_mail_to_student(student, param_dict): + """ + Construct the email using templates and then send it. + `student` is the student's email address (a `str`), + + `param_dict` is a `dict` with keys [ + `site_name`: name given to edX instance (a `str`) + `registration_url`: url for registration (a `str`) + `course_id`: id of course (a `str`) + `auto_enroll`: user input option (a `str`) + `course_url`: url of course (a `str`) + `email_address`: email of student (a `str`) + `first_name`: student first name (a `str`) + `last_name`: student last name (a `str`) + `message`: type of email to send and template to use (a `str`) + ] + Returns a boolean indicating whether the email was sent successfully. + """ + + EMAIL_TEMPLATE_DICT = {'allowed_enroll': ('emails/enroll_email_allowedsubject.txt', 'emails/enroll_email_allowedmessage.txt'), + 'enrolled_enroll': ('emails/enroll_email_enrolledsubject.txt', 'emails/enroll_email_enrolledmessage.txt'), + 'allowed_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_allowedmessage.txt'), + 'enrolled_unenroll': ('emails/unenroll_email_subject.txt', 'emails/unenroll_email_enrolledmessage.txt')} + + subject_template, message_template = EMAIL_TEMPLATE_DICT.get(param_dict['message'], (None, None)) + if subject_template is not None and message_template is not None: + subject = render_to_string(subject_template, param_dict) + message = render_to_string(message_template, param_dict) + + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [student], fail_silently=False) + return True + else: + return False + + def get_and_clean_student_list(students): """ Separate out individual student email from the comma, or space separated string. - - In: - students: string coming from the input text area - Return: + `students` is string of student emails separated by commas or returns (a `str`) + Returns: students: list of cleaned student emails students_lc: list of lower case cleaned student emails """ diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index d541962906..bc49cda427 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -382,6 +382,8 @@ function goto( mode)

Enroll or un-enroll one or many students: enter emails, separated by new lines or commas;

+ Notify students by email +

Auto-enroll students when they activate

diff --git a/lms/templates/emails/enroll_email_allowedmessage.txt b/lms/templates/emails/enroll_email_allowedmessage.txt new file mode 100644 index 0000000000..eab347166e --- /dev/null +++ b/lms/templates/emails/enroll_email_allowedmessage.txt @@ -0,0 +1,13 @@ +Dear student, + +You have been invited to join ${course_id} at ${site_name} by a member of the course staff. + +To finish your registration, please visit ${registration_url} and fill out the registration form. +% if auto_enroll: +Once you have registered and activated your account, you will see ${course_id} listed on your dashboard. +% else: +Once you have registered and activated your account, visit ${course_url} to join the course. +% endif + +---- +This email was automatically sent from ${site_name} to ${email_address} \ No newline at end of file diff --git a/lms/templates/emails/enroll_email_allowedsubject.txt b/lms/templates/emails/enroll_email_allowedsubject.txt new file mode 100644 index 0000000000..41da60d1db --- /dev/null +++ b/lms/templates/emails/enroll_email_allowedsubject.txt @@ -0,0 +1 @@ +You have been invited to register for ${course_id} \ No newline at end of file diff --git a/lms/templates/emails/enroll_email_enrolledmessage.txt b/lms/templates/emails/enroll_email_enrolledmessage.txt new file mode 100644 index 0000000000..8e8f24efed --- /dev/null +++ b/lms/templates/emails/enroll_email_enrolledmessage.txt @@ -0,0 +1,8 @@ +Dear ${first_name} ${last_name} + +You have been enrolled in ${course_id} at ${site_name} by a member of the course staff. The course should now appear on your ${site_name} dashboard. + +To start accessing course materials, please visit ${course_url} + +---- +This email was automatically sent from ${site_name} to ${first_name} ${last_name} \ No newline at end of file diff --git a/lms/templates/emails/enroll_email_enrolledsubject.txt b/lms/templates/emails/enroll_email_enrolledsubject.txt new file mode 100644 index 0000000000..db897a3299 --- /dev/null +++ b/lms/templates/emails/enroll_email_enrolledsubject.txt @@ -0,0 +1 @@ +You have been enrolled in ${course_id} \ No newline at end of file diff --git a/lms/templates/emails/unenroll_email_allowedmessage.txt b/lms/templates/emails/unenroll_email_allowedmessage.txt new file mode 100644 index 0000000000..9bd0bd3cfd --- /dev/null +++ b/lms/templates/emails/unenroll_email_allowedmessage.txt @@ -0,0 +1,6 @@ +Dear Student, + +You have been un-enrolled from course ${course_id} by a member of the course staff. Please disregard the invitation previously sent. + +---- +This email was automatically sent from ${site_name} to ${email_address} \ No newline at end of file diff --git a/lms/templates/emails/unenroll_email_enrolledmessage.txt b/lms/templates/emails/unenroll_email_enrolledmessage.txt new file mode 100644 index 0000000000..8a7f9f996e --- /dev/null +++ b/lms/templates/emails/unenroll_email_enrolledmessage.txt @@ -0,0 +1,8 @@ +Dear ${first_name} ${last_name} + +You have been un-enrolled in ${course_id} at ${site_name} by a member of the course staff. The course will no longer appear on your ${site_name} dashboard. + +Your other courses have not been affected. + +---- +This email was automatically sent from ${site_name} to ${first_name} ${last_name} \ No newline at end of file diff --git a/lms/templates/emails/unenroll_email_subject.txt b/lms/templates/emails/unenroll_email_subject.txt new file mode 100644 index 0000000000..f79218ff22 --- /dev/null +++ b/lms/templates/emails/unenroll_email_subject.txt @@ -0,0 +1 @@ +You have been un-enrolled from ${course_id} \ No newline at end of file From 42f71156debfcb0735541ace593f8eb5c919d249 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 26 Jun 2013 14:42:17 -0400 Subject: [PATCH 086/161] Script for making all instructors content creators. --- cms/djangoapps/auth/authz.py | 7 ++++ cms/djangoapps/auth/tests/test_authz.py | 32 ++++++++++++++++++- .../management/commands/populate_creators.py | 14 ++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 cms/djangoapps/contentstore/management/commands/populate_creators.py diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index a544906875..169332e820 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -205,3 +205,10 @@ def is_user_in_creator_group(user): return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0 return True + + +def _grant_instructors_creator_access(caller): + for group in Group.objects.all(): + if group.name.startswith(INSTRUCTOR_ROLE_NAME + "_"): + for user in group.user_set.all(): + add_user_to_creator_group(caller, user) diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index 173155df4c..511ac8ead8 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -9,7 +9,7 @@ from django.core.exceptions import PermissionDenied from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group,\ create_all_course_groups, add_user_to_course_group, STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,\ - is_user_in_course_group_role, remove_user_from_course_group + is_user_in_course_group_role, remove_user_from_course_group, _grant_instructors_creator_access class CreatorGroupTest(TestCase): @@ -174,3 +174,33 @@ class CourseGroupTest(TestCase): create_all_course_groups(self.creator, self.location) with self.assertRaises(PermissionDenied): remove_user_from_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) + + +class GrantInstructorsCreatorAccessTest(TestCase): + + def create_course(self, index): + creator = User.objects.create_user('testcreator'+str(index), 'testcreator+courses@edx.org', 'foo') + staff = User.objects.create_user('teststaff'+str(index), 'teststaff+courses@edx.org', 'foo') + location = 'i4x', 'mitX', str(index), 'course', 'test' + create_all_course_groups(creator, location) + add_user_to_course_group(creator, staff, location, STAFF_ROLE_NAME) + return [creator, staff] + + def test_grant_creator_access(self): + [creator1, staff1] = self.create_course(1) + [creator2, staff2] = self.create_course(2) + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.assertFalse(is_user_in_creator_group(creator1)) + self.assertFalse(is_user_in_creator_group(creator2)) + self.assertFalse(is_user_in_creator_group(staff1)) + self.assertFalse(is_user_in_creator_group(staff2)) + + admin = User.objects.create_user('populate_creators_command', 'grant+creator+access@edx.org', 'foo') + admin.is_staff = True + _grant_instructors_creator_access(admin) + _grant_instructors_creator_access(admin) + + self.assertTrue(is_user_in_creator_group(creator1)) + self.assertTrue(is_user_in_creator_group(creator2)) + self.assertFalse(is_user_in_creator_group(staff1)) + self.assertFalse(is_user_in_creator_group(staff2)) diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py new file mode 100644 index 0000000000..e9453025a0 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -0,0 +1,14 @@ +from auth.authz import _grant_instructors_creator_access +from django.core.management.base import BaseCommand + +from django.contrib.auth.models import User + + +class Command(BaseCommand): + help = 'Grants all users with INSTRUCTOR role permission to create courses' + + def handle(self, *args, **options): + admin = User.objects.create_user('populate_creators_command', 'grant+creator+access@edx.org', 'foo') + admin.is_staff = True + _grant_instructors_creator_access(admin) + admin.delete() From 3babf5392584350a2d34a0076b6bdaa4bb3bf521 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 27 Jun 2013 09:44:09 -0400 Subject: [PATCH 087/161] Handle the case of script being terminated before the user was deleted. --- .../management/commands/populate_creators.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py index e9453025a0..28f360bacf 100644 --- a/cms/djangoapps/contentstore/management/commands/populate_creators.py +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -2,13 +2,23 @@ from auth.authz import _grant_instructors_creator_access from django.core.management.base import BaseCommand from django.contrib.auth.models import User +from django.db.utils import IntegrityError class Command(BaseCommand): help = 'Grants all users with INSTRUCTOR role permission to create courses' def handle(self, *args, **options): - admin = User.objects.create_user('populate_creators_command', 'grant+creator+access@edx.org', 'foo') - admin.is_staff = True + username = 'populate_creators_command' + email = 'grant+creator+access@edx.org' + try: + admin = User.objects.create_user(username, email, 'foo') + admin.is_staff = True + admin.save() + except IntegrityError: + # If the script did not complete the last time it was run, + # the admin user will already exist. + admin = User.objects.get(username=username, email=email) + _grant_instructors_creator_access(admin) admin.delete() From ce100bad8819ddcfb8649bfffd2e8c902ee31da4 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 27 Jun 2013 09:48:47 -0400 Subject: [PATCH 088/161] Add doc strings. --- cms/djangoapps/auth/authz.py | 4 ++++ cms/djangoapps/auth/tests/test_authz.py | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 169332e820..bc11828b2a 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -208,6 +208,10 @@ def is_user_in_creator_group(user): def _grant_instructors_creator_access(caller): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ for group in Group.objects.all(): if group.name.startswith(INSTRUCTOR_ROLE_NAME + "_"): for user in group.user_set.all(): diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index 511ac8ead8..a615a78bbc 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -177,8 +177,13 @@ class CourseGroupTest(TestCase): class GrantInstructorsCreatorAccessTest(TestCase): - + """ + Tests granting existing instructors course creator rights. + """ def create_course(self, index): + """ + Creates a course with one instructor and one staff member. + """ creator = User.objects.create_user('testcreator'+str(index), 'testcreator+courses@edx.org', 'foo') staff = User.objects.create_user('teststaff'+str(index), 'teststaff+courses@edx.org', 'foo') location = 'i4x', 'mitX', str(index), 'course', 'test' @@ -187,9 +192,13 @@ class GrantInstructorsCreatorAccessTest(TestCase): return [creator, staff] def test_grant_creator_access(self): + """ + Test for _grant_instructors_creator_access. + """ [creator1, staff1] = self.create_course(1) [creator2, staff2] = self.create_course(2) with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + # Initially no creators. self.assertFalse(is_user_in_creator_group(creator1)) self.assertFalse(is_user_in_creator_group(creator2)) self.assertFalse(is_user_in_creator_group(staff1)) @@ -198,9 +207,10 @@ class GrantInstructorsCreatorAccessTest(TestCase): admin = User.objects.create_user('populate_creators_command', 'grant+creator+access@edx.org', 'foo') admin.is_staff = True _grant_instructors_creator_access(admin) - _grant_instructors_creator_access(admin) + # Now instructors only are creators. self.assertTrue(is_user_in_creator_group(creator1)) self.assertTrue(is_user_in_creator_group(creator2)) self.assertFalse(is_user_in_creator_group(staff1)) self.assertFalse(is_user_in_creator_group(staff2)) + From 0e0f22999d9c12236b5cee755a562f5cd3a2f81c Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 27 Jun 2013 09:52:18 -0400 Subject: [PATCH 089/161] Add doc strings. --- .../management/commands/populate_creators.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py index 28f360bacf..41c5d8194a 100644 --- a/cms/djangoapps/contentstore/management/commands/populate_creators.py +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -1,3 +1,6 @@ +""" +Script for granting existing course instructors course creator privileges. +""" from auth.authz import _grant_instructors_creator_access from django.core.management.base import BaseCommand @@ -6,9 +9,15 @@ from django.db.utils import IntegrityError class Command(BaseCommand): + """ + Script for granting existing course instructors course creator privileges. + """ help = 'Grants all users with INSTRUCTOR role permission to create courses' def handle(self, *args, **options): + """ + The logic of the command. + """ username = 'populate_creators_command' email = 'grant+creator+access@edx.org' try: From ea8e5f84acae87ee15eb7796e6d6be34b2902c3d Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Thu, 27 Jun 2013 10:14:43 -0400 Subject: [PATCH 090/161] Cleaned up code --- .../contentstore/features/component.feature | 4 +- .../contentstore/features/component.py | 126 +++++++++--------- 2 files changed, 66 insertions(+), 64 deletions(-) diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature index b3d446fc3b..6a18dfa7ab 100644 --- a/cms/djangoapps/contentstore/features/component.feature +++ b/cms/djangoapps/contentstore/features/component.feature @@ -3,7 +3,7 @@ Feature: Component Adding Scenario: I can add components Given I have opened a new course in studio - And I am on a new unit + And I am editing a new unit When I add the following components: | Component | | Discussion | @@ -46,7 +46,7 @@ Feature: Component Adding Scenario: I can delete Components Given I have opened a new course in studio - And I am on a new unit + And I am editing a new unit And I add the following components: | Component | | Discussion | diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index 7366adf07c..b3775a6cbe 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -3,49 +3,34 @@ from lettuce import world, step -data_location = 'i4x://edx/templates' +DATA_LOCATION = 'i4x://edx/templates' -@step(u'I am on a new unit') +@step(u'I am editing a new unit') def add_unit(step): - section_css = 'a.new-courseware-section-button' - world.css_click(section_css) - save_section_css = 'input.new-section-name-save' - world.css_click(save_section_css) - subsection_css = 'a.new-subsection-item' - world.css_click(subsection_css) - save_subsection_css = 'input.new-subsection-name-save' - world.css_click(save_subsection_css) - expand_css = 'div.section-item a.expand-collapse-icon' - world.css_click(expand_css) - unit_css = 'a.new-unit-item' - world.css_click(unit_css) + css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] + for selector in css_selectors: + world.css_click(selector) @step(u'I add the following components:') def add_components(step): - for component in step.hashes: - #due to the way lettuce stores the dictionary - component = component['Component'] - #from pdb import set_trace; set_trace() - assert component in component_dictionary - how_to_add = component_dictionary[component]['steps'] - for css in how_to_add: + for component in [step_hash['Component'] for step_hash in step.hashes]: + assert component in COMPONENT_DICTIONARY + for css in COMPONENT_DICTIONARY[component]['steps']: world.css_click(css) @step(u'I see the following components') def check_components(step): - for component in step.hashes: - component = component['Component'] - assert component in component_dictionary - assert component_dictionary[component]['found']() + for component in [step_hash['Component'] for step_hash in step.hashes]: + assert component in COMPONENT_DICTIONARY + assert COMPONENT_DICTIONARY[component]['found_func']() @step(u'I delete all components') def delete_all_components(step): - components_num = len(component_dictionary) - for delete in range(0, components_num): + for _ in range(len(COMPONENT_DICTIONARY)): world.css_click('a.delete-button') @@ -54,73 +39,90 @@ def see_no_components(steps): assert world.is_css_not_present('li.component') -component_dictionary = { +def step_selector_list(data_type, path, index=1): + selector_list = ['a[data-type="{}"]'.format(data_type)] + if index != 1: + selector_list.append('a[id="ui-id-{}"]'.format(index)) + if path is not None: + selector_list.append('a[data-location="{}/{}/{}"]'.format(DATA_LOCATION, data_type, path)) + return selector_list + + +def found_text_func(text): + return lambda: world.browser.is_text_present(text) + + +def found_css_func(css): + return lambda: world.is_css_present(css, wait_time=2) + +COMPONENT_DICTIONARY = { 'Discussion': { - 'steps': ['a[data-type="discussion"]'], - 'found': lambda: world.is_css_present('section.xmodule_DiscussionModule', wait_time=2) + 'steps': step_selector_list('discussion', None), + 'found_func': found_css_func('section.xmodule_DiscussionModule') }, 'Announcement': { - 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Announcement"]' % data_location], - 'found': lambda: world.browser.is_text_present('Heading of document') + 'steps': step_selector_list('html', 'Announcement'), + 'found_func': found_text_func('Heading of document') }, 'Blank HTML': { - 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Blank_HTML_Page"]' % data_location], - 'found': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] + 'steps': step_selector_list('html', 'Blank_HTML_Page'), + #this one is a blank html so a more refined search is being done + 'found_func': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] }, 'LaTex': { - 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/E-text_Written_in_LaTeX"]' % data_location], - 'found': lambda: world.browser.is_text_present('EXAMPLE: E-TEXT PAGE', wait_time=2) + 'steps': step_selector_list('html', 'E-text_Written_in_LaTeX'), + 'found_func': found_text_func('EXAMPLE: E-TEXT PAGE') }, 'Blank Problem': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Blank_Common_Problem"]' % data_location], - 'found': lambda: world.browser.is_text_present('BLANK COMMON PROBLEM', wait_time=2) + 'steps': step_selector_list('problem', 'Blank_Common_Problem'), + 'found_func': found_text_func('BLANK COMMON PROBLEM') }, 'Dropdown': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Dropdown"]' % data_location], - 'found': lambda: world.browser.is_text_present('DROPDOWN', wait_time=2) + 'steps': step_selector_list('problem', 'Dropdown'), + 'found_func': found_text_func('DROPDOWN') }, 'Multi Choice': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Multiple_Choice"]' % data_location], - 'found': lambda: world.browser.is_text_present('MULTIPLE CHOICE', wait_time=2) + 'steps': step_selector_list('problem', 'Multiple_Choice'), + 'found_func': found_text_func('MULTIPLE CHOICE') }, 'Numerical': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Numerical_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('NUMERICAL INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Numerical_Input'), + 'found_func': found_text_func('NUMERICAL INPUT') }, 'Text Input': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Text_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('TEXT INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Text_Input'), + 'found_func': found_text_func('TEXT INPUT') }, 'Advanced': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Blank_Advanced_Problem"]' % data_location], - 'found': lambda: world.browser.is_text_present('BLANK ADVANCED PROBLEM', wait_time=2) + 'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2), + 'found_func': found_text_func('BLANK ADVANCED PROBLEM') }, 'Circuit': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Circuit_Schematic_Builder"]' % data_location], - 'found': lambda: world.browser.is_text_present('CIRCUIT SCHEMATIC BUILDER', wait_time=2) + 'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2), + 'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER') }, 'Custom Python': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Custom_Python-Evaluated_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('CUSTOM PYTHON-EVALUATED INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2), + 'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT') }, 'Image Mapped': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Image_Mapped_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('IMAGE MAPPED INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2), + 'found_func': found_text_func('IMAGE MAPPED INPUT') }, 'Math Input': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Math_Expression_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('MATH EXPRESSION INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Math_Expression_Input', index=2), + 'found_func': found_text_func('MATH EXPRESSION INPUT') }, 'Problem LaTex': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_Written_in_LaTeX"]' % data_location], - 'found': lambda: world.browser.is_text_present('PROBLEM WRITTEN IN LATEX', wait_time=2) + 'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2), + 'found_func': found_text_func('PROBLEM WRITTEN IN LATEX') }, 'Adaptive Hint': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_with_Adaptive_Hint"]' % data_location], - 'found': lambda: world.browser.is_text_present('PROBLEM WITH ADAPTIVE HINT', wait_time=2) + 'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2), + 'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT') }, 'Video': { - 'steps': ['a[data-type="video"]'], - 'found': lambda: world.is_css_present('section.xmodule_VideoModule', wait_time=2) + 'steps': step_selector_list('video', None), + 'found_func': found_css_func('section.xmodule_VideoModule') } } From 27e720cf3b8d8802524381e61b9f94ea64b81d79 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 27 Jun 2013 11:00:11 -0400 Subject: [PATCH 091/161] Make it clear that this should only be run once. --- cms/djangoapps/auth/authz.py | 5 ++++- .../contentstore/management/commands/populate_creators.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index bc11828b2a..438c9d0697 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -210,7 +210,10 @@ def is_user_in_creator_group(user): def _grant_instructors_creator_access(caller): """ This is to be called only by either a command line code path or through an app which has already - asserted permissions to do this action + asserted permissions to do this action. + + Gives all users with instructor role course creator rights. + This is only intended to be run once on a given environment. """ for group in Group.objects.all(): if group.name.startswith(INSTRUCTOR_ROLE_NAME + "_"): diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py index 41c5d8194a..f627df88f5 100644 --- a/cms/djangoapps/contentstore/management/commands/populate_creators.py +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -1,5 +1,7 @@ """ Script for granting existing course instructors course creator privileges. + +This script is only intended to be run once on a given environment. """ from auth.authz import _grant_instructors_creator_access from django.core.management.base import BaseCommand From f095c5fec6507ba38552b0e02530e8ce981ffcbe Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 27 Jun 2013 11:00:43 -0400 Subject: [PATCH 092/161] Add ENABLE_CREATOR_GROUP (set to False). --- cms/envs/common.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 7f4c106e6d..87c130a4b5 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -54,7 +54,11 @@ MITX_FEATURES = { 'ENABLE_SERVICE_STATUS': False, # Don't autoplay videos for course authors - 'AUTOPLAY_VIDEOS': False + 'AUTOPLAY_VIDEOS': False, + + # If set to True, new Studio users won't be able to author courses unless + # edX has explicitly added them to the course creator group. + 'ENABLE_CREATOR_GROUP': False } ENABLE_JASMINE = False From c98a77565fef43ebc92c5d58d614cacb6d17c8f2 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 27 Jun 2013 10:52:26 -0400 Subject: [PATCH 093/161] Make the UrlResetMixin load the urlconf after resetting it, and fix the comment client test that was leaving ENABLE_DISCUSSION_SERVICE at True --- common/djangoapps/util/testing.py | 5 ++++- lms/djangoapps/django_comment_client/base/tests.py | 12 ++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/common/djangoapps/util/testing.py b/common/djangoapps/util/testing.py index d33f1c8f8b..062b04c8a0 100644 --- a/common/djangoapps/util/testing.py +++ b/common/djangoapps/util/testing.py @@ -1,7 +1,7 @@ import sys from django.conf import settings -from django.core.urlresolvers import clear_url_caches +from django.core.urlresolvers import clear_url_caches, resolve class UrlResetMixin(object): @@ -27,6 +27,9 @@ class UrlResetMixin(object): reload(sys.modules[urlconf]) clear_url_caches() + # Resolve a URL so that the new urlconf gets loaded + resolve('/') + def setUp(self): """Reset django default urlconf before tests and after tests""" super(UrlResetMixin, self).setUp() diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py index aa5b657bd6..434d4d616b 100644 --- a/lms/djangoapps/django_comment_client/base/tests.py +++ b/lms/djangoapps/django_comment_client/base/tests.py @@ -1,6 +1,5 @@ import logging -from django.conf import settings from django.test.utils import override_settings from django.test.client import Client from django.contrib.auth.models import User @@ -21,16 +20,13 @@ log = logging.getLogger(__name__) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @patch('comment_client.utils.requests.request') class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase): + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): - # This feature affects the contents of urls.py, so we change - # it before the call to super.setUp() which reloads urls.py (because + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because # of the UrlResetMixin) - - # This setting is cleaned up at the end of the test by @override_settings, which - # restores all of the old settings - settings.MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True - super(ViewsTestCase, self).setUp() # create a course From ed57e7e555c87b3a798209cb7ba7e7ea32584bd0 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 27 Jun 2013 10:51:23 -0400 Subject: [PATCH 094/161] Used dependency to ensure that REPORT_DIR exists before cleaning. This allows the build to pass in Jenkins --- rakelib/tests.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 0ca7c5c1e9..2bbe3a6ad8 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -53,7 +53,7 @@ task :clean_test_files do sh("git clean -fqdx test_root") end -task :clean_reports_dir do +task :clean_reports_dir => REPORT_DIR do desc "Clean coverage files, to ensure that we don't use stale data to generate reports." # We delete the files but preserve the directory structure From 8d4c2af9f2dcf8e4a2a600cfe239709f1fdd25ee Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 27 Jun 2013 13:34:58 -0400 Subject: [PATCH 095/161] Teach rake test_$lib to understand specific test names --- rakelib/tests.rake | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 0ca7c5c1e9..48ad3ad1d5 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -99,9 +99,10 @@ Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| report_dir = report_dir_path(lib) desc "Run tests for common lib #{lib}" - task "test_#{lib}" => [report_dir, :clean_reports_dir] do + task "test_#{lib}", [:test_id] => [report_dir, :clean_reports_dir] do |t, args| + args.with_defaults(:test_id => lib) ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") - cmd = "nosetests #{lib}" + cmd = "nosetests #{args.test_id}" test_sh(run_under_coverage(cmd, lib)) end TEST_TASK_DIRS << lib From f6d7cc55166b06ddabe97decd6780d8ebf6cba1e Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Thu, 27 Jun 2013 15:57:51 -0400 Subject: [PATCH 096/161] save the cert record immediately after we set the status There was a potential though unlikely race condition because the .save() was after the certificate request was put on the queue. Now calling .save() immediately after we update the cert. --- lms/djangoapps/certificates/queue.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index af1037f903..78e786e884 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -194,6 +194,7 @@ class XQueueCertInterface(object): # on the queue if self.restricted.filter(user=student).exists(): cert.status = status.restricted + cert.save() else: contents = { 'action': 'create', @@ -202,15 +203,15 @@ class XQueueCertInterface(object): 'name': profile.name, } cert.status = status.generating + cert.save() self._send_to_xqueue(contents, key) - cert.save() else: cert_status = status.notpassing cert.grade = grade['percent'] - cert.status = cert_status cert.user = student cert.course_id = course_id cert.name = profile.name + cert.status = cert_status cert.save() return cert_status From 32e6d4819f5a4d1f879b3b33e3b1f81940ea2736 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 27 Jun 2013 16:18:16 -0400 Subject: [PATCH 097/161] pep8/pylint cleanup. --- cms/djangoapps/auth/authz.py | 4 +++- cms/djangoapps/auth/tests/test_authz.py | 5 ++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 438c9d0697..23dde88e94 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -36,7 +36,7 @@ def get_course_groupname_for_role(location, role): def get_users_in_course_group_by_role(location, role): groupname = get_course_groupname_for_role(location, role) - (group, created) = Group.objects.get_or_create(name=groupname) + (group, _created) = Group.objects.get_or_create(name=groupname) return group.user_set.all() @@ -59,6 +59,7 @@ def create_new_course_group(creator, location, role): return + def _delete_course_group(location): """ This is to be called only by either a command line code path or through a app which has already @@ -75,6 +76,7 @@ def _delete_course_group(location): user.groups.remove(staff) user.save() + def _copy_course_group(source, dest): """ This is to be called only by either a command line code path or through an app which has already diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index a615a78bbc..658c176498 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -184,8 +184,8 @@ class GrantInstructorsCreatorAccessTest(TestCase): """ Creates a course with one instructor and one staff member. """ - creator = User.objects.create_user('testcreator'+str(index), 'testcreator+courses@edx.org', 'foo') - staff = User.objects.create_user('teststaff'+str(index), 'teststaff+courses@edx.org', 'foo') + creator = User.objects.create_user('testcreator' + str(index), 'testcreator+courses@edx.org', 'foo') + staff = User.objects.create_user('teststaff' + str(index), 'teststaff+courses@edx.org', 'foo') location = 'i4x', 'mitX', str(index), 'course', 'test' create_all_course_groups(creator, location) add_user_to_course_group(creator, staff, location, STAFF_ROLE_NAME) @@ -213,4 +213,3 @@ class GrantInstructorsCreatorAccessTest(TestCase): self.assertTrue(is_user_in_creator_group(creator2)) self.assertFalse(is_user_in_creator_group(staff1)) self.assertFalse(is_user_in_creator_group(staff2)) - From 4557891e0de803e1d6236387371ff8788df918df Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Thu, 27 Jun 2013 16:25:03 -0400 Subject: [PATCH 098/161] Standardize types used for logging events from the instructor dashboard --- lms/djangoapps/instructor/views.py | 87 +++++++++++------------------- 1 file changed, 32 insertions(+), 55 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index ea96901bca..60c4f2143d 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -200,7 +200,7 @@ def instructor_dashboard(request, course_id): cmd = "cd {0}; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml".format(gdir) msg += "git pull on {0}:

".format(data_dir) msg += "

{0}

".format(escape(os.popen(cmd).read())) - track.views.server_track(request, 'git pull {0}'.format(data_dir), {}, page='idashboard') + track.views.server_track(request, "git-pull", {"directory": data_dir}, page="idashboard") if 'Reload course' in action: log.debug('reloading {0} ({1})'.format(course_id, course)) @@ -208,7 +208,7 @@ def instructor_dashboard(request, course_id): data_dir = getattr(course, 'data_dir') modulestore().try_load_course(data_dir) msg += "

Course reloaded from {0}

".format(data_dir) - track.views.server_track(request, 'reload {0}'.format(data_dir), {}, page='idashboard') + track.views.server_track(request, "reload", {"directory": data_dir}, page="idashboard") course_errors = modulestore().get_item_errors(course.location) msg += '
    ' for cmsg, cerr in course_errors: @@ -221,37 +221,38 @@ def instructor_dashboard(request, course_id): log.debug(action) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=False, use_offline=use_offline) datatable['title'] = 'List of students enrolled in {0}'.format(course_id) - track.views.server_track(request, 'list-students', {}, page='idashboard') + track.views.server_track(request, "list-students", {}, page="idashboard") elif 'Dump Grades' in action: log.debug(action) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True, use_offline=use_offline) datatable['title'] = 'Summary Grades of students enrolled in {0}'.format(course_id) - track.views.server_track(request, 'dump-grades', {}, page='idashboard') + track.views.server_track(request, "dump-grades", {}, page="idashboard") elif 'Dump all RAW grades' in action: log.debug(action) datatable = get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=True, use_offline=use_offline) datatable['title'] = 'Raw Grades of students enrolled in {0}'.format(course_id) - track.views.server_track(request, 'dump-grades-raw', {}, page='idashboard') + track.views.server_track(request, "dump-grades-raw", {}, page="idashboard") elif 'Download CSV of all student grades' in action: - track.views.server_track(request, 'dump-grades-csv', {}, page='idashboard') + track.views.server_track(request, "dump-grades-csv", {}, page="idashboard") return return_csv('grades_{0}.csv'.format(course_id), get_student_grade_summary_data(request, course, course_id, use_offline=use_offline)) elif 'Download CSV of all RAW grades' in action: - track.views.server_track(request, 'dump-grades-csv-raw', {}, page='idashboard') + track.views.server_track(request, "dump-grades-csv-raw", {}, page="idashboard") return return_csv('grades_{0}_raw.csv'.format(course_id), get_student_grade_summary_data(request, course, course_id, get_raw_scores=True, use_offline=use_offline)) elif 'Download CSV of answer distributions' in action: - track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard') + track.views.server_track(request, "dump-answer-dist-csv", {}, page="idashboard") return return_csv('answer_dist_{0}.csv'.format(course_id), get_answers_distribution(request, course_id)) elif 'Dump description of graded assignments configuration' in action: - track.views.server_track(request, action, {}, page='idashboard') + # what is "graded assignments configuration"? + track.views.server_track(request, "dump-graded-assignments-config", {}, page="idashboard") msg += dump_grading_context(course) elif "Rescore ALL students' problem submissions" in action: @@ -262,8 +263,7 @@ def instructor_dashboard(request, course_id): if instructor_task is None: msg += 'Failed to create a background task for rescoring "{0}".'.format(problem_url) else: - track_msg = 'rescore problem {problem} for all students in {course}'.format(problem=problem_url, course=course_id) - track.views.server_track(request, track_msg, {}, page='idashboard') + track.views.server_track(request, "rescore-all-submissions", {"problem": problem_url, "course": course_id}, page="idashboard") except ItemNotFoundError as e: msg += 'Failed to create a background task for rescoring "{0}": problem not found.'.format(problem_url) except Exception as e: @@ -278,8 +278,7 @@ def instructor_dashboard(request, course_id): if instructor_task is None: msg += 'Failed to create a background task for resetting "{0}".'.format(problem_url) else: - track_msg = 'reset problem {problem} for all students in {course}'.format(problem=problem_url, course=course_id) - track.views.server_track(request, track_msg, {}, page='idashboard') + track.views.server_track(request, "reset-all-attempts", {"problem": problem_url, "course": course_id}, page="idashboard") except ItemNotFoundError as e: log.error('Failure to reset: unknown problem "{0}"'.format(e)) msg += 'Failed to create a background task for resetting "{0}": problem not found.'.format(problem_url) @@ -332,9 +331,7 @@ def instructor_dashboard(request, course_id): try: student_module.delete() msg += "Deleted student module state for %s!" % module_state_key - track_format = 'delete student module state for problem {problem} for student {student} in {course}' - track_msg = track_format.format(problem=problem_url, student=unique_student_identifier, course=course_id) - track.views.server_track(request, track_msg, {}, page='idashboard') + track.views.server_track(request, "delete-student-module-state", {"problem": problem_url, "student": unique_student_identifier, "course": course_id}, page="idashboard") except: msg += "Failed to delete module state for %s/%s" % (unique_student_identifier, problem_urlname) elif "Reset student's attempts" in action: @@ -348,13 +345,7 @@ def instructor_dashboard(request, course_id): # save student_module.state = json.dumps(problem_state) student_module.save() - track_format = '{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}' - track_msg = track_format.format(old_attempts=old_number_of_attempts, - student=student, - problem=student_module.module_state_key, - instructor=request.user, - course=course_id) - track.views.server_track(request, track_msg, {}, page='idashboard') + track.views.server_track(request, "reset-student-attempts", {"old_attempts": old_number_of_attempts, "student": student, "problem": student_module.module_state_key, "instructor": request.user, "course": course_id}, page="idashboard") msg += "Module state successfully reset!" except: msg += "Couldn't reset module state. " @@ -365,8 +356,7 @@ def instructor_dashboard(request, course_id): if instructor_task is None: msg += 'Failed to create a background task for rescoring "{0}" for student {1}.'.format(module_state_key, unique_student_identifier) else: - track_msg = 'rescore problem {problem} for student {student} in {course}'.format(problem=module_state_key, student=unique_student_identifier, course=course_id) - track.views.server_track(request, track_msg, {}, page='idashboard') + track.views.server_track(request, "rescore-student-submission", {"problem": module_state_key, "student": unique_student_identifier, "course": course_id}, page="idashboard") except Exception as e: log.exception("Encountered exception from rescore: {0}") msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(module_state_key, e.message) @@ -378,13 +368,7 @@ def instructor_dashboard(request, course_id): msg += message if student is not None: progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': student.id}) - track.views.server_track(request, - '{instructor} requested progress page for {student} in {course}'.format( - student=student, - instructor=request.user, - course=course_id), - {}, - page='idashboard') + track.views.server_track(request, "get-student-progress-page", {"student": student, "instructor": request.user, "course": course_id}, page="idashboard") msg += " Progress page for username: {1} with email address: {2}.".format(progress_url, student.username, student.email) #---------------------------------------- @@ -453,7 +437,7 @@ def instructor_dashboard(request, course_id): group = get_staff_group(course) msg += 'Staff group = {0}'.format(group.name) datatable = _group_members_table(group, "List of Staff", course_id) - track.views.server_track(request, 'list-staff', {}, page='idashboard') + track.views.server_track(request, "list-staff", {}, page="idashboard") elif 'List course instructors' in action and request.user.is_staff: group = get_instructor_group(course) @@ -463,7 +447,7 @@ def instructor_dashboard(request, course_id): datatable = {'header': ['Username', 'Full name']} datatable['data'] = [[x.username, x.profile.name] for x in uset] datatable['title'] = 'List of Instructors in course {0}'.format(course_id) - track.views.server_track(request, 'list-instructors', {}, page='idashboard') + track.views.server_track(request, "list-instructors", {}, page="idashboard") elif action == 'Add course staff': uname = request.POST['staffuser'] @@ -482,7 +466,7 @@ def instructor_dashboard(request, course_id): msg += 'Added {0} to instructor group = {1}'.format(user, group.name) log.debug('staffgrp={0}'.format(group.name)) user.groups.add(group) - track.views.server_track(request, 'add-instructor {0}'.format(user), {}, page='idashboard') + track.views.server_track(request, "add-instructor", {"instructor": user}, page="idashboard") elif action == 'Remove course staff': uname = request.POST['staffuser'] @@ -501,7 +485,7 @@ def instructor_dashboard(request, course_id): msg += 'Removed {0} from instructor group = {1}'.format(user, group.name) log.debug('instructorgrp={0}'.format(group.name)) user.groups.remove(group) - track.views.server_track(request, 'remove-instructor {0}'.format(user), {}, page='idashboard') + track.views.server_track(request, "remove-instructor", {"instructor": user}, page="idashboard") #---------------------------------------- # DataDump @@ -550,7 +534,7 @@ def instructor_dashboard(request, course_id): group = get_beta_group(course) msg += 'Beta test group = {0}'.format(group.name) datatable = _group_members_table(group, "List of beta_testers", course_id) - track.views.server_track(request, 'list-beta-testers', {}, page='idashboard') + track.views.server_track(request, "list-beta-testers", {}, page="idashboard") elif action == 'Add beta testers': users = request.POST['betausers'] @@ -574,55 +558,49 @@ def instructor_dashboard(request, course_id): rolename = FORUM_ROLE_ADMINISTRATOR datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) - track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') + track.views.server_track(request, "list-forum-admins", {"course": course_id}, page="idashboard") elif action == 'Remove forum admin': uname = request.POST['forumadmin'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_ADMINISTRATOR, course_id), - {}, page='idashboard') + track.views.server_track(request, "remove-forum-admin", {"username": uname, "course": course_id}, page="idashboard") elif action == 'Add forum admin': uname = request.POST['forumadmin'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADD) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_ADMINISTRATOR, course_id), - {}, page='idashboard') + track.views.server_track(request, "add-forum-admin", {"username": uname, "course": course_id}, page="idashboard") elif action == 'List course forum moderators': rolename = FORUM_ROLE_MODERATOR datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) - track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') + track.views.server_track(request, "list-forum-mods", {"course": course_id}, page="idashboard") elif action == 'Remove forum moderator': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_REMOVE) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_MODERATOR, course_id), - {}, page='idashboard') + track.views.server_track(request, "remove-forum-mod", {"username": uname, "course": course_id}, page="idashboard") elif action == 'Add forum moderator': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_ADD) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_MODERATOR, course_id), - {}, page='idashboard') + track.views.server_track(request, "add-forum-mod", {"username": uname, "course": course_id}, page="idashboard") elif action == 'List course forum community TAs': rolename = FORUM_ROLE_COMMUNITY_TA datatable = {} msg += _list_course_forum_members(course_id, rolename, datatable) - track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') + track.views.server_track(request, "list-forum-community-TAs", {"course": course_id}, page="idashboard") elif action == 'Remove forum community TA': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_REMOVE) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_COMMUNITY_TA, course_id), - {}, page='idashboard') + track.views.server_track(request, "remove-forum-community-TA", {"username": uname, "course": course_id}, page="idashboard") elif action == 'Add forum community TA': uname = request.POST['forummoderator'] msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADD) - track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_COMMUNITY_TA, course_id), - {}, page='idashboard') + track.views.server_track(request, "add-forum-community-TA", {"username": uname, "course": course_id}, page="idashboard") #---------------------------------------- # enrollment @@ -674,7 +652,7 @@ def instructor_dashboard(request, course_id): problem = request.POST['Problem'] nmsg, plots = psychoanalyze.generate_plots_for_problem(problem) msg += nmsg - track.views.server_track(request, 'psychometrics {0}'.format(problem), {}, page='idashboard') + track.views.server_track(request, "psychometrics-histogram-generation", {"problem": problem}, page="idashboard") if idash_mode == 'Psychometrics': problems = psychoanalyze.problems_with_psychometric_data(course_id) @@ -927,8 +905,7 @@ def _add_or_remove_user_group(request, username_or_email, group, group_title, ev else: user.groups.remove(group) event = "add" if do_add else "remove" - track.views.server_track(request, '{event}-{0} {1}'.format(event_name, user, event=event), - {}, page='idashboard') + track.views.server_track(request, "add-or-remove-user-group", {"event_name": event_name, "user": user, "event": event}, page="idashboard") return msg From a484224ef5b9efe6fe37071b775a8138f0410600 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 27 Jun 2013 16:27:39 -0400 Subject: [PATCH 099/161] Remove pointless addition tests --- common/djangoapps/track/tests.py | 16 ---------------- lms/djangoapps/certificates/tests.py | 16 ---------------- lms/djangoapps/circuit/tests.py | 16 ---------------- lms/djangoapps/dashboard/tests.py | 16 ---------------- lms/djangoapps/staticbook/tests.py | 16 ---------------- lms/lib/perfstats/tests.py | 16 ---------------- 6 files changed, 96 deletions(-) delete mode 100644 common/djangoapps/track/tests.py delete mode 100644 lms/djangoapps/certificates/tests.py delete mode 100644 lms/djangoapps/circuit/tests.py delete mode 100644 lms/djangoapps/dashboard/tests.py delete mode 100644 lms/djangoapps/staticbook/tests.py delete mode 100644 lms/lib/perfstats/tests.py diff --git a/common/djangoapps/track/tests.py b/common/djangoapps/track/tests.py deleted file mode 100644 index 501deb776c..0000000000 --- a/common/djangoapps/track/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/lms/djangoapps/certificates/tests.py b/lms/djangoapps/certificates/tests.py deleted file mode 100644 index 501deb776c..0000000000 --- a/lms/djangoapps/certificates/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/lms/djangoapps/circuit/tests.py b/lms/djangoapps/circuit/tests.py deleted file mode 100644 index 501deb776c..0000000000 --- a/lms/djangoapps/circuit/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/lms/djangoapps/dashboard/tests.py b/lms/djangoapps/dashboard/tests.py deleted file mode 100644 index 501deb776c..0000000000 --- a/lms/djangoapps/dashboard/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/lms/djangoapps/staticbook/tests.py b/lms/djangoapps/staticbook/tests.py deleted file mode 100644 index 501deb776c..0000000000 --- a/lms/djangoapps/staticbook/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/lms/lib/perfstats/tests.py b/lms/lib/perfstats/tests.py deleted file mode 100644 index 501deb776c..0000000000 --- a/lms/lib/perfstats/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - -from django.test import TestCase - - -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) From 1a5bda4d08d76c80194b7143e174f2f042944544 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Thu, 27 Jun 2013 16:35:01 -0400 Subject: [PATCH 100/161] Broke up long line into smaller line --- cms/djangoapps/contentstore/features/component.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index b3775a6cbe..64f088f056 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -8,7 +8,8 @@ DATA_LOCATION = 'i4x://edx/templates' @step(u'I am editing a new unit') def add_unit(step): - css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] + css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', + 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] for selector in css_selectors: world.css_click(selector) From 3e0f933a73830e39f4499c25e184153150a18d72 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Thu, 27 Jun 2013 15:09:59 -0400 Subject: [PATCH 101/161] Formatted a docstring. --- lms/djangoapps/courseware/tests/test_navigation.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index 282f6383fc..dd1f00711c 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -11,13 +11,12 @@ from modulestore_config import TEST_DATA_MONGO_MODULESTORE @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): - - STUDENT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] - """ Check that navigation state is saved properly. """ + STUDENT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] + def setUp(self): self.test_course = CourseFactory.create(display_name='Robot_Sub_Course') From 7e6722c50ffa6fb3c7828f6643ff046942d80cfc Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 26 Jun 2013 17:14:12 -0400 Subject: [PATCH 102/161] Added clean reports dependency to rake JavaScript test tasks. This ensures that `rake test` will clean the report directories *before* running the JS tests. --- rakelib/jasmine.rake | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/rakelib/jasmine.rake b/rakelib/jasmine.rake index 0f532fdf6f..ff72161937 100644 --- a/rakelib/jasmine.rake +++ b/rakelib/jasmine.rake @@ -80,7 +80,7 @@ end namespace :jasmine do namespace system do desc "Open jasmine tests for #{system} in your default browser" - task :browser do + task :browser => [:clean_reports_dir] do Rake::Task[:assets].invoke(system, 'jasmine') django_for_jasmine(system, true) do |jasmine_url| jasmine_browser(jasmine_url) @@ -88,7 +88,7 @@ end end desc "Open jasmine tests for #{system} in your default browser, and dynamically recompile coffeescript" - task :'browser:watch' => :'assets:coffee:_watch' do + task :'browser:watch' => [:clean_reports_dir, :'assets:coffee:_watch'] do django_for_jasmine(system, true) do |jasmine_url| jasmine_browser(jasmine_url, jitter=0, wait=0) end @@ -97,7 +97,7 @@ end end desc "Use phantomjs to run jasmine tests for #{system} from the console" - task :phantomjs do + task :phantomjs => [:clean_reports_dir] do Rake::Task[:assets].invoke(system, 'jasmine') phantomjs = ENV['PHANTOMJS_PATH'] || 'phantomjs' django_for_jasmine(system, false) do |jasmine_url| @@ -122,7 +122,7 @@ static_js_dirs.each do |dir| namespace :jasmine do namespace dir do desc "Open jasmine tests for #{dir} in your default browser" - task :browser do + task :browser => [:clean_reports_dir] do # We need to use either CMS or LMS to preprocess files. Use LMS by default Rake::Task['assets:coffee'].invoke('lms', 'jasmine') template_jasmine_runner(dir) do |f| @@ -131,7 +131,7 @@ static_js_dirs.each do |dir| end desc "Use phantomjs to run jasmine tests for #{dir} from the console" - task :phantomjs do + task :phantomjs => [:clean_reports_dir] do # We need to use either CMS or LMS to preprocess files. Use LMS by default Rake::Task[:assets].invoke('lms', 'jasmine') template_jasmine_runner(dir) do |f| From af05d05cbe289ba2e596901a2cd3bd8abe66fd64 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 27 Jun 2013 10:51:23 -0400 Subject: [PATCH 103/161] Used dependency to ensure that REPORT_DIR exists before cleaning. This allows the build to pass in Jenkins --- rakelib/tests.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 0ca7c5c1e9..2bbe3a6ad8 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -53,7 +53,7 @@ task :clean_test_files do sh("git clean -fqdx test_root") end -task :clean_reports_dir do +task :clean_reports_dir => REPORT_DIR do desc "Clean coverage files, to ensure that we don't use stale data to generate reports." # We delete the files but preserve the directory structure From c6fa4873b13c10be651f5e3818ac4e43cdec4e1b Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 27 Jun 2013 14:48:49 -0400 Subject: [PATCH 104/161] Handle issues decoding requests more gracefully Clean up some pep8/pylint violations as well --- .../tests/test_openid_provider.py | 99 ++++++++++++------- common/djangoapps/external_auth/views.py | 35 +++---- 2 files changed, 82 insertions(+), 52 deletions(-) diff --git a/common/djangoapps/external_auth/tests/test_openid_provider.py b/common/djangoapps/external_auth/tests/test_openid_provider.py index 570dfbf9ee..1f093c93be 100644 --- a/common/djangoapps/external_auth/tests/test_openid_provider.py +++ b/common/djangoapps/external_auth/tests/test_openid_provider.py @@ -59,18 +59,15 @@ class MyFetcher(HTTPFetcher): final_url=final_url, headers=response_headers, status=status, - ) + ) class OpenIdProviderTest(TestCase): + """ + Tests of the OpenId login + """ -# def setUp(self): -# username = 'viewtest' -# email = 'view@test.com' -# password = 'foo' -# user = User.objects.create_user(username, email, password) - - def testBeginLoginWithXrdsUrl(self): + def test_begin_login_with_xrds_url(self): # skip the test if openid is not enabled (as in cms.envs.test): if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): return @@ -99,7 +96,7 @@ class OpenIdProviderTest(TestCase): "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) - def testBeginLoginWithLoginUrl(self): + def test_begin_login_with_login_url(self): # skip the test if openid is not enabled (as in cms.envs.test): if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): return @@ -150,45 +147,77 @@ class OpenIdProviderTest(TestCase): # # - - def testOpenIdSetup(self): + def test_open_id_setup(self): if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): return url = reverse('openid-provider-login') post_args = { - "openid.mode": "checkid_setup", - "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", - "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", - "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns": "http://specs.openid.net/auth/2.0", - "openid.realm": "http://testserver/", - "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns.ax": "http://openid.net/srv/ax/1.0", - "openid.ax.mode": "fetch_request", - "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", - "openid.ax.type.fullname": "http://axschema.org/namePerson", - "openid.ax.type.lastname": "http://axschema.org/namePerson/last", - "openid.ax.type.firstname": "http://axschema.org/namePerson/first", - "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", - "openid.ax.type.email": "http://axschema.org/contact/email", - "openid.ax.type.old_email": "http://schema.openid.net/contact/email", - "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", - "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", - } + "openid.mode": "checkid_setup", + "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", + "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", + "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.realm": "http://testserver/", + "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns.ax": "http://openid.net/srv/ax/1.0", + "openid.ax.mode": "fetch_request", + "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", + "openid.ax.type.fullname": "http://axschema.org/namePerson", + "openid.ax.type.lastname": "http://axschema.org/namePerson/last", + "openid.ax.type.firstname": "http://axschema.org/namePerson/first", + "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", + "openid.ax.type.email": "http://axschema.org/contact/email", + "openid.ax.type.old_email": "http://schema.openid.net/contact/email", + "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", + "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", + } resp = self.client.post(url, post_args) code = 200 self.assertEqual(resp.status_code, code, "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) + def test_invalid_namespace(self): + """ Test for 403 error code when the namespace of the request is invalid""" + if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): + return + url = reverse('openid-provider-login') + post_args = { + "openid.mode": "checkid_setup", + "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", + "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", + "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns": "http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0", + "openid.realm": "http://testserver/", + "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns.ax": "http://openid.net/srv/ax/1.0", + "openid.ax.mode": "fetch_request", + "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", + "openid.ax.type.fullname": "http://axschema.org/namePerson", + "openid.ax.type.lastname": "http://axschema.org/namePerson/last", + "openid.ax.type.firstname": "http://axschema.org/namePerson/first", + "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", + "openid.ax.type.email": "http://axschema.org/contact/email", + "openid.ax.type.old_email": "http://schema.openid.net/contact/email", + "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", + "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", + } + resp = self.client.post(url, post_args) + code = 403 + self.assertEqual(resp.status_code, code, + "got code {0} for url '{1}'. Expected code {2}" + .format(resp.status_code, url, code)) + -# In order for this absolute URL to work (i.e. to get xrds, then authentication) -# in the test environment, we either need a live server that works with the default -# fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. -# Here we do the former. class OpenIdProviderLiveServerTest(LiveServerTestCase): + """ + In order for this absolute URL to work (i.e. to get xrds, then authentication) + in the test environment, we either need a live server that works with the default + fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. + Here we do the former. + """ - def testBeginLogin(self): + def test_begin_login(self): # skip the test if openid is not enabled (as in cms.envs.test): if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): return diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 06709eff9e..50deb5c449 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -36,7 +36,7 @@ import django_openid_auth.views as openid_views from django_openid_auth import auth as openid_auth from openid.consumer.consumer import SUCCESS -from openid.server.server import Server +from openid.server.server import Server, ProtocolError from openid.server.trustroot import TrustRoot from openid.extensions import ax, sreg @@ -102,7 +102,7 @@ def openid_login_complete(request, oid_backend = openid_auth.OpenIDBackend() details = oid_backend._extract_user_details(openid_response) - log.debug('openid success, details=%s' % details) + log.debug('openid success, details={0}'.format(details)) url = getattr(settings, 'OPENID_SSO_SERVER_URL', None) external_domain = "openid:%s" % url @@ -132,7 +132,7 @@ def external_login_or_signup(request, try: eamap = ExternalAuthMap.objects.get(external_id=external_id, external_domain=external_domain) - log.debug('Found eamap=%s' % eamap) + log.debug('Found eamap={0}'.format(eamap)) except ExternalAuthMap.DoesNotExist: # go render form for creating edX user eamap = ExternalAuthMap(external_id=external_id, @@ -141,11 +141,11 @@ def external_login_or_signup(request, eamap.external_email = email eamap.external_name = fullname eamap.internal_password = generate_password() - log.debug('Created eamap=%s' % eamap) + log.debug('Created eamap={0}'.format(eamap)) eamap.save() - log.info("External_Auth login_or_signup for %s : %s : %s : %s" % (external_domain, external_id, email, fullname)) + log.info(u"External_Auth login_or_signup for {0} : {1} : {2} : {3}".format(external_domain, external_id, email, fullname)) internal_user = eamap.user if internal_user is None: if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): @@ -157,7 +157,7 @@ def external_login_or_signup(request, eamap.user = link_user eamap.save() internal_user = link_user - log.info('SHIB: Linking existing account for %s' % eamap.external_email) + log.info('SHIB: Linking existing account for {0}'.format(eamap.external_email)) # now pass through to log in else: # otherwise, there must have been an error, b/c we've already linked a user with these external @@ -168,10 +168,10 @@ def external_login_or_signup(request, % getattr(settings, 'TECH_SUPPORT_EMAIL', 'techsupport@class.stanford.edu'))) return default_render_failure(request, failure_msg) except User.DoesNotExist: - log.info('SHIB: No user for %s yet, doing signup' % eamap.external_email) + log.info('SHIB: No user for {0} yet, doing signup'.format(eamap.external_email)) return signup(request, eamap) else: - log.info('No user for %s yet, doing signup' % eamap.external_email) + log.info('No user for {0} yet.formatdoing signup'.format(eamap.external_email)) return signup(request, eamap) # We trust shib's authentication, so no need to authenticate using the password again @@ -183,17 +183,17 @@ def external_login_or_signup(request, else: auth_backend = 'django.contrib.auth.backends.ModelBackend' user.backend = auth_backend - log.info('SHIB: Logging in linked user %s' % user.email) + log.info('SHIB: Logging in linked user {0}'.format(user.email)) else: uname = internal_user.username user = authenticate(username=uname, password=eamap.internal_password) if user is None: - log.warning("External Auth Login failed for %s / %s" % - (uname, eamap.internal_password)) + log.warning("External Auth Login failed for {0} / {1}".format( + uname, eamap.internal_password)) return signup(request, eamap) if not user.is_active: - log.warning("User %s is not active" % (uname)) + log.warning("User {0} is not active".format(uname)) # TODO: improve error page msg = 'Account not yet activated: please look for link in your email' return default_render_failure(request, msg) @@ -261,7 +261,7 @@ def signup(request, eamap=None): except ValidationError: context['ask_for_email'] = True - log.info('EXTAUTH: Doing signup for %s' % eamap.external_id) + log.info('EXTAUTH: Doing signup for {0}'.format(eamap.external_id)) return student_views.register_user(request, extra_context=context) @@ -405,7 +405,7 @@ def shib_login(request): shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8') shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8') - log.info("SHIB creds returned: %r" % shib) + log.info("SHIB creds returned: {0}".format(shib)) return external_login_or_signup(request, external_id=shib['REMOTE_USER'], @@ -640,7 +640,10 @@ def provider_login(request): error = False if 'openid.mode' in request.GET or 'openid.mode' in request.POST: # decode request - openid_request = server.decodeRequest(querydict) + try: + openid_request = server.decodeRequest(querydict) + except ProtocolError: + return default_render_failure(request, "Invalid OpenID request") if not openid_request: return default_render_failure(request, "Invalid OpenID request") @@ -720,8 +723,6 @@ def provider_login(request): if 'openid_error' in request.session: del request.session['openid_error'] - # fullname field comes from user profile - profile = UserProfile.objects.get(user=user) log.info("OpenID login success - {0} ({1})".format(user.username, user.email)) From c98651fadba6ffb8e9e4c3132920e86cf9b92fd1 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 27 Jun 2013 16:12:21 -0400 Subject: [PATCH 105/161] Add in the ability to handle malformed return urls. --- .../tests/test_openid_provider.py | 33 +++++++++++++++++++ common/djangoapps/external_auth/views.py | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/external_auth/tests/test_openid_provider.py b/common/djangoapps/external_auth/tests/test_openid_provider.py index 1f093c93be..f62a6d3f14 100644 --- a/common/djangoapps/external_auth/tests/test_openid_provider.py +++ b/common/djangoapps/external_auth/tests/test_openid_provider.py @@ -9,6 +9,7 @@ from urlparse import parse_qs from django.conf import settings from django.test import TestCase, LiveServerTestCase +from django.test.utils import override_settings # from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.test.client import RequestFactory @@ -208,6 +209,38 @@ class OpenIdProviderTest(TestCase): "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) + @override_settings(OPENID_PROVIDER_TRUSTED_ROOTS=['http://apps.cs50.edx.org']) + def test_invalid_return_url(self): + """ Test for 403 error code when the url""" + if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): + return + url = reverse('openid-provider-login') + post_args = { + "openid.mode": "checkid_setup", + "openid.return_to": "http://apps.cs50.edx.or", + "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", + "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.realm": "http://testserver/", + "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns.ax": "http://openid.net/srv/ax/1.0", + "openid.ax.mode": "fetch_request", + "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", + "openid.ax.type.fullname": "http://axschema.org/namePerson", + "openid.ax.type.lastname": "http://axschema.org/namePerson/last", + "openid.ax.type.firstname": "http://axschema.org/namePerson/first", + "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", + "openid.ax.type.email": "http://axschema.org/contact/email", + "openid.ax.type.old_email": "http://schema.openid.net/contact/email", + "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", + "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", + } + resp = self.client.post(url, post_args) + code = 403 + self.assertEqual(resp.status_code, code, + "got code {0} for url '{1}'. Expected code {2}" + .format(resp.status_code, url, code)) + class OpenIdProviderLiveServerTest(LiveServerTestCase): """ diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 50deb5c449..34d65073f7 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -36,7 +36,7 @@ import django_openid_auth.views as openid_views from django_openid_auth import auth as openid_auth from openid.consumer.consumer import SUCCESS -from openid.server.server import Server, ProtocolError +from openid.server.server import Server, ProtocolError, UntrustedReturnURL from openid.server.trustroot import TrustRoot from openid.extensions import ax, sreg @@ -642,7 +642,7 @@ def provider_login(request): # decode request try: openid_request = server.decodeRequest(querydict) - except ProtocolError: + except (UntrustedReturnURL, ProtocolError): return default_render_failure(request, "Invalid OpenID request") if not openid_request: From 97d7421432702611768046d5f165da74dfdb58eb Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Fri, 28 Jun 2013 11:32:35 +0300 Subject: [PATCH 106/161] Fix for bug Blades/BLD-152. Added test for bugfix. Optimized bug fix. --- .../lib/xmodule/xmodule/videoalpha_module.py | 5 ++- .../courseware/tests/test_videoalpha_mongo.py | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index 6b27bcda2b..3b5b90e674 100644 --- a/common/lib/xmodule/xmodule/videoalpha_module.py +++ b/common/lib/xmodule/xmodule/videoalpha_module.py @@ -70,7 +70,10 @@ class VideoAlphaModule(VideoAlphaFields, XModule): def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) xmltree = etree.fromstring(self.data) - self.youtube_streams = xmltree.get('youtube') + + # Front-end expects an empty string, or a properly formatted string with YouTube IDs. + self.youtube_streams = xmltree.get('youtube', '') + self.sub = xmltree.get('sub') self.position = 0 self.show_captions = xmltree.get('show_captions', 'true') diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py index a6bff60acf..182cbab9e7 100644 --- a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py +++ b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py @@ -52,3 +52,47 @@ class TestVideo(BaseTestXmodule): 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) } self.assertDictEqual(context, expected_context) + + +class TestVideoNonYouTube(TestVideo): + """Integration tests: web client + mongo.""" + + DATA = """ + + + + + + """ + MODEL_DATA = { + 'data': DATA + } + + def test_videoalpha_constructor(self): + """Make sure that if the 'youtube' attribute is omitted in XML, then + the template generates an empty string for the YouTube streams. + """ + + # `get_html` return only context, cause we + # overwrite `system.render_template` + context = self.item_module.get_html() + expected_context = { + 'data_dir': getattr(self, 'data_dir', None), + 'caption_asset_path': '/c4x/MITx/999/asset/subs_', + 'show_captions': self.item_module.show_captions, + 'display_name': self.item_module.display_name_with_default, + 'end': self.item_module.end_time, + 'id': self.item_module.location.html_id(), + 'sources': self.item_module.sources, + 'start': self.item_module.start_time, + 'sub': self.item_module.sub, + 'track': self.item_module.track, + 'youtube_streams': '', + 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) + } + self.assertDictEqual(context, expected_context) From d53114abda2788bcd797f53990f997b80c2f831f Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Fri, 28 Jun 2013 11:46:20 -0400 Subject: [PATCH 107/161] Shorten line length --- lms/djangoapps/instructor/views.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 60c4f2143d..9f9b7a2399 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -331,7 +331,8 @@ def instructor_dashboard(request, course_id): try: student_module.delete() msg += "Deleted student module state for %s!" % module_state_key - track.views.server_track(request, "delete-student-module-state", {"problem": problem_url, "student": unique_student_identifier, "course": course_id}, page="idashboard") + event = {"problem": problem_url, "student": unique_student_identifier, "course": course_id} + track.views.server_track(request, "delete-student-module-state", event, page="idashboard") except: msg += "Failed to delete module state for %s/%s" % (unique_student_identifier, problem_urlname) elif "Reset student's attempts" in action: @@ -345,7 +346,12 @@ def instructor_dashboard(request, course_id): # save student_module.state = json.dumps(problem_state) student_module.save() - track.views.server_track(request, "reset-student-attempts", {"old_attempts": old_number_of_attempts, "student": student, "problem": student_module.module_state_key, "instructor": request.user, "course": course_id}, page="idashboard") + event = {"old_attempts": old_number_of_attempts, + "student": student, + "problem": student_module.module_state_key, + "instructor": request.user, + "course": course_id} + track.views.server_track(request, "reset-student-attempts", event, page="idashboard") msg += "Module state successfully reset!" except: msg += "Couldn't reset module state. " From 1be2068bcf4c7365fb29f6d7c0c42d1a78ea4110 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 28 Jun 2013 11:37:37 -0400 Subject: [PATCH 108/161] Show a confirmation on asset delete. Fixes STUD-375. --- cms/djangoapps/contentstore/features/upload.feature | 1 + cms/djangoapps/contentstore/features/upload.py | 6 ++++++ cms/static/js/views/assets.js | 6 +++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature index b3c1fc2ce3..8d40163685 100644 --- a/cms/djangoapps/contentstore/features/upload.feature +++ b/cms/djangoapps/contentstore/features/upload.feature @@ -21,6 +21,7 @@ Feature: Upload Files When I upload the file "test" And I delete the file "test" Then I should not see the file "test" was uploaded + And I see a confirmation that the file was deleted Scenario: Users can download files Given I have opened a new course in studio diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index 258fc5ebcf..bc06c97f9c 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -90,6 +90,12 @@ def modify_upload(_step, file_name): cur_file.write(new_text) +@step('I see a confirmation that the file was deleted') +def i_see_a_delete_confirmation(step): + alert_css = '#alert-confirmation' + assert world.is_css_present(alert_css) + + def get_index(file_name): names_css = 'td.name-col > a.filename' all_names = world.css_find(names_css) diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index 18ef131f52..e9ab2cb911 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -23,7 +23,11 @@ function removeAsset(e){ { 'location': row.data('id') }, function() { // show the post-commit confirmation - $(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false'); + var deleted = new CMS.Views.Alert.Confirmation({ + title: gettext("Your file has been deleted."), + closeIcon: false + }); + deleted.show(); row.remove(); analytics.track('Deleted Asset', { 'course': course_location_analytics, From 26aa085d9d7a724586d51b74b089043f6861ece3 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:58:57 -0400 Subject: [PATCH 109/161] Handle edge case when trying to aggregate progress in an empty sequence --- common/lib/xmodule/xmodule/seq_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 088967ebc0..580475e1ae 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -59,7 +59,7 @@ class SequenceModule(SequenceFields, XModule): # TODO: Cache progress or children array? children = self.get_children() progresses = [child.get_progress() for child in children] - progress = reduce(Progress.add_counts, progresses) + progress = reduce(Progress.add_counts, progresses, None) return progress def handle_ajax(self, dispatch, data): # TODO: bounds checking From 97c2e9ec802f742c48874a1baf433fa3fbecd0ba Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 16:39:49 -0400 Subject: [PATCH 110/161] Make url matching regex flexible to allow for browsers that munged :// into :/ --- .../xmodule/xmodule/modulestore/__init__.py | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 87e5bc04c1..2fa12e2e90 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -16,16 +16,7 @@ log = logging.getLogger('mitx.' + 'modulestore') URL_RE = re.compile(""" - (?P[^:]+):// - (?P[^/]+)/ - (?P[^/]+)/ - (?P[^/]+)/ - (?P[^@]+) - (@(?P[^/]+))? - """, re.VERBOSE) - -MISSING_SLASH_URL_RE = re.compile(""" - (?P[^:]+):/ + (?P[^:]+)://? (?P[^/]+)/ (?P[^/]+)/ (?P[^/]+)/ @@ -180,13 +171,8 @@ class Location(_LocationBase): if isinstance(location, basestring): match = URL_RE.match(location) if match is None: - # cdodge: - # check for a dropped slash near the i4x:// element of the location string. This can happen with some - # redirects (e.g. edx.org -> www.edx.org which I think happens in Nginx) - match = MISSING_SLASH_URL_RE.match(location) - if match is None: - log.debug('location is instance of %s but no URL match' % basestring) - raise InvalidLocationError(location) + log.debug('location is instance of %s but no URL match' % basestring) + raise InvalidLocationError(location) groups = match.groupdict() check_dict(groups) return _LocationBase.__new__(_cls, **groups) From e9bd1c56025e380444ba1e92f6631f59dd01a10a Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:24:45 -0400 Subject: [PATCH 111/161] Fix SessionKeyValueStore.has to use the correct indexing value when looking up data --- cms/djangoapps/contentstore/views/session_kv_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py index 54ab25ff54..87a92a9e2e 100644 --- a/cms/djangoapps/contentstore/views/session_kv_store.py +++ b/cms/djangoapps/contentstore/views/session_kv_store.py @@ -25,4 +25,4 @@ class SessionKeyValueStore(KeyValueStore): del self._session[tuple(key)] def has(self, key): - return key in self._descriptor_model_data or key in self._session + return key.field_name in self._descriptor_model_data or tuple(key) in self._session From 115b214df149ce7e724da65f675548716f201ac9 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 12:00:19 -0400 Subject: [PATCH 112/161] Retrieve location from self in CustomTagDescriptor --- common/lib/xmodule/xmodule/template_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index bf8f616913..c28378210b 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -49,7 +49,7 @@ class CustomTagDescriptor(RawDescriptor): else: # TODO (vshnayder): better exception type raise Exception("Could not find impl attribute in customtag {0}" - .format(location)) + .format(self.location)) params = dict(xmltree.items()) From c7046df0f24aadbeb8e491a9b61c6cc4e49e59c6 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:29:41 -0400 Subject: [PATCH 113/161] Make request.POST be only json content when using expect_json --- common/djangoapps/util/json_request.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/djangoapps/util/json_request.py b/common/djangoapps/util/json_request.py index 840a8282f9..a9a0c39278 100644 --- a/common/djangoapps/util/json_request.py +++ b/common/djangoapps/util/json_request.py @@ -15,8 +15,7 @@ def expect_json(view_function): # e.g. 'charset', so we can't do a direct string compare if request.META.get('CONTENT_TYPE', '').lower().startswith("application/json"): cloned_request = copy.copy(request) - cloned_request.POST = cloned_request.POST.copy() - cloned_request.POST.update(json.loads(request.body)) + cloned_request.POST = json.loads(request.body) return view_function(cloned_request, *args, **kwargs) else: return view_function(request, *args, **kwargs) From 2ef4d82959b8339092bf03b987d1330b53f449d6 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:22:16 -0400 Subject: [PATCH 114/161] Standardize on get_default_time_display --- cms/djangoapps/contentstore/views/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 039deb2740..007467f8a6 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -220,7 +220,7 @@ def edit_unit(request, location): 'section': containing_section, 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), 'unit_state': unit_state, - 'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None, + 'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None }) From 1c5706fb1fa02b31b63567fb6c74c4d32c7b0ad3 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 12:33:32 -0400 Subject: [PATCH 115/161] Add JSON encoder for datetimes to xml export During policy export, we want to write any python datetime objects found as iso timestamps, rather than throwing exceptions. --- .../xmodule/modulestore/xml_exporter.py | 21 ++++++- .../lib/xmodule/xmodule/tests/test_export.py | 61 +++++++++++++++++++ common/lib/xmodule/xmodule/xml_module.py | 3 +- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index 9fceb51c51..27d79a68b5 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -3,7 +3,24 @@ from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata from fs.osfs import OSFS from json import dumps +import json +from json.encoder import JSONEncoder +import datetime +class EdxJSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Location): + return obj.url() + elif isinstance(obj, datetime.datetime): + if obj.tzinfo is not None: + if obj.utcoffset() is None: + return obj.isoformat() + 'Z' + else: + return obj.isoformat() + else: + return obj.isoformat() + else: + return super(EdxJSONEncoder, self).default(obj) def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir, draft_modulestore=None): @@ -35,12 +52,12 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d policies_dir = export_fs.makeopendir('policies') course_run_policy_dir = policies_dir.makeopendir(course.location.name) with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy: - grading_policy.write(dumps(course.grading_policy)) + grading_policy.write(dumps(course.grading_policy, cls=EdxJSONEncoder)) # export all of the course metadata in policy.json with course_run_policy_dir.open('policy.json', 'w') as course_policy: policy = {'course/' + course.location.name: own_metadata(course)} - course_policy.write(dumps(policy)) + course_policy.write(dumps(policy, cls=EdxJSONEncoder)) # export draft content # NOTE: this code assumes that verticals are the top most draftable container diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index a001339311..d33a41c035 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -1,11 +1,17 @@ import unittest +import pytz +from datetime import datetime, timedelta, tzinfo from fs.osfs import OSFS +from mock import Mock from path import path from tempfile import mkdtemp import shutil from xmodule.modulestore.xml import XMLModuleStore +from xmodule.modulestore.xml_exporter import EdxJSONEncoder + +from xmodule.modulestore import Location # from ~/mitx_all/mitx/common/lib/xmodule/xmodule/tests/ # to ~/mitx_all/mitx/common/test @@ -127,3 +133,58 @@ class RoundTripTestCase(unittest.TestCase): def test_word_cloud_roundtrip(self): self.check_export_roundtrip(DATA_DIR, "word_cloud") + + +class TestEdxJsonEncoder(unittest.TestCase): + def setUp(self): + self.encoder = EdxJSONEncoder() + + class OffsetTZ(tzinfo): + """A timezone with non-None utcoffset""" + def utcoffset(self, dt): + return timedelta(hours=4) + + self.offset_tz = OffsetTZ() + + class NullTZ(tzinfo): + """A timezone with None as its utcoffset""" + def utcoffset(self, dt): + return None + self.null_utc_tz = NullTZ() + + def test_encode_location(self): + loc = Location('i4x', 'org', 'course', 'category', 'name') + self.assertEqual(loc.url(), self.encoder.default(loc)) + + loc = Location('i4x', 'org', 'course', 'category', 'name', 'version') + self.assertEqual(loc.url(), self.encoder.default(loc)) + + def test_encode_naive_datetime(self): + self.assertEqual( + "2013-05-03T10:20:30.000100", + self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 100)) + ) + self.assertEqual( + "2013-05-03T10:20:30", + self.encoder.default(datetime(2013, 5, 3, 10, 20, 30)) + ) + + def test_encode_utc_datetime(self): + self.assertEqual( + "2013-05-03T10:20:30+00:00", + self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, pytz.UTC)) + ) + + self.assertEqual( + "2013-05-03T10:20:30+04:00", + self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, self.offset_tz)) + ) + + self.assertEqual( + "2013-05-03T10:20:30Z", + self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, self.null_utc_tz)) + ) + + def test_other_classes(self): + with self.assertRaises(TypeError): + self.encoder.default(None) diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 2a7a15d434..c1340a9fc0 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -10,6 +10,7 @@ from xblock.core import Dict, Scope from xmodule.x_module import (XModuleDescriptor, policy_key) from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata +from xmodule.modulestore.xml_exporter import EdxJSONEncoder log = logging.getLogger(__name__) @@ -84,7 +85,7 @@ def serialize_field(value): By default, this is the result of calling json.dumps on the input value. """ - return json.dumps(value) + return json.dumps(value, cls=EdxJSONEncoder) def deserialize_field(field, value): From b3cd6af9aa396f969dffb3471c1bd7994a706708 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 28 Jun 2013 14:34:05 -0400 Subject: [PATCH 116/161] Change deleted Alert to a Notification, with a timeout. --- cms/djangoapps/contentstore/features/upload.py | 2 +- cms/static/js/views/assets.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index bc06c97f9c..a298ff08b1 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -92,7 +92,7 @@ def modify_upload(_step, file_name): @step('I see a confirmation that the file was deleted') def i_see_a_delete_confirmation(step): - alert_css = '#alert-confirmation' + alert_css = '#notification-confirmation' assert world.is_css_present(alert_css) diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index e9ab2cb911..224ec928fb 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -23,9 +23,10 @@ function removeAsset(e){ { 'location': row.data('id') }, function() { // show the post-commit confirmation - var deleted = new CMS.Views.Alert.Confirmation({ + var deleted = new CMS.Views.Notification.Confirmation({ title: gettext("Your file has been deleted."), - closeIcon: false + closeIcon: false, + maxShown: 2000 }); deleted.show(); row.remove(); From f33bfd1c6c07210eed3cc1975f7e55c68c594a85 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 28 Jun 2013 14:41:06 -0400 Subject: [PATCH 117/161] Address code review feedback --- .../tests/test_openid_provider.py | 96 +++++-------------- common/djangoapps/external_auth/views.py | 47 +++++---- 2 files changed, 49 insertions(+), 94 deletions(-) diff --git a/common/djangoapps/external_auth/tests/test_openid_provider.py b/common/djangoapps/external_auth/tests/test_openid_provider.py index f62a6d3f14..1f7f201087 100644 --- a/common/djangoapps/external_auth/tests/test_openid_provider.py +++ b/common/djangoapps/external_auth/tests/test_openid_provider.py @@ -13,6 +13,7 @@ from django.test.utils import override_settings # from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.test.client import RequestFactory +from unittest import skipUnless class MyFetcher(HTTPFetcher): @@ -68,10 +69,9 @@ class OpenIdProviderTest(TestCase): Tests of the OpenId login """ + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) def test_begin_login_with_xrds_url(self): - # skip the test if openid is not enabled (as in cms.envs.test): - if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): - return # the provider URL must be converted to an absolute URL in order to be # used as an openid provider. @@ -97,10 +97,9 @@ class OpenIdProviderTest(TestCase): "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) def test_begin_login_with_login_url(self): - # skip the test if openid is not enabled (as in cms.envs.test): - if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): - return # the provider URL must be converted to an absolute URL in order to be # used as an openid provider. @@ -148,9 +147,8 @@ class OpenIdProviderTest(TestCase): # # - def test_open_id_setup(self): - if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): - return + def attempt_login(self, expected_code, **kwargs): + """ Attempt to log in through the open id provider login """ url = reverse('openid-provider-login') post_args = { "openid.mode": "checkid_setup", @@ -172,74 +170,34 @@ class OpenIdProviderTest(TestCase): "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", } + # override the default args with any given arguments + for key in kwargs: + post_args["openid." + key] = kwargs[key] + resp = self.client.post(url, post_args) - code = 200 + code = expected_code self.assertEqual(resp.status_code, code, "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) + def test_open_id_setup(self): + """ Attempt a standard successful login """ + self.attempt_login(200) + + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) def test_invalid_namespace(self): """ Test for 403 error code when the namespace of the request is invalid""" - if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): - return - url = reverse('openid-provider-login') - post_args = { - "openid.mode": "checkid_setup", - "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", - "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", - "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns": "http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0", - "openid.realm": "http://testserver/", - "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns.ax": "http://openid.net/srv/ax/1.0", - "openid.ax.mode": "fetch_request", - "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", - "openid.ax.type.fullname": "http://axschema.org/namePerson", - "openid.ax.type.lastname": "http://axschema.org/namePerson/last", - "openid.ax.type.firstname": "http://axschema.org/namePerson/first", - "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", - "openid.ax.type.email": "http://axschema.org/contact/email", - "openid.ax.type.old_email": "http://schema.openid.net/contact/email", - "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", - "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", - } - resp = self.client.post(url, post_args) - code = 403 - self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}" - .format(resp.status_code, url, code)) + self.attempt_login(403, ns="http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0") @override_settings(OPENID_PROVIDER_TRUSTED_ROOTS=['http://apps.cs50.edx.org']) + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) def test_invalid_return_url(self): """ Test for 403 error code when the url""" - if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): - return - url = reverse('openid-provider-login') - post_args = { - "openid.mode": "checkid_setup", - "openid.return_to": "http://apps.cs50.edx.or", - "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", - "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns": "http://specs.openid.net/auth/2.0", - "openid.realm": "http://testserver/", - "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns.ax": "http://openid.net/srv/ax/1.0", - "openid.ax.mode": "fetch_request", - "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", - "openid.ax.type.fullname": "http://axschema.org/namePerson", - "openid.ax.type.lastname": "http://axschema.org/namePerson/last", - "openid.ax.type.firstname": "http://axschema.org/namePerson/first", - "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", - "openid.ax.type.email": "http://axschema.org/contact/email", - "openid.ax.type.old_email": "http://schema.openid.net/contact/email", - "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", - "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", - } - resp = self.client.post(url, post_args) - code = 403 - self.assertEqual(resp.status_code, code, - "got code {0} for url '{1}'. Expected code {2}" - .format(resp.status_code, url, code)) + self.attempt_login(403, return_to="http://apps.cs50.edx.or") class OpenIdProviderLiveServerTest(LiveServerTestCase): @@ -250,11 +208,9 @@ class OpenIdProviderLiveServerTest(LiveServerTestCase): Here we do the former. """ + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) def test_begin_login(self): - # skip the test if openid is not enabled (as in cms.envs.test): - if not settings.MITX_FEATURES.get('AUTH_USE_OPENID') or not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): - return - # the provider URL must be converted to an absolute URL in order to be # used as an openid provider. provider_url = reverse('openid-provider-xrds') diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 34d65073f7..2a673acdf8 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -102,7 +102,7 @@ def openid_login_complete(request, oid_backend = openid_auth.OpenIDBackend() details = oid_backend._extract_user_details(openid_response) - log.debug('openid success, details={0}'.format(details)) + log.debug('openid success, details=%s', details) url = getattr(settings, 'OPENID_SSO_SERVER_URL', None) external_domain = "openid:%s" % url @@ -132,7 +132,7 @@ def external_login_or_signup(request, try: eamap = ExternalAuthMap.objects.get(external_id=external_id, external_domain=external_domain) - log.debug('Found eamap={0}'.format(eamap)) + log.debug('Found eamap=%s', eamap) except ExternalAuthMap.DoesNotExist: # go render form for creating edX user eamap = ExternalAuthMap(external_id=external_id, @@ -141,11 +141,11 @@ def external_login_or_signup(request, eamap.external_email = email eamap.external_name = fullname eamap.internal_password = generate_password() - log.debug('Created eamap={0}'.format(eamap)) + log.debug('Created eamap=%s', eamap) eamap.save() - log.info(u"External_Auth login_or_signup for {0} : {1} : {2} : {3}".format(external_domain, external_id, email, fullname)) + log.info(u"External_Auth login_or_signup for %s : %s : %s : %s", external_domain, external_id, email, fullname) internal_user = eamap.user if internal_user is None: if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): @@ -157,7 +157,7 @@ def external_login_or_signup(request, eamap.user = link_user eamap.save() internal_user = link_user - log.info('SHIB: Linking existing account for {0}'.format(eamap.external_email)) + log.info('SHIB: Linking existing account for %s', eamap.external_email) # now pass through to log in else: # otherwise, there must have been an error, b/c we've already linked a user with these external @@ -168,10 +168,10 @@ def external_login_or_signup(request, % getattr(settings, 'TECH_SUPPORT_EMAIL', 'techsupport@class.stanford.edu'))) return default_render_failure(request, failure_msg) except User.DoesNotExist: - log.info('SHIB: No user for {0} yet, doing signup'.format(eamap.external_email)) + log.info('SHIB: No user for %s yet, doing signup', eamap.external_email) return signup(request, eamap) else: - log.info('No user for {0} yet.formatdoing signup'.format(eamap.external_email)) + log.info('No user for %s yet. doing signup', eamap.external_email) return signup(request, eamap) # We trust shib's authentication, so no need to authenticate using the password again @@ -183,17 +183,17 @@ def external_login_or_signup(request, else: auth_backend = 'django.contrib.auth.backends.ModelBackend' user.backend = auth_backend - log.info('SHIB: Logging in linked user {0}'.format(user.email)) + log.info('SHIB: Logging in linked user %s', user.email) else: uname = internal_user.username user = authenticate(username=uname, password=eamap.internal_password) if user is None: - log.warning("External Auth Login failed for {0} / {1}".format( - uname, eamap.internal_password)) + log.warning("External Auth Login failed for %s / %s", + uname, eamap.internal_password) return signup(request, eamap) if not user.is_active: - log.warning("User {0} is not active".format(uname)) + log.warning("User %s is not active", uname) # TODO: improve error page msg = 'Account not yet activated: please look for link in your email' return default_render_failure(request, msg) @@ -208,7 +208,7 @@ def external_login_or_signup(request, student_views.try_change_enrollment(enroll_request) else: student_views.try_change_enrollment(request) - log.info("Login success - {0} ({1})".format(user.username, user.email)) + log.info("Login success - %s (%s)", user.username, user.email) if retfun is None: return redirect('/') return retfun() @@ -261,7 +261,7 @@ def signup(request, eamap=None): except ValidationError: context['ask_for_email'] = True - log.info('EXTAUTH: Doing signup for {0}'.format(eamap.external_id)) + log.info('EXTAUTH: Doing signup for %s', eamap.external_id) return student_views.register_user(request, extra_context=context) @@ -405,7 +405,7 @@ def shib_login(request): shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8') shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8') - log.info("SHIB creds returned: {0}".format(shib)) + log.info("SHIB creds returned: %r", shib) return external_login_or_signup(request, external_id=shib['REMOTE_USER'], @@ -643,7 +643,7 @@ def provider_login(request): try: openid_request = server.decodeRequest(querydict) except (UntrustedReturnURL, ProtocolError): - return default_render_failure(request, "Invalid OpenID request") + openid_request = None if not openid_request: return default_render_failure(request, "Invalid OpenID request") @@ -700,8 +700,8 @@ def provider_login(request): user = User.objects.get(email=email) except User.DoesNotExist: request.session['openid_error'] = True - msg = "OpenID login failed - Unknown user email: {0}".format(email) - log.warning(msg) + msg = "OpenID login failed - Unknown user email: %s" + log.warning(msg, email) return HttpResponseRedirect(openid_request_url) # attempt to authenticate user (but not actually log them in...) @@ -711,9 +711,8 @@ def provider_login(request): user = authenticate(username=username, password=password) if user is None: request.session['openid_error'] = True - msg = "OpenID login failed - password for {0} is invalid" - msg = msg.format(email) - log.warning(msg) + msg = "OpenID login failed - password for %s is invalid" + log.warning(msg, email) return HttpResponseRedirect(openid_request_url) # authentication succeeded, so fetch user information @@ -723,8 +722,8 @@ def provider_login(request): if 'openid_error' in request.session: del request.session['openid_error'] - log.info("OpenID login success - {0} ({1})".format(user.username, - user.email)) + log.info("OpenID login success - %s (%s)", + user.username, user.email) # redirect user to return_to location url = endpoint + urlquote(user.username) @@ -754,8 +753,8 @@ def provider_login(request): # the account is not active, so redirect back to the login page: request.session['openid_error'] = True - msg = "Login failed - Account not active for user {0}".format(username) - log.warning(msg) + msg = "Login failed - Account not active for user %s" + log.warning(msg, username) return HttpResponseRedirect(openid_request_url) # determine consumer domain if applicable From 09501a62e298cad8afea8f143b2f5639423941da Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Mon, 24 Jun 2013 18:02:04 -0400 Subject: [PATCH 118/161] Handle exception with a 404 --- lms/djangoapps/django_comment_client/forum/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index b04bd787d8..558542e804 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -369,7 +369,7 @@ def user_profile(request, course_id, user_id): } return render_to_response('discussion/user_profile.html', context) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist) as err: raise Http404 @@ -412,5 +412,5 @@ def followed_threads(request, course_id, user_id): } return render_to_response('discussion/user_profile.html', context) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError): + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist) as err: raise Http404 From b1424a75b8ae477cbee839a56f97578b15c77374 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 27 Jun 2013 13:03:51 -0400 Subject: [PATCH 119/161] Add a test for 404 raised when requesting the profile page of a user that does not exist --- .../django_comment_client/forum/tests.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 lms/djangoapps/django_comment_client/forum/tests.py diff --git a/lms/djangoapps/django_comment_client/forum/tests.py b/lms/djangoapps/django_comment_client/forum/tests.py new file mode 100644 index 0000000000..bd18ab80d6 --- /dev/null +++ b/lms/djangoapps/django_comment_client/forum/tests.py @@ -0,0 +1,82 @@ +from django.test.utils import override_settings +from django.test.client import Client +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from django.core.urlresolvers import reverse +from util.testing import UrlResetMixin + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from nose.tools import assert_true +from mock import patch, Mock + +import logging + +log = logging.getLogger(__name__) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because + # of the UrlResetMixin) + super(ViewsExceptionTestCase, self).setUp() + + # create a course + self.course = CourseFactory.create(org='MITx', course='999', + display_name='Robot Super Course') + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch('student.models.cc.User.save'): + uname = 'student' + email = 'student@edx.org' + password = 'test' + + # Create the student + self.student = UserFactory(username=uname, password=password, email=email) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + + # Log the student in + self.client = Client() + assert_true(self.client.login(username=uname, password=password)) + + @patch('student.models.cc.User.from_django_user') + @patch('student.models.cc.User.active_threads') + def test_user_profile_exception(self, mock_threads, mock_from_django_user): + + # Mock the code that makes the HTTP requests to the cs_comment_service app + # for the profiled user's active threads + mock_threads.return_value = [], 1, 1 + + # Mock the code that makes the HTTP request to the cs_comment_service app + # that gets the current user's info + mock_from_django_user.return_value = Mock() + + url = reverse('django_comment_client.forum.views.user_profile', + kwargs={'course_id': self.course.id, 'user_id': '12345'}) # There is no user 12345 + self.response = self.client.get(url) + self.assertEqual(self.response.status_code, 404) + + @patch('student.models.cc.User.from_django_user') + @patch('student.models.cc.User.active_threads') + def test_user_followed_threads_exception(self, mock_threads, mock_from_django_user): + + # Mock the code that makes the HTTP requests to the cs_comment_service app + # for the profiled user's active threads + mock_threads.return_value = [], 1, 1 + + # Mock the code that makes the HTTP request to the cs_comment_service app + # that gets the current user's info + mock_from_django_user.return_value = Mock() + + url = reverse('django_comment_client.forum.views.followed_threads', + kwargs={'course_id': self.course.id, 'user_id': '12345'}) # There is no user 12345 + self.response = self.client.get(url) + self.assertEqual(self.response.status_code, 404) From 7aa76bc66325902881a0f4e362f555a77aeaa7ab Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 28 Jun 2013 16:51:03 -0400 Subject: [PATCH 120/161] Add some documentation around XModule/XModuleDescriptor initialization. --- README.md | 6 ++++++ doc/overview.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 92a4116354..4dbf069da3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ otherwise noted. Please see ``LICENSE.txt`` for details. +Documentation +------------ + +High-level documentation of the code is located in the `doc` subdirectory. Start +with `overview.md` to get an introduction to the architecture of the system. + How to Contribute ----------------- diff --git a/doc/overview.md b/doc/overview.md index 31ddd011ff..c38c61b43e 100644 --- a/doc/overview.md +++ b/doc/overview.md @@ -64,6 +64,12 @@ You should be familiar with the following. If you're not, go read some docs... from a Location object, and the ModuleSystem knows how to render things, track events, and complain about 404s + - XModules and XModuleDescriptors are uniquely identified by a Location object, encoding the organization, course, category, name, and possibly revision of the module. + + - XModule initialization: XModules are instantiated by the `XModuleDescriptor.xmodule` method, and given a ModuleSystem, the descriptor which instantiated it, and their relevant model data. + + - XModuleDescriptor initialization: If an XModuleDescriptor is loaded from an XML-based course, the XML data is passed into its `from_xml` method, which is responsible for instantiating a descriptor with the correct attributes. If it's in Mongo, the descriptor is instantiated directly. The module's attributes will be present in the `model_data` dict. + - `course.xml` format. We use python setuptools to connect supported tags with the descriptors that handle them. See `common/lib/xmodule/setup.py`. There are checking and validation tools in `common/validate`. - the xml import+export functionality is in `xml_module.py:XmlDescriptor`, which is a mixin class that's used by the actual descriptor classes. From e5f97b372b1bdd5dcb561d22a6fbd9ae550bfe59 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Fri, 28 Jun 2013 18:00:41 -0400 Subject: [PATCH 121/161] pep8 fixes --- .../django_comment_client/forum/views.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 558542e804..24305a214a 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -114,7 +114,7 @@ def inline_discussion(request, course_id, discussion_id): threads, query_params = get_threads(request, course_id, discussion_id, per_page=INLINE_THREADS_PER_PAGE) cc_user = cc.User.from_django_user(request.user) user_info = cc_user.to_dict() - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError): # TODO (vshnayder): since none of this code seems to be aware of the fact that # sometimes things go wrong, I suspect that the js client is also not # checking for errors on request. Check and fix as needed. @@ -141,8 +141,8 @@ def inline_discussion(request, course_id, discussion_id): if is_moderator: cohorts = get_course_cohorts(course_id) - for c in cohorts: - cohorts_list.append({'name': c.name, 'id': c.id}) + for cohort in cohorts: + cohorts_list.append({'name': cohort.name, 'id': cohort.id}) else: #students don't get to choose @@ -174,11 +174,11 @@ def forum_form_discussion(request, course_id): try: unsafethreads, query_params = get_threads(request, course_id) # This might process a search query threads = [utils.safe_content(thread) for thread in unsafethreads] - except (cc.utils.CommentClientMaintenanceError) as err: + except cc.utils.CommentClientMaintenanceError: log.warning("Forum is in maintenance mode") return render_to_response('discussion/maintenance.html', {}) except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: - log.error("Error loading forum discussion threads: %s" % str(err)) + log.error("Error loading forum discussion threads: %s", str(err)) raise Http404 user = cc.User.from_django_user(request.user) @@ -244,7 +244,7 @@ def single_thread(request, course_id, discussion_id, thread_id): try: thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError): log.error("Error loading single thread.") raise Http404 @@ -269,7 +269,7 @@ def single_thread(request, course_id, discussion_id, thread_id): try: threads, query_params = get_threads(request, course_id) threads.append(thread.to_dict()) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError): log.error("Error loading single thread.") raise Http404 @@ -369,7 +369,7 @@ def user_profile(request, course_id, user_id): } return render_to_response('discussion/user_profile.html', context) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist): raise Http404 @@ -412,5 +412,5 @@ def followed_threads(request, course_id, user_id): } return render_to_response('discussion/user_profile.html', context) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist): raise Http404 From 1809fde60c68288ef62806699f0c1daab0af69dd Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Mon, 1 Jul 2013 09:16:25 -0400 Subject: [PATCH 122/161] Pylint cleanup. --- cms/djangoapps/contentstore/features/upload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index a298ff08b1..47d770dc47 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -91,7 +91,7 @@ def modify_upload(_step, file_name): @step('I see a confirmation that the file was deleted') -def i_see_a_delete_confirmation(step): +def i_see_a_delete_confirmation(_step): alert_css = '#notification-confirmation' assert world.is_css_present(alert_css) From 6f6a45178485592b20fb5c67bc8cda4310de8558 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 12 Jun 2013 14:45:40 -0400 Subject: [PATCH 123/161] Working prototype of crowdsourced hinting module. Conflicts: common/static/coffee/src/logger.coffee --- common/lib/xmodule/setup.py | 1 + .../lib/xmodule/xmodule/crowdsource_hinter.py | 261 ++++++++++++++++++ .../xmodule/js/src/capa/display.coffee | 1 + .../js/src/crowdsource_hinter/display.coffee | 62 +++++ common/static/coffee/src/logger.coffee | 30 +- 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/crowdsource_hinter.py create mode 100644 common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 43d970d898..6b106dd94d 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -55,6 +55,7 @@ setup( "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", + "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", ], 'console_scripts': [ 'xmodule_assets = xmodule.static_content:main', diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py new file mode 100644 index 0000000000..1d424b7fff --- /dev/null +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -0,0 +1,261 @@ +import logging +import copy +import json +import os +import re +import string +import random + +from pkg_resources import resource_listdir, resource_string, resource_isdir + +from lxml import etree + +from xmodule.modulestore import Location +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.x_module import XModule +from xmodule.xml_module import XmlDescriptor +from xblock.core import XBlock, Scope, String, Integer, Float, Object, Boolean + +from django.utils.html import escape + +log = logging.getLogger(__name__) + + +class CrowdsourceHinterFields(object): + has_children = True + hints = Object(help='''A dictionary mapping answers to lists of [hint, number_of_votes] pairs. + ''', scope=Scope.content, default= { + '4': + [['This is a hint.', 5], + ['This is hint 2', 3], + ['This is hint 3', 2], + ['This is hint 4', 1]]}) + ''' + Testing data for hints: + + ''' + previous_answers = Object(help='''A list of previous answers this student made to this problem. + Of the form (answer, (hint_id_1, hint_id_2, hint_id_3)) for each problem. hint_id's are + None if the hint was not given.''', + scope=Scope.user_state, default=[]) + + user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', + scope=Scope.user_state, default=False) + + +class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): + ''' An Xmodule that makes crowdsourced hints. + ''' + icon_class = 'crowdsource_hinter' + + js = {'coffee': [resource_string(__name__, 'js/src/crowdsource_hinter/display.coffee'), + ], + 'js': []} + js_module_name = "Hinter" + + + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) + + + def get_html(self): + ''' + Does a regular expression find and replace to change the AJAX url. + - Dependent on lon-capa problem. + ''' + # Reset the user vote, for debugging only! Remove for prod. + self.user_voted = False + for child in self.get_display_items(): + out = child.get_html() + # The event listener uses the ajax url to find the child. + child_url = child.system.ajax_url + break + # Wrap the module in a
    . This lets us pass data attributes to the javascript. + out += '
    ' + return out + + def capa_make_answer_hashable(self, answer): + ''' + Capa answer format: dict[problem name] -> [list of answers] + Output format: ((problem name, (answers))) + ''' + out = [] + for problem, a in answer.items(): + out.append((problem, tuple(a))) + return str(tuple(sorted(out))) + + + def ans_to_text(self, answer): + ''' + Converts capa answer format to a string representation + of the answer. + -Lon-capa dependent. + ''' + return answer.values()[0][0] + + + def handle_ajax(self, dispatch, get): + ''' + This is the landing method for AJAX calls. + ''' + if dispatch == 'get_hint': + return self.get_hint(get) + if dispatch == 'get_feedback': + return self.get_feedback(get) + if dispatch == 'vote': + return self.tally_vote(get) + if dispatch == 'submit_hint': + return self.submit_hint(get) + + def get_hint(self, get): + ''' + The student got the incorrect answer found in get. Give him a hint. + ''' + print self.hints + answer = self.ans_to_text(get) + # Look for a hint to give. + if answer not in self.hints: + # No hints to give. Return. + self.previous_answers += [(answer, (None, None, None))] + return json.dumps({'contents': ' '}) + # Get the top hint, plus two random hints. + n_hints = len(self.hints[answer]) + best_hint_index = max(xrange(n_hints), key=lambda i:self.hints[answer][i][1]) + best_hint = self.hints[answer][best_hint_index][0] + if len(self.hints[answer]) == 1: + rand_hint_1 = '' + rand_hint_2 = '' + self.previous_answers += [(answer, (0, None, None))] + elif len(self.hints[answer]) == 2: + best_hint = self.hints[answer][0][0] + rand_hint_1 = self.hints[answer][1][0] + rand_hint_2 = '' + self.previous_answers += [(answer, (0, 1, None))] + else: + hint_index_1, hint_index_2 = random.sample(xrange(len(self.hints[answer])), 2) + rand_hint_1 = self.hints[answer][hint_index_1][0] + rand_hint_2 = self.hints[answer][hint_index_2][0] + self.previous_answers += [(answer, (best_hint_index, hint_index_1, hint_index_2))] + hint_text = best_hint + '
    ' + rand_hint_1 + '
    ' + rand_hint_2 + return json.dumps({'contents': hint_text}) + + def get_feedback(self, get): + ''' + The student got it correct. Ask him to vote on hints, or submit a hint. + ''' + # The student got it right. + # Did he submit at least one wrong answer? + out = ' ' + if len(self.previous_answers) == 0: + # No. Nothing to do here. + return json.dumps({'contents': out}) + # Make a hint-voting interface for each wrong answer. The student will only + # be allowed to make one vote / submission, but he can choose which wrong answer + # he wants to look at. + pretty_answers = [] + for i in xrange(len(self.previous_answers)): + answer, hints_offered = self.previous_answers[i] + pretty_answers.append(answer) + # If there are previous hints for this answer, ask the student to vote on one. + if answer in self.hints: + out += '' + + # Add preamble. + out2 = '''Help us improve our hinting system by voting on the hint that was most helpful + to you. Start by picking one of your previous incorrect answers from below:
    +
    ' + return json.dumps({'contents': out2 + out}) + + + def tally_vote(self, get): + ''' + Tally a user's vote on his favorite hint. + get: + 'answer': ans_no (index in previous_answers) + 'hint': hint_no + ''' + if self.user_voted: + return json.dumps({'contents': 'Sorry, but you have already voted!'}) + ans_no = int(get['answer']) + hint_no = int(get['hint']) + answer = self.previous_answers[ans_no][0] + temp_dict = self.hints + temp_dict[answer][hint_no][1] += 1 + # Awkward, but you need to do a direct write for the database to update. + self.hints = temp_dict + # Don't let the user vote again! + self.user_voted = True + # Reset self.previous_answers. + self.previous_answers = [] + # In the future, return a list of how many votes each hint got, maybe? + return json.dumps({'contents': 'Congrats, you\'ve voted!'}) + + + def submit_hint(self, get): + ''' + Take a hint submission and add it to the database. + get: + 'answer': answer index in previous_answers + 'hint': text of the new hint that the user is adding + ''' + # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. + hint = escape(get['hint']) + answer = self.previous_answers[int(get['answer'])][0] + # Add the new hint to self.hints. (Awkward because a direct write + # is necessary.) + temp_dict = self.hints + temp_dict[answer].append([hint, 1]) # With one vote (the user himself). + self.hints = temp_dict + # Mark the user has having voted; reset previous_answers + self.user_voted = True + self.previous_answers = [] + return json.dumps({'contents': 'Thank you for your hint!'}) + + +class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): + module_class = CrowdsourceHinterModule + stores_state = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + children = [] + for child in xml_object: + try: + children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url()) + except Exception as e: + log.exception("Unable to load child when parsing CrowdsourceHinter. Continuing...") + if system.error_tracker is not None: + system.error_tracker("ERROR: " + str(e)) + continue + return {}, children + + def definition_to_xml(self, resource_fs): + xml_object = etree.Element('crowdsource_hinter') + for child in self.get_children(): + xml_object.append( + etree.fromstring(child.export_to_xml(resource_fs))) + return xml_object \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1f3be9e5e9..4640f7555d 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -223,6 +223,7 @@ class @Problem @el.removeClass 'showed' else @gentle_alert response.success + Logger.log 'problem_graded', [@answers, response.contents], @url reset: => Logger.log 'problem_reset', @answers diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee new file mode 100644 index 0000000000..1e38ff0e82 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -0,0 +1,62 @@ +class @Hinter + + constructor: (element) -> + @el = $(element).find('.crowdsource-wrapper') + @url = @el.data('url') + Logger.listen('problem_graded', @el.data('child-url'), @capture_problem) + # The line below will eventually be generated by Python. + @render() + + capture_problem: (event_type, data, element) => + # After a problem gets graded, we get the info here. + # We want to send this info to the server in another AJAX + # request. + answers = data[0] + response = data[1] + if response.search(/class="correct "/) == -1 + # Incorrect. Get hints. + $.postWithPrefix "#{@url}/get_hint", answers, (response) => + @render(response.contents) + else + # Correct. Get feedback from students. + $.postWithPrefix "#{@url}/get_feedback", answers, (response) => + @render(response.contents) + + $: (selector) -> + $(selector, @el) + + bind: => + window.update_schematics() + @$('input.vote').click @vote + @$('#feedback-select').change @feedback_ui_change + @$('input.submit-hint').click @submit_hint + + + vote: (eventObj) => + target = @$(eventObj.currentTarget) + post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')} + $.postWithPrefix "#{@url}/vote", post_json, (response) => + @render(response.contents) + + submit_hint: (eventObj) => + target = @$(eventObj.currentTarget) + textarea_id = '#custom-hint-' + target.data('answer') + console.debug(textarea_id) + post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} + $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => + @render(response.contents) + + feedback_ui_change: => + # Make all of the previous-answer divs hidden. + @$('.previous-answer').css('display', 'none') + # But, now find the selected div, and make it visible. + selector = '#previous-answer-' + @$('#feedback-select option:selected').attr('value') + @$(selector).css('display', 'inline') + + + render: (content) -> + if content + @el.html(content) + JavascriptLoader.executeModuleScripts @el, () => + @bind() + @$('#previous-answer-0').css('display', 'inline') \ No newline at end of file diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index f2dfef5132..6eaa497255 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -1,8 +1,11 @@ class @Logger + # events we want sent to Segment.io for tracking SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"] - @log: (event_type, data) -> + # listeners[event_type][element] -> list of callbacks + listeners = {} + @log: (event_type, data, element = null) -> # Segment.io event tracking if event_type in SEGMENT_IO_WHITELIST # to avoid changing the format of data sent to our servers, we only massage it here @@ -11,11 +14,36 @@ class @Logger else analytics.track event_type, data + # Check to see if we're listening for the event type. + if event_type of listeners + # Cool. Do the elements also match? + # null element in the listener dictionary means any element will do. + # null element in the @log call means we don't know the element name. + if null of listeners[event_type] + # Make the callbacks. + for callback in listeners[event_type][null] + callback(event_type, data, element) + else if element of listeners[event_type] + for callback in listeners[event_type][element] + callback(event_type, data, element) + + # Regardless of whether any callbacks were made, log this event. $.getWithPrefix '/event', event_type: event_type event: JSON.stringify(data) page: window.location.href + @listen: (event_type, element, callback) -> + # Add a listener. If you want any element to trigger this listener, + # do element = null + if event_type not of listeners + listeners[event_type] = {} + if element not of listeners[event_type] + listeners[event_type][element] = [callback] + else + listeners[event_type][element].push callback + + @bind: -> window.onunload = -> $.ajaxWithPrefix From 100f6bf11e7529b13bf285f6881bdd971eaac826 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 20 Jun 2013 09:44:19 -0400 Subject: [PATCH 124/161] Began work on instructor view to hinting system. Added moderation feature - you can now choose to hold all hints for moderator approval before showing. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 65 ++++++++- lms/djangoapps/instructor/hint_manager.py | 138 ++++++++++++++++++ lms/templates/courseware/hint_manager.html | 89 +++++++++++ .../courseware/hint_manager_inner.html | 39 +++++ lms/urls.py | 3 + 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/instructor/hint_manager.py create mode 100644 lms/templates/courseware/hint_manager.html create mode 100644 lms/templates/courseware/hint_manager_inner.html diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 1d424b7fff..97120bbf1c 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -42,6 +42,14 @@ class CrowdsourceHinterFields(object): user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', scope=Scope.user_state, default=False) + moderate = String(help='''If 'True', then all hints must be approved by staff before + becoming visible. + This field is automatically populated from the xml metadata.''', scope=Scope.settings, + default='False') + + mod_queue = Dict(help='''Contains hints that have not been approved by the staff yet. Structured + identically to the hints dictionary.''', scope=Scope.content, default={}) + class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ''' An Xmodule that makes crowdsourced hints. @@ -115,7 +123,11 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): print self.hints answer = self.ans_to_text(get) # Look for a hint to give. +<<<<<<< HEAD if answer not in self.hints: +======= + if (answer not in self.hints) or (len(self.hints[answer]) == 0): +>>>>>>> Began work on instructor view to hinting system. # No hints to give. Return. self.previous_answers += [(answer, (None, None, None))] return json.dumps({'contents': ' '}) @@ -126,12 +138,23 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if len(self.hints[answer]) == 1: rand_hint_1 = '' rand_hint_2 = '' +<<<<<<< HEAD self.previous_answers += [(answer, (0, None, None))] elif len(self.hints[answer]) == 2: best_hint = self.hints[answer][0][0] rand_hint_1 = self.hints[answer][1][0] rand_hint_2 = '' self.previous_answers += [(answer, (0, 1, None))] +======= + self.previous_answers += [[answer, [best_hint_index, None, None]]] + elif n_hints == 2: + best_hint = self.hints[answer].values()[0][0] + best_hint_index = self.hints[answer].keys()[0] + rand_hint_1 = self.hints[answer].values()[1][0] + hint_index_1 = self.hints[answer].keys()[1] + rand_hint_2 = '' + self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] +>>>>>>> Began work on instructor view to hinting system. else: hint_index_1, hint_index_2 = random.sample(xrange(len(self.hints[answer])), 2) rand_hint_1 = self.hints[answer][hint_index_1][0] @@ -163,10 +186,20 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): '" style="display:none"> Which hint was most helpful when you got the wrong answer of '\ + answer + '?' # Add each hint to the html string, with a vote button. - for j, hint_id in enumerate(hints_offered): + for hint_id in hints_offered: if hint_id != None: +<<<<<<< HEAD out += '
    ' + self.hints[answer][hint_id][0] +======= + hint_id = str(hint_id) + try: + out += '
    ' + self.hints[answer][hint_id][0] + except KeyError: + # Sometimes, the hint that a user saw will have been deleted by the instructor. + continue +>>>>>>> Began work on instructor view to hinting system. # Or, let the student create his own hint @@ -227,15 +260,45 @@ What would you say to help someone who got this wrong answer? answer = self.previous_answers[int(get['answer'])][0] # Add the new hint to self.hints. (Awkward because a direct write # is necessary.) +<<<<<<< HEAD temp_dict = self.hints temp_dict[answer].append([hint, 1]) # With one vote (the user himself). self.hints = temp_dict +======= + if self.moderate: + temp_dict = self.mod_queue + else: + temp_dict = self.hints + if answer in temp_dict: + temp_dict[answer][self.hint_pk] = [hint, 1] # With one vote (the user himself). + else: + temp_dict[answer] = {self.hint_pk: [hint, 1]} + self.hint_pk += 1 + if self.moderate: + self.mod_queue = temp_dict + else: + self.hints = temp_dict +>>>>>>> Began work on instructor view to hinting system. # Mark the user has having voted; reset previous_answers self.user_voted = True self.previous_answers = [] return json.dumps({'contents': 'Thank you for your hint!'}) +<<<<<<< HEAD +======= + def delete_hint(self, answer, hint_id): + ''' + From the answer, delete the hint with hint_id. + Not designed to be accessed via POST request, for now. + -LIKELY DEPRECATED. + ''' + temp_hints = self.hints + del temp_hints[answer][str(hint_id)] + self.hints = temp_hints + + +>>>>>>> Began work on instructor view to hinting system. class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): module_class = CrowdsourceHinterModule stores_state = True diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py new file mode 100644 index 0000000000..431d3f5d7c --- /dev/null +++ b/lms/djangoapps/instructor/hint_manager.py @@ -0,0 +1,138 @@ +''' +Views for hint management. +''' + +from collections import defaultdict +import csv +import json +import logging +from markupsafe import escape +import os +import re +import requests +from requests.status_codes import codes +import urllib +from collections import OrderedDict + +from StringIO import StringIO + +from django.conf import settings +from django.contrib.auth.models import User, Group +from django.http import HttpResponse, Http404 +from django_future.csrf import ensure_csrf_cookie +from django.views.decorators.cache import cache_control +from mitxmako.shortcuts import render_to_response, render_to_string +from django.core.urlresolvers import reverse + +from courseware.courses import get_course_with_access +from courseware.models import XModuleContentField + + +@ensure_csrf_cookie +def hint_manager(request, course_id): + try: + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + except Http404: + out = 'Sorry, but students are not allowed to access the hint manager!' + return + if request.method == 'GET': + out = get_hints(request, course_id, 'mod_queue') + return render_to_response('courseware/hint_manager.html', out) + field = request.POST['field'] + if not (field == 'mod_queue' or field == 'hints'): + # Invalid field. (Don't let users continue - they may overwrite other db's) + return + if request.POST['op'] == 'delete hints': + delete_hints(request, course_id, field) + if request.POST['op'] == 'switch fields': + pass + if request.POST['op'] == 'change votes': + change_votes(request, course_id, field) + rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(request, course_id, field)) + return HttpResponse(json.dumps({'success': True, 'contents': rendered_html})) + + + +def get_hints(request, course_id, field): + # field indicates the database entry that we are modifying. + # Right now, the options are 'hints' or 'mod_queue'. + # DON'T TRUST field attributes that come from ajax. Use an if statement + # to make sure the field is valid before plugging into functions. + + out = '' + if field == 'mod_queue': + other_field = 'hints' + field_label = 'Hints Awaiting Moderation' + other_field_label = 'Approved Hints' + elif field == 'hints': + other_field = 'mod_queue' + field_label = 'Approved Hints' + other_field_label = 'Hints Awaiting Moderation' + chopped_id = '/'.join(course_id.split('/')[:-1]) + chopped_id = re.escape(chopped_id) + all_hints = XModuleContentField.objects.filter(field_name=field, definition_id__regex=chopped_id) + for problem in all_hints: + out += '

    Problem: ' + problem.definition_id + '

    ' + for answer, hint_dict in json.loads(problem.value).items(): + out += '

    Answer: ' + answer + '

    ' + for pk, hint in hint_dict.items(): + out += '

    ' + out += '' + hint[0] + \ + '
    Votes: ' + out += '

    ' + out += '''

    Add a hint to this problem

    + Answer (exact formatting): +
    Hint:


    ' + + + out += ' ' + render_dict = {'out': out, + 'field': field, + 'other_field': other_field, + 'field_label': field_label, + 'other_field_label': other_field_label, + 'all_hints': all_hints} + return render_dict + +def delete_hints(request, course_id, field): + ''' + Deletes the hints specified by the [problem_defn_id, answer, pk] tuples in the numbered + fields of request.POST. + ''' + for key in request.POST: + if key == 'op' or key == 'field': + continue + problem_id, answer, pk = request.POST.getlist(key) + # Can be optimized - sort the delete list by problem_id, and load each problem + # from the database only once. + this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) + problem_dict = json.loads(this_problem.value) + del problem_dict[answer][pk] + this_problem.value = json.dumps(problem_dict) + this_problem.save() + +def change_votes(request, course_id, field): + ''' + Updates the number of votes. The numbered fields of request.POST contain + [problem_id, answer, pk, new_votes] tuples. + - Very similar to delete_hints. Is there a way to merge them? Nah, too complicated. + ''' + for key in request.POST: + if key == 'op' or key == 'field': + continue + problem_id, answer, pk, new_votes = request.POST.getlist(key) + this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) + problem_dict = json.loads(this_problem.value) + problem_dict[answer][pk][1] = new_votes + this_problem.value = json.dumps(problem_dict) + this_problem.save() + + + + + diff --git a/lms/templates/courseware/hint_manager.html b/lms/templates/courseware/hint_manager.html new file mode 100644 index 0000000000..94156d3d68 --- /dev/null +++ b/lms/templates/courseware/hint_manager.html @@ -0,0 +1,89 @@ +<%inherit file="/main.html" /> +<%namespace name='static' file='/static_content.html'/> +<%namespace name="content" file="/courseware/hint_manager_inner.html"/> + + +<%block name="headextra"> + <%static:css group='course'/> + + + + + + + + + + +
    +
    + +
    + ${content.main()} +
    + +
    +
    diff --git a/lms/templates/courseware/hint_manager_inner.html b/lms/templates/courseware/hint_manager_inner.html new file mode 100644 index 0000000000..41e8d018c5 --- /dev/null +++ b/lms/templates/courseware/hint_manager_inner.html @@ -0,0 +1,39 @@ +<%block name="main"> + + +

    ${field_label}

    +Switch to ${other_field_label} + + +% for problem in all_hints: +

    Problem: ${problem.definition_id}

    + <% + import json + loaded_json = json.loads(problem.value).items() + %> + % for answer, hint_dict in loaded_json: +

    Answer: ${answer}

    + % for pk, hint in hint_dict.items(): +

    + ${hint[0]} +
    + Votes: +

    + % endfor + % endfor + +

    Add a hint to this problem

    + Answer (exact formatting): + +
    + Hint:
    + +
    + +
    +% endfor + + + + + \ No newline at end of file diff --git a/lms/urls.py b/lms/urls.py index 3e5ffea015..febd0f1c0e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -264,6 +264,9 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor$', 'instructor.views.instructor_dashboard', name="instructor_dashboard"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/hint_manager$', + 'instructor.hint_manager.hint_manager', name="hint_manager"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', 'instructor.views.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', From b64fe5c5374952966197e38414148cf4daa8e58a Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 20 Jun 2013 17:09:00 -0400 Subject: [PATCH 125/161] Finished prototype of hint moderation view. Began re-writing tests of the crowdsource hinter module. (Old tests no longer cover all the code, now that moderation has been added.) --- .../xmodule/tests/test_crowdsource_hinter.py | 296 ++++++++++++++++++ lms/djangoapps/instructor/hint_manager.py | 129 ++++++-- lms/templates/courseware/hint_manager.html | 35 +++ .../courseware/hint_manager_inner.html | 40 ++- 4 files changed, 462 insertions(+), 38 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py new file mode 100644 index 0000000000..7fe890fa77 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -0,0 +1,296 @@ +from mock import Mock, patch +import unittest + +import xmodule +from xmodule.crowdsource_hinter import CrowdsourceHinterModule +from xmodule.modulestore import Location + +from django.http import QueryDict + +from . import test_system + +import json + +class CHModuleFactory(object): + ''' + Helps us make a CrowdsourceHinterModule with the specified internal + state. + ''' + + sample_problem_xml = ''' + + + +

    A numerical input problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.

    +

    The answer is correct if it is within a specified numerical tolerance of the expected answer.

    +

    Enter the number of fingers on a human hand:

    + + + + +
    +

    Explanation

    +

    If you look at your hand, you can count that you have five fingers.

    +
    +
    +
    +
    + ''' + + num = 0 + + @staticmethod + def next_num(): + CHModuleFactory.num += 1 + return CHModuleFactory.num + + @staticmethod + def create(hints=None, + previous_answers=None, + user_voted=None, + moderate=None, + mod_queue=None): + + location = Location(["i4x", "edX", "capa_test", "problem", + "SampleProblem{0}".format(CHModuleFactory.next_num())]) + model_data = {'data': CHModuleFactory.sample_problem_xml} + + if hints != None: + model_data['hints'] = hints + else: + model_data['hints'] = { + '24.0': {'0': ['Best hint', 40], + '3': ['Another hint', 30], + '4': ['A third hint', 20], + '6': ['A less popular hint', 3]}, + '25.0': {'1': ['Really popular hint', 100]} + } + + if mod_queue != None: + model_data['mod_queue'] = mod_queue + else: + model_data['mod_queue'] = { + '24.0': {'2': ['A non-approved hint']}, + '26.0': {'5': ['Another non-approved hint']} + } + + if previous_answers != None: + model_data['previous_answers'] = previous_answers + else: + model_data['previous_answers'] = [ + ['24.0', [0, 3, 4]], + ['29.0', [None, None, None]] + ] + + if user_voted != None: + model_data['user_voted'] = user_voted + + if moderate != None: + model_data['moderate'] = moderate + + descriptor = Mock(weight="1") + system = test_system() + system.render_template = Mock(return_value="
    Test Template HTML
    ") + module = CrowdsourceHinterModule(system, descriptor, model_data) + + return module + +class CrowdsourceHinterTest(unittest.TestCase): + ''' + In the below tests, '24.0' represents a wrong answer, and '42.5' represents + a correct answer. + ''' + + def test_gethint_0hint(self): + ''' + Someone asks for a hint, when there's no hint to give. + - Output should be blank. + - New entry should be added to previous_answers + ''' + m = CHModuleFactory.create() + json_in = {'problem_name': '26.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue(json_out == ' ') + self.assertTrue(['26.0', [None, None, None]] in m.previous_answers) + + def test_gethint_1hint(self): + ''' + Someone asks for a hint, with exactly one hint in the database. + Output should contain that hint. + ''' + m = CHModuleFactory.create() + json_in = {'problem_name': '25.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('Really popular hint' in json_out) + + + def test_gethint_manyhints(self): + ''' + Someone asks for a hint, with many matching hints in the database. + - The top-rated hint should be returned. + - Two other random hints should be returned. + Currently, the best hint could be returned twice - need to fix this + in implementation. + ''' + m = CHModuleFactory.create() + json_in = {'problem_name': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('Best hint' in json_out) + self.assertTrue(json_out.count('hint') == 3) + + + def test_getfeedback_0wronganswers(self): + ''' + Someone has gotten the problem correct on the first try. + Output should be empty. + ''' + m = CHModuleFactory.create(previous_answers=[]) + json_in = {'problem_name': '42.5'} + json_out = json.loads(m.get_feedback(json_in))['contents'] + self.assertTrue(json_out == ' ') + + def test_getfeedback_1wronganswer_nohints(self): + ''' + Someone has gotten the problem correct, with one previous wrong + answer. However, we don't actually have hints for this problem. + There should be a dialog to submit a new hint. + ''' + m = CHModuleFactory.create(previous_answers=[['26.0',[None, None, None]]]) + json_in = {'problem_name': '42.5'} + json_out = json.loads(m.get_feedback(json_in))['contents'] + self.assertTrue('textarea' in json_out) + self.assertTrue('Vote' not in json_out) + + + def test_getfeedback_1wronganswer_withhints(self): + ''' + Same as above, except the user did see hints. There should be + a voting dialog, with the correct choices, plus a hint submission + dialog. + ''' + m = CHModuleFactory.create(hints={ + '24.0': {'0': ['a hint', 42], + '1': ['another hint', 35], + '2': ['irrelevent hint', 25.0]} + }, + previous_answers=[ + ['24.0', [0, 1, None]]], + ) + json_in = {'problem_name': '42.5'} + json_out = json.loads(m.get_feedback(json_in))['contents'] + self.assertTrue('a hint' in json_out) + self.assertTrue('another hint' in json_out) + self.assertTrue('irrelevent hint' not in json_out) + self.assertTrue('textarea' in json_out) + + + def test_vote_nopermission(self): + ''' + A user tries to vote for a hint, but he has already voted! + Should not change any vote tallies. + ''' + m = CHModuleFactory.create(hints={ + '24.0': {'0': ['a hint', 42], + '1': ['another hint', 35], + '2': ['irrelevent hint', 25.0]} + }, + previous_answers=[ + ['24.0', [0, 1, None]]], + user_voted=True + ) + json_in = {'answer': 0, 'hint': 1} + json_out = json.loads(m.tally_vote(json_in))['contents'] + self.assertTrue(m.hints['24.0']['0'][1] == 42) + self.assertTrue(m.hints['24.0']['1'][1] == 35) + self.assertTrue(m.hints['24.0']['2'][1] == 25.0) + + + def test_vote_withpermission(self): + ''' + A user votes for a hint. + ''' + m = CHModuleFactory.create(hints={ + '24.0': {'0': ['a hint', 42], + '1': ['another hint', 35], + '2': ['irrelevent hint', 25.0]} + }, + previous_answers=[ + ['24.0', [0, 1, None]]], + ) + json_in = {'answer': 0, 'hint': 1} + json_out = json.loads(m.tally_vote(json_in))['contents'] + self.assertTrue(m.hints['24.0']['0'][1] == 42) + self.assertTrue(m.hints['24.0']['1'][1] == 36) + self.assertTrue(m.hints['24.0']['2'][1] == 25.0) + + + def test_submithint_nopermission(self): + ''' + A user tries to submit a hint, but he has already voted. + ''' + m = CHModuleFactory.create(previous_answers=[ + ['24.0', [None, None, None]]], + user_voted=True) + json_in = {'answer': 0, 'hint': 'This is a new hint.'} + m.submit_hint(json_in) + self.assertTrue('24.0' not in m.hints) + + + def test_submithint_withpermission_new(self): + ''' + A user submits a hint to an answer for which no hints + exist yet. + ''' + m = CHModuleFactory.create(previous_answers=[ + ['24.0', [None, None, None]]], + ) + json_in = {'answer': 0, 'hint': 'This is a new hint.'} + m.submit_hint(json_in) + # Make a hint request. + json_in = {'problem name': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('This is a new hint.' in json_out) + + + def test_submithint_withpermission_existing(self): + ''' + A user submits a hint to an answer that has other hints + already. + ''' + m = CHModuleFactory.create(previous_answers=[ + ['24.0', [0, None, None]]], + hints={'24.0': {'0': ['Existing hint.', 1]}} + ) + json_in = {'answer': 0, 'hint': 'This is a new hint.'} + m.submit_hint(json_in) + # Make a hint request. + json_in = {'problem name': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('This is a new hint.' in json_out) + + def test_deletehint(self): + ''' + An admin / instructor deletes a hint. + ''' + m = CHModuleFactory.create(hints={ + '24.0': {'0': ['Deleted hint', 5], + '1': ['Safe hint', 4]} + }) + m.delete_hint('24.0', '0') + json_in = {'problem name': '24.0'} + json_out = json.loads(m.get_hint(json_in))['contents'] + self.assertTrue('Deleted hint' not in json_out) + self.assertTrue('Safe hint' in json_out) + + + + + + + + + + + + + diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 431d3f5d7c..5d722b1e79 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -26,6 +26,8 @@ from django.core.urlresolvers import reverse from courseware.courses import get_course_with_access from courseware.models import XModuleContentField +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore @ensure_csrf_cookie @@ -48,6 +50,10 @@ def hint_manager(request, course_id): pass if request.POST['op'] == 'change votes': change_votes(request, course_id, field) + if request.POST['op'] == 'add hint': + add_hint(request, course_id, field) + if request.POST['op'] == 'approve': + approve(request, course_id, field) rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(request, course_id, field)) return HttpResponse(json.dumps({'success': True, 'contents': rendered_html})) @@ -59,7 +65,6 @@ def get_hints(request, course_id, field): # DON'T TRUST field attributes that come from ajax. Use an if statement # to make sure the field is valid before plugging into functions. - out = '' if field == 'mod_queue': other_field = 'hints' field_label = 'Hints Awaiting Moderation' @@ -71,32 +76,40 @@ def get_hints(request, course_id, field): chopped_id = '/'.join(course_id.split('/')[:-1]) chopped_id = re.escape(chopped_id) all_hints = XModuleContentField.objects.filter(field_name=field, definition_id__regex=chopped_id) + big_out_dict = {} + name_dict = {} for problem in all_hints: - out += '

    Problem: ' + problem.definition_id + '

    ' - for answer, hint_dict in json.loads(problem.value).items(): - out += '

    Answer: ' + answer + '

    ' - for pk, hint in hint_dict.items(): - out += '

    ' - out += '' + hint[0] + \ - '
    Votes: ' - out += '

    ' - out += '''

    Add a hint to this problem

    - Answer (exact formatting): -
    Hint:


    ' + loc = Location(problem.definition_id) + try: + descriptor = modulestore().get_items(loc)[0] + except IndexError: + # Sometimes, the problem is no longer in the course. Just + # don't include said problem. + continue + name_dict[problem.definition_id] = descriptor.get_children()[0].display_name + # Answer list contains (answer, dict_of_hints) tuples. + def answer_sorter(thing): + ''' + thing is a tuple, where thing[0] contains an answer, and thing[1] contains + a dict of hints. This function returns an index based on thing[0], which + is used as a key to sort the list of things. + ''' + try: + return float(thing[0]) + except ValueError: + # Put all non-numerical answers first. + return float('-inf') - out += ' ' - render_dict = {'out': out, - 'field': field, + answer_list = sorted(json.loads(problem.value).items(), key=answer_sorter) + big_out_dict[problem.definition_id] = answer_list + + render_dict = {'field': field, 'other_field': other_field, 'field_label': field_label, 'other_field_label': other_field_label, - 'all_hints': all_hints} + 'all_hints': big_out_dict, + 'id_to_name': name_dict} return render_dict def delete_hints(request, course_id, field): @@ -131,6 +144,80 @@ def change_votes(request, course_id, field): problem_dict[answer][pk][1] = new_votes this_problem.value = json.dumps(problem_dict) this_problem.save() + +def add_hint(request, course_id, field): + ''' + Add a new hint. POST: + op + field + problem - The problem id + answer - The answer to which a hint will be added + hint - The text of the hint + ''' + problem_id = request.POST['problem'] + answer = request.POST['answer'] + hint_text = request.POST['hint'] + this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) + + hint_pk_entry = XModuleContentField.objects.get(field_name='hint_pk', definition_id=problem_id) + this_pk = int(hint_pk_entry.value) + hint_pk_entry.value = this_pk + 1 + hint_pk_entry.save() + + problem_dict = json.loads(this_problem.value) + if answer not in problem_dict: + problem_dict[answer] = {} + problem_dict[answer][this_pk] = [hint_text, 1] + this_problem.value = json.dumps(problem_dict) + this_problem.save() + +def approve(request, course_id, field): + ''' + Approve a list of hints, moving them from the mod_queue to the real + hint list. POST: + op, field + (some number) -> [problem, answer, pk] + ''' + for key in request.POST: + if key == 'op' or key == 'field': + continue + problem_id, answer, pk = request.POST.getlist(key) + # Can be optimized - sort the delete list by problem_id, and load each problem + # from the database only once. + problem_in_mod = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) + problem_dict = json.loads(problem_in_mod.value) + hint_to_move = problem_dict[answer][pk] + del problem_dict[answer][pk] + problem_in_mod.value = json.dumps(problem_dict) + problem_in_mod.save() + + problem_in_hints = XModuleContentField.objects.get(field_name='hints', definition_id=problem_id) + problem_dict = json.loads(problem_in_hints.value) + if answer not in problem_dict: + problem_dict[answer] = {} + problem_dict[answer][pk] = hint_to_move + problem_in_hints.value = json.dumps(problem_dict) + problem_in_hints.save() + + + + + + + + + + + + + + + + + + + + diff --git a/lms/templates/courseware/hint_manager.html b/lms/templates/courseware/hint_manager.html index 94156d3d68..394792f892 100644 --- a/lms/templates/courseware/hint_manager.html +++ b/lms/templates/courseware/hint_manager.html @@ -28,6 +28,7 @@ data_dict[i] = [$(this).parent().attr("data-problem"), $(this).parent().attr("data-answer"), $(this).parent().attr("data-pk")]; + i += 1 } }); $.ajax(window.location.pathname, { @@ -64,6 +65,40 @@ }); }); + $(".submit-new-hint").click(function(){ + problem_name = $(this).data("problem"); + hint_text = $(".submit-hint-text").filter('*[data-problem="'+problem_name+'"]').val(); + hint_answer = $(".submit-hint-answer").filter('*[data-problem="'+problem_name+'"]').val(); + data_dict = {'op': 'add hint', + 'field': field, + 'problem': problem_name, + 'answer': hint_answer, + 'hint': hint_text}; + $.ajax(window.location.pathname, { + type: "POST", + data: data_dict, + success: update_contents + }); + }); + + $("#approve").click(function(){ + var data_dict = {'op': 'approve', + 'field': field} + var i = 1 + $(".hint-select").each(function(){ + if ($(this).is(":checked")) { + data_dict[i] = [$(this).parent().attr("data-problem"), + $(this).parent().attr("data-answer"), + $(this).parent().attr("data-pk")]; + i += 1 + } + }); + $.ajax(window.location.pathname, { + type: "POST", + data: data_dict, + success: update_contents + }); + }); } $(document).ready(setup); diff --git a/lms/templates/courseware/hint_manager_inner.html b/lms/templates/courseware/hint_manager_inner.html index 41e8d018c5..c69539522f 100644 --- a/lms/templates/courseware/hint_manager_inner.html +++ b/lms/templates/courseware/hint_manager_inner.html @@ -1,39 +1,45 @@ <%block name="main"> - +

    ${field_label}

    -Switch to ${other_field_label} +Switch to ${other_field_label} -% for problem in all_hints: -

    Problem: ${problem.definition_id}

    - <% - import json - loaded_json = json.loads(problem.value).items() - %> - % for answer, hint_dict in loaded_json: -

    Answer: ${answer}

    +% for definition_id in all_hints: +

    Problem: ${id_to_name[definition_id]}

    + % for answer, hint_dict in all_hints[definition_id]: + % if len(hint_dict) > 0: +

    Answer: ${answer}

    + % endif % for pk, hint in hint_dict.items(): -

    +

    ${hint[0]}
    - Votes: + Votes: +

    % endfor + % if len(hint_dict) > 0: +

    + % endif % endfor

    Add a hint to this problem

    - Answer (exact formatting): - +

    Answer:

    + + (Be sure to format your answer in the same way as the other answers you see here.)
    Hint:
    - +
    - +
    % endfor - + +% if field == 'mod_queue': + +% endif \ No newline at end of file From 6ccfa2e38dcf326e1f635b88535bf54b31edfe53 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 21 Jun 2013 12:15:03 -0400 Subject: [PATCH 126/161] Made tests of the crowdsource hinter module more standardized and easier to read. Fixed database non-initialization bug in crowdsource hinter module. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 32 +++---- .../xmodule/tests/test_crowdsource_hinter.py | 92 +++++++------------ 2 files changed, 44 insertions(+), 80 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 97120bbf1c..5fc9cab09b 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -42,9 +42,9 @@ class CrowdsourceHinterFields(object): user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', scope=Scope.user_state, default=False) - moderate = String(help='''If 'True', then all hints must be approved by staff before + moderate = String(help='''If True, then all hints must be approved by staff before becoming visible. - This field is automatically populated from the xml metadata.''', scope=Scope.settings, + This field is automatically populated from the xml metadata.''', scope=Scope.content, default='False') mod_queue = Dict(help='''Contains hints that have not been approved by the staff yet. Structured @@ -73,6 +73,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ''' # Reset the user vote, for debugging only! Remove for prod. self.user_voted = False + # You are invited to guess what the lines below do :) + if self.hints == {}: + self.hints = {} + for child in self.get_display_items(): out = child.get_html() # The event listener uses the ajax url to find the child. @@ -123,11 +127,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): print self.hints answer = self.ans_to_text(get) # Look for a hint to give. -<<<<<<< HEAD - if answer not in self.hints: -======= if (answer not in self.hints) or (len(self.hints[answer]) == 0): ->>>>>>> Began work on instructor view to hinting system. # No hints to give. Return. self.previous_answers += [(answer, (None, None, None))] return json.dumps({'contents': ' '}) @@ -138,14 +138,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if len(self.hints[answer]) == 1: rand_hint_1 = '' rand_hint_2 = '' -<<<<<<< HEAD - self.previous_answers += [(answer, (0, None, None))] - elif len(self.hints[answer]) == 2: - best_hint = self.hints[answer][0][0] - rand_hint_1 = self.hints[answer][1][0] - rand_hint_2 = '' - self.previous_answers += [(answer, (0, 1, None))] -======= self.previous_answers += [[answer, [best_hint_index, None, None]]] elif n_hints == 2: best_hint = self.hints[answer].values()[0][0] @@ -154,7 +146,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): hint_index_1 = self.hints[answer].keys()[1] rand_hint_2 = '' self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] ->>>>>>> Began work on instructor view to hinting system. else: hint_index_1, hint_index_2 = random.sample(xrange(len(self.hints[answer])), 2) rand_hint_1 = self.hints[answer][hint_index_1][0] @@ -188,10 +179,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Add each hint to the html string, with a vote button. for hint_id in hints_offered: if hint_id != None: -<<<<<<< HEAD - out += '
    ' + self.hints[answer][hint_id][0] -======= hint_id = str(hint_id) try: out += '
    ' + rand_hint_1 + '
    ' + rand_hint_2 return json.dumps({'contents': hint_text}) @@ -172,9 +166,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): answer, hints_offered = self.previous_answers[i] pretty_answers.append(answer) # If there are previous hints for this answer, ask the student to vote on one. + out += ' From ab303e7567f252d5e5a16ed5bb9a4a242e256343 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 26 Jun 2013 15:53:40 -0400 Subject: [PATCH 132/161] Fixed numerous code-formatting issues and pep8 violations. Began enforcing one-vote-per-person. This can be disabled with debug="True" in the tag. Started tests of the hint manager. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 178 ++++++++++-------- .../js/src/crowdsource_hinter/display.coffee | 9 +- .../xmodule/tests/test_crowdsource_hinter.py | 70 ++++++- common/templates/hinter_display.html | 2 +- lms/djangoapps/instructor/hint_manager.py | 142 +++++++------- .../instructor/tests/test_hint_manager.py | 79 ++++++++ 6 files changed, 313 insertions(+), 167 deletions(-) create mode 100644 lms/djangoapps/instructor/tests/test_hint_manager.py diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index c5d5dd7f80..664cf85f1a 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -1,20 +1,20 @@ +""" +Adds crowdsourced hinting functionality to lon-capa numerical response problems. + +Currently experimental - not for instructor use, yet. +""" + import logging -import copy import json -import os -import re -import string import random -from pkg_resources import resource_listdir, resource_string, resource_isdir +from pkg_resources import resource_string from lxml import etree -from xmodule.modulestore import Location -from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.x_module import XModule from xmodule.xml_module import XmlDescriptor -from xblock.core import XBlock, Scope, String, Integer, Float, Boolean, Dict, List +from xblock.core import Scope, String, Integer, Boolean, Dict, List from django.utils.html import escape @@ -22,112 +22,123 @@ log = logging.getLogger(__name__) class CrowdsourceHinterFields(object): + """Defines fields for the crowdsource hinter module.""" has_children = True - hints = Dict(help="""A dictionary mapping answers to lists of [hint, number_of_votes] pairs. - """, scope=Scope.content, default= {}) - - previous_answers = List(help="""A list of previous answers this student made to this problem. - Of the form (answer, (hint_id_1, hint_id_2, hint_id_3)) for each problem. hint_id's are - None if the hint was not given.""", - scope=Scope.user_state, default=[]) - - user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', - scope=Scope.user_state, default=False) - - moderate = String(help="""If True, then all hints must be approved by staff before - becoming visible. - This field is automatically populated from the xml metadata.""", scope=Scope.content, - default='False') - - mod_queue = Dict(help="""Contains hints that have not been approved by the staff yet. Structured - identically to the hints dictionary.""", scope=Scope.content, default={}) + moderate = String(help='String "True"/"False" - activates moderation', scope=Scope.content, + default='False') + debug = String(help='String "True"/"False" - allows multiple voting', scope=Scope.content, + default='False') + # hints[answer] = {str(pk): [hint_text, #votes]} + hints = Dict(help='A dictionary containing all the active hints.', scope=Scope.content, default={}) + mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, + default={}) hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) + # A list of previous answers this student made to this problem. + # Of the form (answer, (hint_pk_1, hint_pk_2, hint_pk_3)) for each problem. hint_pk's are + # None if the hint was not given. + previous_answers = List(help='A list of previous submissions.', scope=Scope.user_state, default=[]) + user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', + scope=Scope.user_state, default=False) class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): - """ An Xmodule that makes crowdsourced hints. + """ + An Xmodule that makes crowdsourced hints. + Currently, only works on capa problems with exactly one numerical response, + and no other parts. + + Example usage: + + + + + XML attributes: + -moderate="True" will not display hints until staff approve them in the hint manager. + -debug="True" will let users vote as often as they want. """ icon_class = 'crowdsource_hinter' - - js = {'coffee': [resource_string(__name__, 'js/src/crowdsource_hinter/display.coffee'), - ], - 'js': []} + js = {'coffee': [resource_string(__name__, 'js/src/crowdsource_hinter/display.coffee')], + 'js': []} js_module_name = "Hinter" - def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) - def get_html(self): """ - Does a regular expression find and replace to change the AJAX url. + Puts a wrapper around the problem html. This wrapper includes ajax urls of the + hinter and of the problem. - Dependent on lon-capa problem. """ - # Reset the user vote, for debugging only! Remove for prod. - self.user_voted = False - # You are invited to guess what the lines below do :) + if self.debug == 'True': + # Reset the user vote, for debugging only! + self.user_voted = False if self.hints == {}: + # Force self.hints to be written into the database. (When an xmodule is initialized, + # fields are not added to the db until explicitly changed at least once.) self.hints = {} - for child in self.get_display_items(): + try: + child = self.get_display_items()[0] out = child.get_html() # The event listener uses the ajax url to find the child. child_url = child.system.ajax_url - break + except IndexError: + out = 'Error in loading crowdsourced hinter - can\'t find child problem.' + child_url = '' + # Wrap the module in a
    . This lets us pass data attributes to the javascript. out += '
    ' return out - def capa_make_answer_hashable(self, answer): - """ - Capa answer format: dict[problem name] -> [list of answers] - Output format: ((problem name, (answers))) - """ - out = [] - for problem, a in answer.items(): - out.append((problem, tuple(a))) - return str(tuple(sorted(out))) - - - def ans_to_text(self, answer): + def capa_answer_to_str(self, answer): """ Converts capa answer format to a string representation of the answer. -Lon-capa dependent. + -Assumes that the problem only has one part. """ return str(float(answer.values()[0])) - def handle_ajax(self, dispatch, get): """ This is the landing method for AJAX calls. """ if dispatch == 'get_hint': out = self.get_hint(get) - if dispatch == 'get_feedback': + elif dispatch == 'get_feedback': out = self.get_feedback(get) - if dispatch == 'vote': + elif dispatch == 'vote': out = self.tally_vote(get) - if dispatch == 'submit_hint': + elif dispatch == 'submit_hint': out = self.submit_hint(get) + else: + return json.dumps({'contents': 'Error - invalid operation.'}) - if out == None: + if out is None: out = {'op': 'empty'} else: out.update({'op': dispatch}) return json.dumps({'contents': self.system.render_template('hinter_display.html', out)}) - def get_hint(self, get): """ The student got the incorrect answer found in get. Give him a hint. + + Called by hinter javascript after a problem is graded as incorrect. + Args: + get -- must be interpretable by capa_answer_to_str. + Output keys: + - best_hint is the hint text with the most votes. + - rand_hint_1 and rand_hint_2 are two random hints to the answer in get. + - answer is the parsed answer that was submitted. """ - answer = self.ans_to_text(get) + answer = self.capa_answer_to_str(get) # Look for a hint to give. # Make a local copy of self.hints - this means we only need to do one json unpacking. + # (This is because xblocks storage makes the following command a deep copy.) local_hints = self.hints if (answer not in local_hints) or (len(local_hints[answer]) == 0): # No hints to give. Return. @@ -156,13 +167,19 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): self.previous_answers += [(answer, (best_hint_index, hint_index_1, hint_index_2))] return {'best_hint': best_hint, - 'rand_hint_1': rand_hint_1, - 'rand_hint_2': rand_hint_2, + 'rand_hint_1': rand_hint_1, + 'rand_hint_2': rand_hint_2, 'answer': answer} def get_feedback(self, get): """ The student got it correct. Ask him to vote on hints, or submit a hint. + + Args: + get -- not actually used. (It is assumed that the answer is correct.) + Output keys: + - index_to_hints maps previous answer indices to hints that the user saw earlier. + - index_to_answer maps previous answer indices to the actual answer submitted. """ # The student got it right. # Did he submit at least one wrong answer? @@ -178,13 +195,15 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # index_to_answer[previous answer #] = answer text index_to_answer = {} + # Go through each previous answer, and populate index_to_hints and index_to_answer. for i in xrange(len(self.previous_answers)): answer, hints_offered = self.previous_answers[i] index_to_hints[i] = [] index_to_answer[i] = answer if answer in self.hints: + # Go through each hint, and add to index_to_hints for hint_id in hints_offered: - if hint_id != None: + if hint_id is None: try: index_to_hints[i].append((self.hints[answer][str(hint_id)][0], hint_id)) except KeyError: @@ -193,22 +212,24 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): return {'index_to_hints': index_to_hints, 'index_to_answer': index_to_answer} - def tally_vote(self, get): """ Tally a user's vote on his favorite hint. - get: + + Args: + get -- expected to have the following keys: 'answer': ans_no (index in previous_answers) - 'hint': hint_no + 'hint': hint_pk + Returns key hint_and_votes, a list of (hint_text, #votes) pairs. """ if self.user_voted: - return json.dumps({'contents': 'Sorry, but you have already voted!'}) - ans_no = int(get['answer']) + return json.dumps({'contents': 'Sorry, but you have already voted!'}) + ans_no = int(get['answer']) hint_no = str(get['hint']) answer = self.previous_answers[ans_no][0] + # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints temp_dict[answer][hint_no][1] += 1 - # Awkward, but you need to do a direct write for the database to update. self.hints = temp_dict # Don't let the user vote again! self.user_voted = True @@ -216,7 +237,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Return a list of how many votes each hint got. hint_and_votes = [] for hint_no in self.previous_answers[ans_no][1]: - if hint_no == None: + if hint_no is None: continue hint_and_votes.append(temp_dict[answer][str(hint_no)]) @@ -227,16 +248,20 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): def submit_hint(self, get): """ Take a hint submission and add it to the database. - get: + + Args: + get -- expected to have the following keys: 'answer': answer index in previous_answers 'hint': text of the new hint that the user is adding + Returns a thank-you message. """ # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. hint = escape(get['hint']) answer = self.previous_answers[int(get['answer'])][0] + # Only allow a student to vote or submit a hint once. if self.user_voted: - return {'message': 'Sorry, but you have already voted!'} - # Add the new hint to self.hints. (Awkward because a direct write + return {'message': 'Sorry, but you have already voted!'} + # Add the new hint to self.hints or self.mod_queue. (Awkward because a direct write # is necessary.) if self.moderate == 'True': temp_dict = self.mod_queue @@ -257,17 +282,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): return {'message': 'Thank you for your hint!'} - def delete_hint(self, answer, hint_id): - """ - From the answer, delete the hint with hint_id. - Not designed to be accessed via POST request, for now. - -LIKELY DEPRECATED. - """ - temp_hints = self.hints - del temp_hints[answer][str(hint_id)] - self.hints = temp_hints - - class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): module_class = CrowdsourceHinterModule stores_state = True diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 8eeab4cb02..ea42601622 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -1,10 +1,13 @@ class @Hinter + # The client side code for the crowdsource_hinter. + # Contains code for capturing problem checks and making ajax calls to + # the server component. Also contains styling code to clear default + # text on a textarea. constructor: (element) -> @el = $(element).find('.crowdsource-wrapper') @url = @el.data('url') Logger.listen('problem_graded', @el.data('child-url'), @capture_problem) - # The line below will eventually be generated by Python. @render() capture_problem: (event_type, data, element) => @@ -32,7 +35,6 @@ class @Hinter @$('.custom-hint').click @clear_default_text @$('#answer-tabs').tabs({active: 0}) - vote: (eventObj) => target = @$(eventObj.currentTarget) post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')} @@ -42,7 +44,6 @@ class @Hinter submit_hint: (eventObj) => target = @$(eventObj.currentTarget) textarea_id = '#custom-hint-' + target.data('answer') - console.debug(textarea_id) post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => @render(response.contents) @@ -53,7 +54,6 @@ class @Hinter target.val('') target.data('cleared', true) - feedback_ui_change: => # Make all of the previous-answer divs hidden. @$('.previous-answer').css('display', 'none') @@ -61,7 +61,6 @@ class @Hinter selector = '#previous-answer-' + @$('#feedback-select option:selected').attr('value') @$(selector).css('display', 'inline') - render: (content) -> if content @el.html(content) diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index 350abe9c8f..31614c4849 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -97,6 +97,17 @@ class CHModuleFactory(object): return module +class FakeChild(object): + """ + A fake Xmodule. + """ + def __init__(self): + self.system = Mock() + self.system.ajax_url = 'this/is/a/fake/ajax/url' + + def get_html(self): + return 'This is supposed to be test html.' + class CrowdsourceHinterTest(unittest.TestCase): @@ -105,6 +116,22 @@ class CrowdsourceHinterTest(unittest.TestCase): a correct answer. """ + def test_gethtml(self): + """ + A simple test of get_html - make sure it returns the html of the inner + problem. + """ + m = CHModuleFactory.create() + def fake_get_display_items(): + """ + A mock of get_display_items + """ + return [FakeChild()] + m.get_display_items = fake_get_display_items + out_html = m.get_html() + self.assertTrue('This is supposed to be test html.' in out_html) + self.assertTrue('this/is/a/fake/ajax/url' in out_html) + def test_gethint_0hint(self): """ Someone asks for a hint, when there's no hint to give. @@ -182,6 +209,18 @@ class CrowdsourceHinterTest(unittest.TestCase): out = m.get_feedback(json_in) self.assertTrue(len(out['index_to_hints'][0])==2) + + def test_getfeedback_missingkey(self): + """ + Someone gets a problem correct, but one of the hints that he saw + earlier (pk=100) has been deleted. Should just skip that hint. + """ + m = CHModuleFactory.create( + previous_answers=[['24.0', [0, 100, None]]]) + json_in = {'problem_name': '42.5'} + out = m.get_feedback(json_in) + self.assertTrue(len(out['index_to_hints'][0])==1) + def test_vote_nopermission(self): """ A user tries to vote for a hint, but he has already voted! @@ -197,13 +236,16 @@ class CrowdsourceHinterTest(unittest.TestCase): def test_vote_withpermission(self): """ A user votes for a hint. + Also tests vote result rendering. """ - m = CHModuleFactory.create() + m = CHModuleFactory.create( + previous_answers=[['24.0', [0, 3, None]]]) json_in = {'answer': 0, 'hint': 3} - m.tally_vote(json_in) + dict_out = m.tally_vote(json_in) self.assertTrue(m.hints['24.0']['0'][1] == 40) self.assertTrue(m.hints['24.0']['3'][1] == 31) - self.assertTrue(m.hints['24.0']['4'][1] == 20) + self.assertTrue(['Best hint', 40] in dict_out['hint_and_votes']) + self.assertTrue(['Another hint', 31] in dict_out['hint_and_votes']) def test_submithint_nopermission(self): @@ -256,6 +298,16 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue('29.0' not in m.hints) self.assertTrue('29.0' in m.mod_queue) + def test_submithint_escape(self): + """ + Make sure that hints are being html-escaped. + """ + m = CHModuleFactory.create() + json_in = {'answer': 1, 'hint': ''} + m.submit_hint(json_in) + print m.hints + self.assertTrue(m.hints['29.0'][0][0] == u'<script> alert("Trololo"); </script>') + def test_template_gethint(self): """ @@ -284,7 +336,9 @@ class CrowdsourceHinterTest(unittest.TestCase): def test_template_feedback(self): """ Test the templates for get_feedback. - """ + NOT FINISHED + + from lxml import etree m = CHModuleFactory.create() def fake_get_feedback(get): @@ -297,9 +351,11 @@ class CrowdsourceHinterTest(unittest.TestCase): m.get_feedback = fake_get_feedback json_in = {'problem_name': '42.5'} out = json.loads(m.handle_ajax('get_feedback', json_in))['contents'] - - - + html_tree = etree.XML(out) + # To be continued... + + """ + pass diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index a253f9f639..f05bb34c40 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -4,7 +4,7 @@ <%def name="get_hint()"> % if best_hint != '': -

    Other students who arrvied at the wrong answer of ${answer} recommend the following hints:

    +

    Other students who arrived at the wrong answer of ${answer} recommend the following hints:

    • ${best_hint}
    • % endif diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 520255a8fc..96ea91eabc 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -1,28 +1,17 @@ -''' +""" Views for hint management. -''' -from collections import defaultdict -import csv +Along with the crowdsource_hinter xmodule, this code is still +experimental, and should not be used in new courses, yet. +""" + import json -import logging -from markupsafe import escape -import os import re -import requests -from requests.status_codes import codes -import urllib -from collections import OrderedDict -from StringIO import StringIO - -from django.conf import settings -from django.contrib.auth.models import User, Group from django.http import HttpResponse, Http404 from django_future.csrf import ensure_csrf_cookie -from django.views.decorators.cache import cache_control + from mitxmako.shortcuts import render_to_response, render_to_string -from django.core.urlresolvers import reverse from courseware.courses import get_course_with_access from courseware.models import XModuleContentField @@ -43,7 +32,9 @@ def hint_manager(request, course_id): field = request.POST['field'] if not (field == 'mod_queue' or field == 'hints'): # Invalid field. (Don't let users continue - they may overwrite other db's) - return + out = 'Error in hint manager - an invalid field was accessed.' + return HttpResponse(out) + if request.POST['op'] == 'delete hints': delete_hints(request, course_id, field) if request.POST['op'] == 'switch fields': @@ -58,12 +49,23 @@ def hint_manager(request, course_id): return HttpResponse(json.dumps({'success': True, 'contents': rendered_html})) - def get_hints(request, course_id, field): - # field indicates the database entry that we are modifying. - # Right now, the options are 'hints' or 'mod_queue'. - # DON'T TRUST field attributes that come from ajax. Use an if statement - # to make sure the field is valid before plugging into functions. + """ + Load all of the hints submitted to the course. + + Args: + request -- Django request object. + course_id -- The course id, like 'Me/19.002/test_course' + field -- Either 'hints' or 'mod_queue'; specifies which set of hints to load. + + Keys in returned dict: + - field: Same as input + - other_field: 'mod_queue' if field == 'hints'; and vice-versa. + - field_label, other_field_label: English name for the above. + - all_hints: A list of [answer, pk dict] pairs, representing all hints. + Sorted by answer. + - id_to_name: A dictionary mapping problem id to problem name. + """ if field == 'mod_queue': other_field = 'hints' @@ -76,47 +78,60 @@ def get_hints(request, course_id, field): chopped_id = '/'.join(course_id.split('/')[:-1]) chopped_id = re.escape(chopped_id) all_hints = XModuleContentField.objects.filter(field_name=field, definition_id__regex=chopped_id) + # big_out_dict[problem id] = [[answer, {pk: [hint, votes]}], sorted by answer] big_out_dict = {} - name_dict = {} - for problem in all_hints: - loc = Location(problem.definition_id) + # name_dict[problem id] = Display name of problem + id_to_name = {} + + for hints_by_problem in all_hints: + loc = Location(hints_by_problem.definition_id) try: descriptor = modulestore().get_items(loc)[0] except IndexError: # Sometimes, the problem is no longer in the course. Just # don't include said problem. continue - name_dict[problem.definition_id] = descriptor.get_children()[0].display_name + id_to_name[hints_by_problem.definition_id] = descriptor.get_children()[0].display_name # Answer list contains (answer, dict_of_hints) tuples. def answer_sorter(thing): - ''' + """ thing is a tuple, where thing[0] contains an answer, and thing[1] contains - a dict of hints. This function returns an index based on thing[0], which + a dict of hints. This function returns an index based on thing[0], which is used as a key to sort the list of things. - ''' + """ try: return float(thing[0]) except ValueError: # Put all non-numerical answers first. return float('-inf') - answer_list = sorted(json.loads(problem.value).items(), key=answer_sorter) - big_out_dict[problem.definition_id] = answer_list + answer_list = sorted(json.loads(hints_by_problem.value).items(), key=answer_sorter) + big_out_dict[hints_by_problem.definition_id] = answer_list render_dict = {'field': field, 'other_field': other_field, 'field_label': field_label, 'other_field_label': other_field_label, 'all_hints': big_out_dict, - 'id_to_name': name_dict} + 'id_to_name': id_to_name} return render_dict + def delete_hints(request, course_id, field): - ''' - Deletes the hints specified by the [problem_defn_id, answer, pk] tuples in the numbered - fields of request.POST. - ''' + """ + Deletes the hints specified. + + request.POST contains some fields keyed by integers. Each such field contains a + [problem_defn_id, answer, pk] tuple. These tuples specify the hints to be deleted. + + Example request.POST: + {'op': 'delete_hints', + 'field': 'mod_queue', + 1: ['problem_whatever', '42.0', 3], + 2: ['problem_whatever', '32.5', 12]} + """ + for key in request.POST: if key == 'op' or key == 'field': continue @@ -129,31 +144,37 @@ def delete_hints(request, course_id, field): this_problem.value = json.dumps(problem_dict) this_problem.save() + def change_votes(request, course_id, field): - ''' - Updates the number of votes. The numbered fields of request.POST contain - [problem_id, answer, pk, new_votes] tuples. + """ + Updates the number of votes. + + The numbered fields of request.POST contain [problem_id, answer, pk, new_votes] tuples. - Very similar to delete_hints. Is there a way to merge them? Nah, too complicated. - ''' + """ + for key in request.POST: if key == 'op' or key == 'field': continue problem_id, answer, pk, new_votes = request.POST.getlist(key) this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(this_problem.value) + # problem_dict[answer][pk] points to a [hint_text, #votes] pair. problem_dict[answer][pk][1] = new_votes this_problem.value = json.dumps(problem_dict) this_problem.save() + def add_hint(request, course_id, field): - ''' - Add a new hint. POST: + """ + Add a new hint. request.POST: op field problem - The problem id answer - The answer to which a hint will be added hint - The text of the hint - ''' + """ + problem_id = request.POST['problem'] answer = request.POST['answer'] hint_text = request.POST['hint'] @@ -171,13 +192,15 @@ def add_hint(request, course_id, field): this_problem.value = json.dumps(problem_dict) this_problem.save() + def approve(request, course_id, field): - ''' + """ Approve a list of hints, moving them from the mod_queue to the real hint list. POST: op, field (some number) -> [problem, answer, pk] - ''' + """ + for key in request.POST: if key == 'op' or key == 'field': continue @@ -197,29 +220,4 @@ def approve(request, course_id, field): problem_dict[answer] = {} problem_dict[answer][pk] = hint_to_move problem_in_hints.value = json.dumps(problem_dict) - problem_in_hints.save() - - - - - - - - - - - - - - - - - - - - - - - - - + problem_in_hints.save() \ No newline at end of file diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py new file mode 100644 index 0000000000..44e676dd83 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -0,0 +1,79 @@ +from factory import DjangoModelFactory +import unittest +import nose.tools +import json + +from django.http import Http404 +from django.test.client import Client +from django.test.utils import override_settings +import mitxmako.middleware + +from courseware.models import XModuleContentField +import instructor.hint_manager as view +from student.tests.factories import UserFactory, AdminFactory +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class HintsFactory(DjangoModelFactory): + FACTORY_FOR = XModuleContentField + definition_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' + field_name = 'hints' + value = json.dumps({'1.0': + {'1': ['Hint 1', 2], + '3': ['Hint 3', 12]}, + '2.0': + {'4': ['Hint 4', 3]} + }) + +class ModQueueFactory(DjangoModelFactory): + FACTORY_FOR = XModuleContentField + definition_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' + field_name = 'mod_queue' + value = json.dumps({'2.0': + {'2': ['Hint 2', 1]} + }) + +class PKFactory(DjangoModelFactory): + FACTORY_FOR = XModuleContentField + definition_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' + field_name = 'hint_pk' + value = 5 + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class HintManagerTest(ModuleStoreTestCase): + + def setUp(self): + """ + Makes a course, which will be the same for all tests. + Set up mako middleware, which is necessary for template rendering to happen. + """ + course = CourseFactory.create(org='Me', number='19.002', display_name='test_course') + # mitxmako.middleware.MakoMiddleware() + + + def test_student_block(self): + """ + Makes sure that students cannot see the hint management view. + """ + c = Client() + user = UserFactory.create(username='robot', email='robot@edx.org', password='test') + c.login(username='robot', password='test') + out = c.get('/courses/Me/19.002/test_course/hint_manager') + print out + self.assertTrue('Sorry, but students are not allowed to access the hint manager!' in out.content) + + def test_staff_access(self): + """ + Makes sure that staff can access the hint management view. + """ + c = Client() + user = UserFactory.create(username='robot', email='robot@edx.org', password='test', is_staff=True) + c.login(username='robot', password='test') + out = c.get('/courses/Me/19.002/test_course/hint_manager') + print out + self.assertTrue('Hints Awaiting Moderation' in out.content) + + + From f219228e4a7b37b77065eb3db89b8d08692145f2 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 26 Jun 2013 16:25:24 -0400 Subject: [PATCH 133/161] Made the hint management instructor view off by default. --- lms/envs/common.py | 3 +++ lms/urls.py | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 141bc127be..8b2a1f28cf 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -141,6 +141,9 @@ MITX_FEATURES = { # Enable instructor dash to submit background tasks 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, + + # Allow use of the hint managment instructor view. + 'ENABLE_HINTER_INSTRUCTOR_VIEW': False, } # Used for A/B testing diff --git a/lms/urls.py b/lms/urls.py index febd0f1c0e..4f9af149bc 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -264,9 +264,6 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/instructor$', 'instructor.views.instructor_dashboard', name="instructor_dashboard"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/hint_manager$', - 'instructor.hint_manager.hint_manager', name="hint_manager"), - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/gradebook$', 'instructor.views.gradebook', name='gradebook'), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/grade_summary$', @@ -433,6 +430,13 @@ if settings.MITX_FEATURES.get('ENABLE_DEBUG_RUN_PYTHON'): url(r'^debug/run_python', 'debug.views.run_python'), ) +# Crowdsourced hinting instructor manager. +if settings.MITX_FEATURES.get('ENABLE_HINTER_INSTRUCTOR_VIEW'): + urlpatterns += ( + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/hint_manager$', + 'instructor.hint_manager.hint_manager', name="hint_manager"), + ) + urlpatterns = patterns(*urlpatterns) if settings.DEBUG: From 6890563d43f76907fc94e09db1a9dc1348747093 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 27 Jun 2013 10:23:12 -0400 Subject: [PATCH 134/161] Fixed broken tests. Made the hint manager enabled in testing environments - this lets us test the hint manager. --- common/lib/xmodule/xmodule/crowdsource_hinter.py | 2 +- .../lib/xmodule/xmodule/tests/test_crowdsource_hinter.py | 1 + lms/djangoapps/instructor/tests/test_hint_manager.py | 2 +- lms/envs/test.py | 2 ++ lms/templates/courseware/hint_manager.html | 8 ++++---- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 664cf85f1a..8a238a1779 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -203,7 +203,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if answer in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: - if hint_id is None: + if hint_id is not None: try: index_to_hints[i].append((self.hints[answer][str(hint_id)][0], hint_id)) except KeyError: diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index 31614c4849..c12fb1f160 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -207,6 +207,7 @@ class CrowdsourceHinterTest(unittest.TestCase): ) json_in = {'problem_name': '42.5'} out = m.get_feedback(json_in) + print out['index_to_hints'] self.assertTrue(len(out['index_to_hints'][0])==2) diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 44e676dd83..1da83dcc43 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -50,13 +50,13 @@ class HintManagerTest(ModuleStoreTestCase): Set up mako middleware, which is necessary for template rendering to happen. """ course = CourseFactory.create(org='Me', number='19.002', display_name='test_course') - # mitxmako.middleware.MakoMiddleware() def test_student_block(self): """ Makes sure that students cannot see the hint management view. """ + nose.tools.set_trace() c = Client() user = UserFactory.create(username='robot', email='robot@edx.org', password='test') c.login(username='robot', password='test') diff --git a/lms/envs/test.py b/lms/envs/test.py index e9b683487e..d335fcd600 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -27,6 +27,8 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = False MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True +MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True diff --git a/lms/templates/courseware/hint_manager.html b/lms/templates/courseware/hint_manager.html index 394792f892..ebd7091a09 100644 --- a/lms/templates/courseware/hint_manager.html +++ b/lms/templates/courseware/hint_manager.html @@ -15,7 +15,7 @@ function setup() { field = $("#field-label").html() changed_votes = [] - $(".votes").live('input', function() { + $(".votes").on('input', function() { changed_votes.push($(this)) }); @@ -43,9 +43,9 @@ 'field': field} for (var i=0; i Date: Thu, 27 Jun 2013 15:20:08 -0400 Subject: [PATCH 135/161] Fixed a small, but dangerous, string-to-integer casting bug in hint_manager. Expanded tests of hint_manager. Enabled the hint_manager by default in development environments. --- .../xmodule/tests/test_crowdsource_hinter.py | 73 +++++++++++ lms/djangoapps/instructor/hint_manager.py | 29 +++-- .../instructor/tests/test_hint_manager.py | 122 ++++++++++++------ lms/envs/dev.py | 1 + 4 files changed, 177 insertions(+), 48 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index c12fb1f160..1bb04654f0 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -5,6 +5,7 @@ import random import xmodule from xmodule.crowdsource_hinter import CrowdsourceHinterModule +from xmodule.vertical_module import VerticalModule, VerticalDescriptor from xmodule.modulestore import Location from django.http import QueryDict @@ -96,6 +97,65 @@ class CHModuleFactory(object): return module +class VerticalWithModulesFactory(object): + """ + Makes a vertical with several crowdsourced hinter modules inside. + Used to make sure that several crowdsourced hinter modules can co-exist + on one vertical. + """ + + sample_problem_xml = """ + + + +

      Test numerical problem.

      + + + + +
      +

      Explanation

      +

      If you look at your hand, you can count that you have five fingers.

      +
      +
      +
      +
      + + + +

      Another test numerical problem.

      + + + + +
      +

      Explanation

      +

      If you look at your hand, you can count that you have five fingers.

      +
      +
      +
      +
      +
      + """ + + num = 0 + + @staticmethod + def next_num(): + CHModuleFactory.num += 1 + return CHModuleFactory.num + + @staticmethod + def create(): + location = Location(["i4x", "edX", "capa_test", "vertical", + "SampleVertical{0}".format(CHModuleFactory.next_num())]) + model_data = {'data': VerticalWithModulesFactory.sample_problem_xml} + system = get_test_system() + descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system) + module = VerticalModule(system, descriptor, model_data) + + return module + class FakeChild(object): """ @@ -132,6 +192,19 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue('This is supposed to be test html.' in out_html) self.assertTrue('this/is/a/fake/ajax/url' in out_html) + def test_gethtml_multiple(self): + """ + Makes sure that multiple crowdsourced hinters play nice, when get_html + is called. + NOT WORKING RIGHT NOW + """ + return + m = VerticalWithModulesFactory.create() + out_html = m.get_html() + print out_html + self.assertTrue('Test numerical problem.' in out_html) + self.assertTrue('Another test numerical problem.' in out_html) + def test_gethint_0hint(self): """ Someone asks for a hint, when there's no hint to give. diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 96ea91eabc..056784947d 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -66,7 +66,6 @@ def get_hints(request, course_id, field): Sorted by answer. - id_to_name: A dictionary mapping problem id to problem name. """ - if field == 'mod_queue': other_field = 'hints' field_label = 'Hints Awaiting Moderation' @@ -85,13 +84,10 @@ def get_hints(request, course_id, field): for hints_by_problem in all_hints: loc = Location(hints_by_problem.definition_id) - try: - descriptor = modulestore().get_items(loc)[0] - except IndexError: - # Sometimes, the problem is no longer in the course. Just - # don't include said problem. + name = location_to_problem_name(loc) + if name is None: continue - id_to_name[hints_by_problem.definition_id] = descriptor.get_children()[0].display_name + id_to_name[hints_by_problem.definition_id] = name # Answer list contains (answer, dict_of_hints) tuples. def answer_sorter(thing): @@ -117,6 +113,19 @@ def get_hints(request, course_id, field): 'id_to_name': id_to_name} return render_dict +def location_to_problem_name(loc): + """ + Given the location of a crowdsource_hinter module, try to return the name of the + problem it wraps around. Return None if the hinter no longer exists. + """ + try: + descriptor = modulestore().get_items(loc)[0] + return descriptor.get_children()[0].display_name + except IndexError: + # Sometimes, the problem is no longer in the course. Just + # don't include said problem. + return None + def delete_hints(request, course_id, field): """ @@ -128,8 +137,8 @@ def delete_hints(request, course_id, field): Example request.POST: {'op': 'delete_hints', 'field': 'mod_queue', - 1: ['problem_whatever', '42.0', 3], - 2: ['problem_whatever', '32.5', 12]} + 1: ['problem_whatever', '42.0', '3'], + 2: ['problem_whatever', '32.5', '12']} """ for key in request.POST: @@ -160,7 +169,7 @@ def change_votes(request, course_id, field): this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(this_problem.value) # problem_dict[answer][pk] points to a [hint_text, #votes] pair. - problem_dict[answer][pk][1] = new_votes + problem_dict[answer][pk][1] = int(new_votes) this_problem.value = json.dumps(problem_dict) this_problem.save() diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 1da83dcc43..44e8458e19 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -1,45 +1,20 @@ -from factory import DjangoModelFactory import unittest import nose.tools import json from django.http import Http404 -from django.test.client import Client +from django.test.client import Client, RequestFactory from django.test.utils import override_settings import mitxmako.middleware from courseware.models import XModuleContentField +from courseware.tests.factories import ContentFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE import instructor.hint_manager as view from student.tests.factories import UserFactory, AdminFactory -from xmodule.modulestore.tests.factories import CourseFactory -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory - -class HintsFactory(DjangoModelFactory): - FACTORY_FOR = XModuleContentField - definition_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' - field_name = 'hints' - value = json.dumps({'1.0': - {'1': ['Hint 1', 2], - '3': ['Hint 3', 12]}, - '2.0': - {'4': ['Hint 4', 3]} - }) - -class ModQueueFactory(DjangoModelFactory): - FACTORY_FOR = XModuleContentField - definition_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' - field_name = 'mod_queue' - value = json.dumps({'2.0': - {'2': ['Hint 2', 1]} - }) - -class PKFactory(DjangoModelFactory): - FACTORY_FOR = XModuleContentField - definition_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' - field_name = 'hint_pk' - value = 5 @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class HintManagerTest(ModuleStoreTestCase): @@ -49,18 +24,39 @@ class HintManagerTest(ModuleStoreTestCase): Makes a course, which will be the same for all tests. Set up mako middleware, which is necessary for template rendering to happen. """ - course = CourseFactory.create(org='Me', number='19.002', display_name='test_course') + self.course = CourseFactory.create(org='Me', number='19.002', display_name='test_course') + self.url = '/courses/Me/19.002/test_course/hint_manager' + self.user = UserFactory.create(username='robot', email='robot@edx.org', password='test', is_staff=True) + self.c = Client() + self.c.login(username='robot', password='test') + self.problem_id = 'i4x://Me/19.002/crowdsource_hinter/crowdsource_hinter_001' + self.course_id = 'Me/19.002/test_course' + ContentFactory.create(field_name='hints', + definition_id=self.problem_id, + value=json.dumps({'1.0': {'1': ['Hint 1', 2], + '3': ['Hint 3', 12]}, + '2.0': {'4': ['Hint 4', 3]} + })) + ContentFactory.create(field_name='mod_queue', + definition_id=self.problem_id, + value=json.dumps({'2.0': {'2': ['Hint 2', 1]}})) + + ContentFactory.create(field_name='hint_pk', + definition_id=self.problem_id, + value=5) + # Mock out location_to_problem_name, which ordinarily accesses the modulestore. + # (I can't figure out how to get fake structures into the modulestore.) + view.location_to_problem_name = lambda loc: "Test problem" def test_student_block(self): """ Makes sure that students cannot see the hint management view. """ - nose.tools.set_trace() c = Client() - user = UserFactory.create(username='robot', email='robot@edx.org', password='test') - c.login(username='robot', password='test') - out = c.get('/courses/Me/19.002/test_course/hint_manager') + user = UserFactory.create(username='student', email='student@edx.org', password='test') + c.login(username='student', password='test') + out = c.get(self.url) print out self.assertTrue('Sorry, but students are not allowed to access the hint manager!' in out.content) @@ -68,12 +64,62 @@ class HintManagerTest(ModuleStoreTestCase): """ Makes sure that staff can access the hint management view. """ - c = Client() - user = UserFactory.create(username='robot', email='robot@edx.org', password='test', is_staff=True) - c.login(username='robot', password='test') - out = c.get('/courses/Me/19.002/test_course/hint_manager') + out = self.c.get('/courses/Me/19.002/test_course/hint_manager') print out self.assertTrue('Hints Awaiting Moderation' in out.content) + def test_invalid_field_access(self): + """ + Makes sure that field names other than 'mod_queue' and 'hints' are + rejected. + """ + out = self.c.post(self.url, {'op': 'delete hints', 'field': 'all your private data'}) + # Keep this around for reference - might be useful later. + # request = RequestFactory() + # post = request.post(self.url, {'op': 'delete hints', 'field': 'all your private data'}) + # out = view.hint_manager(post, 'Me/19.002/test_course') + print out + self.assertTrue('an invalid field was accessed' in out.content) + + def test_gethints(self): + """ + Checks that gethints returns the right data. + """ + request = RequestFactory() + post = request.post(self.url, {'field': 'mod_queue'}) + out = view.get_hints(post, self.course_id, 'mod_queue') + print out + self.assertTrue(out['other_field'] == 'hints') + expected = {self.problem_id: [(u'2.0', {u'2': [u'Hint 2', 1]})]} + self.assertTrue(out['all_hints'] == expected) + + def test_deletehints(self): + """ + Checks that delete_hints deletes the right stuff. + """ + request = RequestFactory() + post = request.post(self.url, {'field': 'hints', + 'op': 'delete hints', + 1: [self.problem_id, '1.0', '1']}) + view.delete_hints(post, self.course_id, 'hints') + problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value + self.assertTrue('1' not in json.loads(problem_hints)['1.0']) + + def test_changevotes(self): + """ + Checks that vote changing works. + """ + request = RequestFactory() + post = request.post(self.url, {'field': 'hints', + 'op': 'change votes', + 1: [self.problem_id, '1.0', '1', 5]}) + view.change_votes(post, self.course_id, 'hints') + problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value + # hints[answer][hint_pk (string)] = [hint text, vote count] + print json.loads(problem_hints)['1.0']['1'] + self.assertTrue(json.loads(problem_hints)['1.0']['1'][1] == 5) + + + diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 813f9cf32c..2ceebf39b8 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True +MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True WIKI_ENABLED = True From 970a7f0916df10957499e78fb8782e4fd72ec1e1 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 27 Jun 2013 16:57:48 -0400 Subject: [PATCH 136/161] Fixed some docstring formatting things. Expanded test coverage a little. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 20 +++---- .../xmodule/tests/test_crowdsource_hinter.py | 15 +++++ lms/djangoapps/instructor/hint_manager.py | 30 +++++----- .../instructor/tests/test_hint_manager.py | 57 +++++++++++++++++-- 4 files changed, 92 insertions(+), 30 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 8a238a1779..341a2598ef 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -129,11 +129,11 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Called by hinter javascript after a problem is graded as incorrect. Args: - get -- must be interpretable by capa_answer_to_str. + `get` -- must be interpretable by capa_answer_to_str. Output keys: - - best_hint is the hint text with the most votes. - - rand_hint_1 and rand_hint_2 are two random hints to the answer in get. - - answer is the parsed answer that was submitted. + - 'best_hint' is the hint text with the most votes. + - 'rand_hint_1' and 'rand_hint_2' are two random hints to the answer in `get`. + - 'answer' is the parsed answer that was submitted. """ answer = self.capa_answer_to_str(get) # Look for a hint to give. @@ -176,10 +176,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): The student got it correct. Ask him to vote on hints, or submit a hint. Args: - get -- not actually used. (It is assumed that the answer is correct.) + `get` -- not actually used. (It is assumed that the answer is correct.) Output keys: - - index_to_hints maps previous answer indices to hints that the user saw earlier. - - index_to_answer maps previous answer indices to the actual answer submitted. + - 'index_to_hints' maps previous answer indices to hints that the user saw earlier. + - 'index_to_answer' maps previous answer indices to the actual answer submitted. """ # The student got it right. # Did he submit at least one wrong answer? @@ -217,10 +217,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Tally a user's vote on his favorite hint. Args: - get -- expected to have the following keys: + `get` -- expected to have the following keys: 'answer': ans_no (index in previous_answers) 'hint': hint_pk - Returns key hint_and_votes, a list of (hint_text, #votes) pairs. + Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs. """ if self.user_voted: return json.dumps({'contents': 'Sorry, but you have already voted!'}) @@ -250,7 +250,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Take a hint submission and add it to the database. Args: - get -- expected to have the following keys: + `get` -- expected to have the following keys: 'answer': answer index in previous_answers 'hint': text of the new hint that the user is adding Returns a thank-you message. diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index 1bb04654f0..b97fb34d9b 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -192,6 +192,21 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue('This is supposed to be test html.' in out_html) self.assertTrue('this/is/a/fake/ajax/url' in out_html) + def test_gethtml_nochild(self): + """ + get_html, except the module has no child :( Should return a polite + error message. + """ + m = CHModuleFactory.create() + def fake_get_display_items(): + """ + Returns no children. + """ + return [] + m.get_display_items = fake_get_display_items + out_html = m.get_html() + self.assertTrue('Error in loading crowdsourced hinter' in out_html) + def test_gethtml_multiple(self): """ Makes sure that multiple crowdsourced hinters play nice, when get_html diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 056784947d..4d5b35356b 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -54,17 +54,17 @@ def get_hints(request, course_id, field): Load all of the hints submitted to the course. Args: - request -- Django request object. - course_id -- The course id, like 'Me/19.002/test_course' - field -- Either 'hints' or 'mod_queue'; specifies which set of hints to load. + `request` -- Django request object. + `course_id` -- The course id, like 'Me/19.002/test_course' + `field` -- Either 'hints' or 'mod_queue'; specifies which set of hints to load. Keys in returned dict: - - field: Same as input - - other_field: 'mod_queue' if field == 'hints'; and vice-versa. - - field_label, other_field_label: English name for the above. - - all_hints: A list of [answer, pk dict] pairs, representing all hints. + - 'field': Same as input + - 'other_field': 'mod_queue' if `field` == 'hints'; and vice-versa. + - 'field_label', 'other_field_label': English name for the above. + - 'all_hints': A list of [answer, pk dict] pairs, representing all hints. Sorted by answer. - - id_to_name: A dictionary mapping problem id to problem name. + - 'id_to_name': A dictionary mapping problem id to problem name. """ if field == 'mod_queue': other_field = 'hints' @@ -92,8 +92,8 @@ def get_hints(request, course_id, field): def answer_sorter(thing): """ - thing is a tuple, where thing[0] contains an answer, and thing[1] contains - a dict of hints. This function returns an index based on thing[0], which + `thing` is a tuple, where `thing[0]` contains an answer, and `thing[1]` contains + a dict of hints. This function returns an index based on `thing[0]`, which is used as a key to sort the list of things. """ try: @@ -131,10 +131,10 @@ def delete_hints(request, course_id, field): """ Deletes the hints specified. - request.POST contains some fields keyed by integers. Each such field contains a + `request.POST` contains some fields keyed by integers. Each such field contains a [problem_defn_id, answer, pk] tuple. These tuples specify the hints to be deleted. - Example request.POST: + Example `request.POST`: {'op': 'delete_hints', 'field': 'mod_queue', 1: ['problem_whatever', '42.0', '3'], @@ -158,8 +158,8 @@ def change_votes(request, course_id, field): """ Updates the number of votes. - The numbered fields of request.POST contain [problem_id, answer, pk, new_votes] tuples. - - Very similar to delete_hints. Is there a way to merge them? Nah, too complicated. + The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples. + - Very similar to `delete_hints`. Is there a way to merge them? Nah, too complicated. """ for key in request.POST: @@ -176,7 +176,7 @@ def change_votes(request, course_id, field): def add_hint(request, course_id, field): """ - Add a new hint. request.POST: + Add a new hint. `request.POST`: op field problem - The problem id diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 44e8458e19..39227f93d6 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -74,13 +74,17 @@ class HintManagerTest(ModuleStoreTestCase): rejected. """ out = self.c.post(self.url, {'op': 'delete hints', 'field': 'all your private data'}) - # Keep this around for reference - might be useful later. - # request = RequestFactory() - # post = request.post(self.url, {'op': 'delete hints', 'field': 'all your private data'}) - # out = view.hint_manager(post, 'Me/19.002/test_course') print out self.assertTrue('an invalid field was accessed' in out.content) + def test_switchfields(self): + """ + Checks that the op: 'switch fields' POST request works. + """ + out = self.c.post(self.url, {'op': 'switch fields', 'field': 'mod_queue'}) + print out + self.assertTrue('Hint 2' in out.content) + def test_gethints(self): """ Checks that gethints returns the right data. @@ -93,6 +97,21 @@ class HintManagerTest(ModuleStoreTestCase): expected = {self.problem_id: [(u'2.0', {u'2': [u'Hint 2', 1]})]} self.assertTrue(out['all_hints'] == expected) + def test_gethints_other(self): + """ + Same as above, with hints instead of mod_queue + """ + request = RequestFactory() + post = request.post(self.url, {'field': 'hints'}) + out = view.get_hints(post, self.course_id, 'hints') + print out + self.assertTrue(out['other_field'] == 'mod_queue') + expected = {self.problem_id: [('1.0', {'1': ['Hint 1', 2], + '3': ['Hint 3', 12]}), + ('2.0', {'4': ['Hint 4', 3]}) + ]} + self.assertTrue(out['all_hints'] == expected) + def test_deletehints(self): """ Checks that delete_hints deletes the right stuff. @@ -119,7 +138,35 @@ class HintManagerTest(ModuleStoreTestCase): print json.loads(problem_hints)['1.0']['1'] self.assertTrue(json.loads(problem_hints)['1.0']['1'][1] == 5) - + def test_addhint(self): + """ + Check that instructors can add new hints. + """ + request = RequestFactory() + post = request.post(self.url, {'field': 'mod_queue', + 'op': 'add hint', + 'problem': self.problem_id, + 'answer': '3.14', + 'hint': 'This is a new hint.'}) + view.add_hint(post, self.course_id, 'mod_queue') + problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value + self.assertTrue('3.14' in json.loads(problem_hints)) + + def test_approve(self): + """ + Check that instructors can approve hints. (Move them + from the mod_queue to the hints.) + """ + request = RequestFactory() + post = request.post(self.url, {'field': 'mod_queue', + 'op': 'approve', + 1: [self.problem_id, '2.0', '2']}) + view.approve(post, self.course_id, 'mod_queue') + problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value + self.assertTrue('2.0' not in json.loads(problem_hints) or len(json.loads(problem_hints)['2.0']) == 0) + problem_hints = XModuleContentField.objects.get(field_name='hints', definition_id=self.problem_id).value + self.assertTrue(json.loads(problem_hints)['2.0']['2'] == ['Hint 2', 1]) + self.assertTrue(len(json.loads(problem_hints)['2.0']) == 2) From aae5758a9992f89377f730241f8faf6345a6c767 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 28 Jun 2013 09:27:47 -0400 Subject: [PATCH 137/161] Edited text of crowdsourced hinter template to Piotr's suggestions. Added some HTML formatting to same. --- .../js/src/crowdsource_hinter/display.coffee | 7 +++ common/templates/hinter_display.html | 54 +++++++++++++++---- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index ea42601622..cbc5c6edd1 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -34,6 +34,13 @@ class @Hinter @$('input.submit-hint').click @submit_hint @$('.custom-hint').click @clear_default_text @$('#answer-tabs').tabs({active: 0}) + @$('.expand-goodhint').click @expand_goodhint + + expand_goodhint: => + if @$('.goodhint').css('display') == 'none' + @$('.goodhint').css('display', 'block') + else + @$('.goodhint').css('display', 'none') vote: (eventObj) => target = @$(eventObj.currentTarget) diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index f05bb34c40..830f69c382 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -4,7 +4,7 @@ <%def name="get_hint()"> % if best_hint != '': -

      Other students who arrived at the wrong answer of ${answer} recommend the following hints:

      +

      Hints from students who made similar mistakes:

      • ${best_hint}
      • % endif @@ -65,11 +65,12 @@ +

        Participation in the hinting system is strictly optional, and will not influence your grade.
        - Help us improve our hinting system. Start by picking one of your previous incorrect answers from below: -

        + Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below: +

          @@ -82,19 +83,54 @@
          % if index in index_to_hints and len(index_to_hints[index]) > 0: - Which hint was most helpful when you got the wrong answer of ${answer}? -
          +

          + Which hint would be most effective to show a student who also got ${answer}? +

          % for hint_text, hint_pk in index_to_hints[index]: +

          ${hint_text} -
          +

          % endfor +

          Don't like any of the hints above? You can also submit your own. % else: - Write a hint for other students who get the wrong answer of ${answer}. +

          % endif - Try to describe what concepts you misunderstood, or what mistake you made. Please don't - give away the answer. + What hint would you give a student who made the same mistake you did? Please don't give away the answer. + Read about what makes a good hint. +

          + - +

          % endfor
        +

        Read about what makes a good hint.

        + + <%def name="show_votes()"> @@ -172,6 +123,3 @@ What would you say to help someone who got this wrong answer? % if op == "vote": ${show_votes()} % endif - - - From 0c4d6ba6d90b735190958bd9a577f882ff2df14c Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 28 Jun 2013 15:43:26 -0400 Subject: [PATCH 139/161] Made explanation for hints field in crowdsource_hinter.py more clear. Fixed various commenting things. Removed an unused function in the crowdsourced module coffeescript. Improved commenting in hint_manager. Fixed pep and pylint violations. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 10 +- .../js/src/crowdsource_hinter/display.coffee | 7 -- .../xmodule/tests/test_crowdsource_hinter.py | 94 ++++++++----------- common/templates/hinter_display.html | 2 +- lms/djangoapps/instructor/hint_manager.py | 14 ++- .../instructor/tests/test_hint_manager.py | 20 ++-- 6 files changed, 62 insertions(+), 85 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 19c447d014..f84b366d2c 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -29,13 +29,16 @@ class CrowdsourceHinterFields(object): default='False') debug = String(help='String "True"/"False" - allows multiple voting', scope=Scope.content, default='False') - # hints[answer] = {str(pk): [hint_text, #votes]} + # Usage: hints[answer] = {str(pk): [hint_text, #votes]} + # hints is a dictionary that takes answer keys. + # Each value is itself a dictionary, accepting hint_pk strings as keys, + # and returning [hint text, #votes] pairs as values hints = Dict(help='A dictionary containing all the active hints.', scope=Scope.content, default={}) mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, default={}) hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) # A list of previous answers this student made to this problem. - # Of the form (answer, (hint_pk_1, hint_pk_2, hint_pk_3)) for each problem. hint_pk's are + # Of the form [answer, [hint_pk_1, hint_pk_2, hint_pk_3]] for each problem. hint_pk's are # None if the hint was not given. previous_answers = List(help='A list of previous submissions.', scope=Scope.user_state, default=[]) user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', @@ -166,7 +169,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): random.sample(local_hints[answer].items(), 2) rand_hint_1 = rand_hint_1[0] rand_hint_2 = rand_hint_2[0] - self.previous_answers += [(answer, (best_hint_index, hint_index_1, hint_index_2))] + self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]] return {'best_hint': best_hint, 'rand_hint_1': rand_hint_1, @@ -185,7 +188,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): """ # The student got it right. # Did he submit at least one wrong answer? - out = '' if len(self.previous_answers) == 0: # No. Nothing to do here. return diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 81872a5ef4..f8bc6037db 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -61,13 +61,6 @@ class @Hinter target.val('') target.data('cleared', true) - feedback_ui_change: => - # Make all of the previous-answer divs hidden. - @$('.previous-answer').css('display', 'none') - # But, now find the selected div, and make it visible. - selector = '#previous-answer-' + @$('#feedback-select option:selected').attr('value') - @$(selector).css('display', 'inline') - render: (content) -> if content # Trim leading and trailing whitespace diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index b97fb34d9b..f57e28ef46 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -1,19 +1,19 @@ -from mock import Mock, patch +""" +Tests the crowdsourced hinter xmodule. +""" + +from mock import Mock import unittest import copy -import random -import xmodule from xmodule.crowdsource_hinter import CrowdsourceHinterModule from xmodule.vertical_module import VerticalModule, VerticalDescriptor -from xmodule.modulestore import Location - -from django.http import QueryDict from . import get_test_system import json + class CHModuleFactory(object): """ Helps us make a CrowdsourceHinterModule with the specified internal @@ -44,6 +44,9 @@ class CHModuleFactory(object): @staticmethod def next_num(): + """ + Helps make unique names for our mock CrowdsourceHinterModule's + """ CHModuleFactory.num += 1 return CHModuleFactory.num @@ -53,23 +56,23 @@ class CHModuleFactory(object): user_voted=None, moderate=None, mod_queue=None): - - location = Location(["i4x", "edX", "capa_test", "problem", - "SampleProblem{0}".format(CHModuleFactory.next_num())]) + """ + A factory method for making CHM's + """ model_data = {'data': CHModuleFactory.sample_problem_xml} - if hints != None: + if hints is not None: model_data['hints'] = hints else: model_data['hints'] = { '24.0': {'0': ['Best hint', 40], - '3': ['Another hint', 30], - '4': ['A third hint', 20], - '6': ['A less popular hint', 3]}, + '3': ['Another hint', 30], + '4': ['A third hint', 20], + '6': ['A less popular hint', 3]}, '25.0': {'1': ['Really popular hint', 100]} } - if mod_queue != None: + if mod_queue is not None: model_data['mod_queue'] = mod_queue else: model_data['mod_queue'] = { @@ -77,7 +80,7 @@ class CHModuleFactory(object): '26.0': {'5': ['Another non-approved hint']} } - if previous_answers != None: + if previous_answers is not None: model_data['previous_answers'] = previous_answers else: model_data['previous_answers'] = [ @@ -85,18 +88,19 @@ class CHModuleFactory(object): ['29.0', [None, None, None]] ] - if user_voted != None: + if user_voted is not None: model_data['user_voted'] = user_voted - if moderate != None: + if moderate is not None: model_data['moderate'] = moderate - + descriptor = Mock(weight="1") system = get_test_system() module = CrowdsourceHinterModule(system, descriptor, model_data) return module + class VerticalWithModulesFactory(object): """ Makes a vertical with several crowdsourced hinter modules inside. @@ -147,8 +151,6 @@ class VerticalWithModulesFactory(object): @staticmethod def create(): - location = Location(["i4x", "edX", "capa_test", "vertical", - "SampleVertical{0}".format(CHModuleFactory.next_num())]) model_data = {'data': VerticalWithModulesFactory.sample_problem_xml} system = get_test_system() descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system) @@ -166,10 +168,12 @@ class FakeChild(object): self.system.ajax_url = 'this/is/a/fake/ajax/url' def get_html(self): + """ + Return a fake html string. + """ return 'This is supposed to be test html.' - class CrowdsourceHinterTest(unittest.TestCase): """ In the below tests, '24.0' represents a wrong answer, and '42.5' represents @@ -178,10 +182,11 @@ class CrowdsourceHinterTest(unittest.TestCase): def test_gethtml(self): """ - A simple test of get_html - make sure it returns the html of the inner + A simple test of get_html - make sure it returns the html of the inner problem. """ m = CHModuleFactory.create() + def fake_get_display_items(): """ A mock of get_display_items @@ -198,6 +203,7 @@ class CrowdsourceHinterTest(unittest.TestCase): error message. """ m = CHModuleFactory.create() + def fake_get_display_items(): """ Returns no children. @@ -207,13 +213,13 @@ class CrowdsourceHinterTest(unittest.TestCase): out_html = m.get_html() self.assertTrue('Error in loading crowdsourced hinter' in out_html) + @unittest.skip("Needs to be finished.") def test_gethtml_multiple(self): """ Makes sure that multiple crowdsourced hinters play nice, when get_html is called. NOT WORKING RIGHT NOW """ - return m = VerticalWithModulesFactory.create() out_html = m.get_html() print out_html @@ -229,7 +235,7 @@ class CrowdsourceHinterTest(unittest.TestCase): m = CHModuleFactory.create() json_in = {'problem_name': '26.0'} out = m.get_hint(json_in) - self.assertTrue(out == None) + self.assertTrue(out is None) self.assertTrue(['26.0', [None, None, None]] in m.previous_answers) def test_gethint_1hint(self): @@ -242,7 +248,6 @@ class CrowdsourceHinterTest(unittest.TestCase): out = m.get_hint(json_in) self.assertTrue(out['best_hint'] == 'Really popular hint') - def test_gethint_manyhints(self): """ Someone asks for a hint, with many matching hints in the database. @@ -258,7 +263,6 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue('rand_hint_1' in out) self.assertTrue('rand_hint_2' in out) - def test_getfeedback_0wronganswers(self): """ Someone has gotten the problem correct on the first try. @@ -267,7 +271,7 @@ class CrowdsourceHinterTest(unittest.TestCase): m = CHModuleFactory.create(previous_answers=[]) json_in = {'problem_name': '42.5'} out = m.get_feedback(json_in) - self.assertTrue(out == None) + self.assertTrue(out is None) def test_getfeedback_1wronganswer_nohints(self): """ @@ -275,29 +279,24 @@ class CrowdsourceHinterTest(unittest.TestCase): answer. However, we don't actually have hints for this problem. There should be a dialog to submit a new hint. """ - m = CHModuleFactory.create(previous_answers=[['26.0',[None, None, None]]]) + m = CHModuleFactory.create(previous_answers=[['26.0', [None, None, None]]]) json_in = {'problem_name': '42.5'} out = m.get_feedback(json_in) print out['index_to_answer'] self.assertTrue(out['index_to_hints'][0] == []) self.assertTrue(out['index_to_answer'][0] == '26.0') - def test_getfeedback_1wronganswer_withhints(self): """ Same as above, except the user did see hints. There should be a voting dialog, with the correct choices, plus a hint submission dialog. """ - m = CHModuleFactory.create( - previous_answers=[ - ['24.0', [0, 3, None]]], - ) + m = CHModuleFactory.create(previous_answers=[['24.0', [0, 3, None]]]) json_in = {'problem_name': '42.5'} out = m.get_feedback(json_in) print out['index_to_hints'] - self.assertTrue(len(out['index_to_hints'][0])==2) - + self.assertTrue(len(out['index_to_hints'][0]) == 2) def test_getfeedback_missingkey(self): """ @@ -308,7 +307,7 @@ class CrowdsourceHinterTest(unittest.TestCase): previous_answers=[['24.0', [0, 100, None]]]) json_in = {'problem_name': '42.5'} out = m.get_feedback(json_in) - self.assertTrue(len(out['index_to_hints'][0])==1) + self.assertTrue(len(out['index_to_hints'][0]) == 1) def test_vote_nopermission(self): """ @@ -318,10 +317,9 @@ class CrowdsourceHinterTest(unittest.TestCase): m = CHModuleFactory.create(user_voted=True) json_in = {'answer': 0, 'hint': 1} old_hints = copy.deepcopy(m.hints) - json_out = json.loads(m.tally_vote(json_in))['contents'] + m.tally_vote(json_in) self.assertTrue(m.hints == old_hints) - def test_vote_withpermission(self): """ A user votes for a hint. @@ -336,7 +334,6 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue(['Best hint', 40] in dict_out['hint_and_votes']) self.assertTrue(['Another hint', 31] in dict_out['hint_and_votes']) - def test_submithint_nopermission(self): """ A user tries to submit a hint, but he has already voted. @@ -348,7 +345,6 @@ class CrowdsourceHinterTest(unittest.TestCase): print m.hints self.assertTrue('29.0' not in m.hints) - def test_submithint_withpermission_new(self): """ A user submits a hint to an answer for which no hints @@ -359,21 +355,19 @@ class CrowdsourceHinterTest(unittest.TestCase): m.submit_hint(json_in) self.assertTrue('29.0' in m.hints) - def test_submithint_withpermission_existing(self): """ A user submits a hint to an answer that has other hints already. """ - m = CHModuleFactory.create(previous_answers = [['25.0', [1, None, None]]]) + m = CHModuleFactory.create(previous_answers=[['25.0', [1, None, None]]]) json_in = {'answer': 0, 'hint': 'This is a new hint.'} m.submit_hint(json_in) # Make a hint request. json_in = {'problem name': '25.0'} out = m.get_hint(json_in) self.assertTrue((out['best_hint'] == 'This is a new hint.') - or (out['rand_hint_1'] == 'This is a new hint.')) - + or (out['rand_hint_1'] == 'This is a new hint.')) def test_submithint_moderate(self): """ @@ -397,7 +391,6 @@ class CrowdsourceHinterTest(unittest.TestCase): print m.hints self.assertTrue(m.hints['29.0'][0][0] == u'<script> alert("Trololo"); </script>') - def test_template_gethint(self): """ Test the templates for get_hint. @@ -421,12 +414,11 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue('A random hint' in out) self.assertTrue('Another random hint' in out) - def test_template_feedback(self): """ Test the templates for get_feedback. NOT FINISHED - + from lxml import etree m = CHModuleFactory.create() @@ -445,11 +437,3 @@ class CrowdsourceHinterTest(unittest.TestCase): """ pass - - - - - - - - diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 5ae3d3e9b4..bc49bf18bd 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -60,7 +60,7 @@ What would you say to help someone who got this wrong answer? % endfor -

        Read about what makes a good hint.

        +

        Read about what makes a good hint.

        - % if subsection.lms.start != parent_item.lms.start and subsection.lms.start: + % if subsection.lms.start and not almost_same_datetime(subsection.lms.start, parent_item.lms.start): % if parent_item.lms.start is None: -

        The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset. +

        The date above differs from the release date of + ${parent_item.display_name_with_default}, which is unset. % else:

        The date above differs from the release date of ${parent_item.display_name_with_default} – ${get_default_time_display(parent_item.lms.start)}. diff --git a/common/lib/xmodule/xmodule/tests/test_date_utils.py b/common/lib/xmodule/xmodule/tests/test_date_utils.py index d051a7c431..cbef0069dc 100644 --- a/common/lib/xmodule/xmodule/tests/test_date_utils.py +++ b/common/lib/xmodule/xmodule/tests/test_date_utils.py @@ -1,54 +1,81 @@ # Tests for xmodule.util.date_utils -from nose.tools import assert_equals -from xmodule.util import date_utils -import datetime +from nose.tools import assert_equals, assert_false +from xmodule.util.date_utils import get_default_time_display, almost_same_datetime +from datetime import datetime, timedelta, tzinfo from pytz import UTC def test_get_default_time_display(): - assert_equals("", date_utils.get_default_time_display(None)) - test_time = datetime.datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) + assert_equals("", get_default_time_display(None)) + test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=UTC) assert_equals( "Mar 12, 1992 at 15:03 UTC", - date_utils.get_default_time_display(test_time)) + get_default_time_display(test_time)) assert_equals( "Mar 12, 1992 at 15:03 UTC", - date_utils.get_default_time_display(test_time, True)) + get_default_time_display(test_time, True)) assert_equals( "Mar 12, 1992 at 15:03", - date_utils.get_default_time_display(test_time, False)) + get_default_time_display(test_time, False)) def test_get_default_time_display_notz(): - test_time = datetime.datetime(1992, 3, 12, 15, 3, 30) + test_time = datetime(1992, 3, 12, 15, 3, 30) assert_equals( "Mar 12, 1992 at 15:03 UTC", - date_utils.get_default_time_display(test_time)) + get_default_time_display(test_time)) assert_equals( "Mar 12, 1992 at 15:03 UTC", - date_utils.get_default_time_display(test_time, True)) + get_default_time_display(test_time, True)) assert_equals( "Mar 12, 1992 at 15:03", - date_utils.get_default_time_display(test_time, False)) + get_default_time_display(test_time, False)) # pylint: disable=W0232 -class NamelessTZ(datetime.tzinfo): +class NamelessTZ(tzinfo): def utcoffset(self, _dt): - return datetime.timedelta(hours=-3) + return timedelta(hours=-3) def dst(self, _dt): - return datetime.timedelta(0) + return timedelta(0) def test_get_default_time_display_no_tzname(): - assert_equals("", date_utils.get_default_time_display(None)) - test_time = datetime.datetime(1992, 3, 12, 15, 3, 30, tzinfo=NamelessTZ()) + assert_equals("", get_default_time_display(None)) + test_time = datetime(1992, 3, 12, 15, 3, 30, tzinfo=NamelessTZ()) assert_equals( "Mar 12, 1992 at 15:03-0300", - date_utils.get_default_time_display(test_time)) + get_default_time_display(test_time)) assert_equals( "Mar 12, 1992 at 15:03-0300", - date_utils.get_default_time_display(test_time, True)) + get_default_time_display(test_time, True)) assert_equals( "Mar 12, 1992 at 15:03", - date_utils.get_default_time_display(test_time, False)) + get_default_time_display(test_time, False)) + +def test_almost_same_datetime(): + assert almost_same_datetime( + datetime(2013, 5, 3, 10, 20, 30), + datetime(2013, 5, 3, 10, 21, 29) + ) + + assert almost_same_datetime( + datetime(2013, 5, 3, 11, 20, 30), + datetime(2013, 5, 3, 10, 21, 29), + timedelta(hours=1) + ) + + assert_false( + almost_same_datetime( + datetime(2013, 5, 3, 11, 20, 30), + datetime(2013, 5, 3, 10, 21, 29) + ) + ) + + assert_false( + almost_same_datetime( + datetime(2013, 5, 3, 11, 20, 30), + datetime(2013, 5, 3, 10, 21, 29), + timedelta(minutes=10) + ) + ) diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index d33a41c035..94baabcf98 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -185,6 +185,9 @@ class TestEdxJsonEncoder(unittest.TestCase): self.encoder.default(datetime(2013, 5, 3, 10, 20, 30, 0, self.null_utc_tz)) ) - def test_other_classes(self): + def test_fallthrough(self): with self.assertRaises(TypeError): self.encoder.default(None) + + with self.assertRaises(TypeError): + self.encoder.default({}) diff --git a/common/lib/xmodule/xmodule/util/date_utils.py b/common/lib/xmodule/xmodule/util/date_utils.py index 933226ede6..cb723f6c44 100644 --- a/common/lib/xmodule/xmodule/util/date_utils.py +++ b/common/lib/xmodule/xmodule/util/date_utils.py @@ -1,3 +1,4 @@ +import datetime def get_default_time_display(dt, show_timezone=True): """ Converts a datetime to a string representation. This is the default @@ -20,3 +21,14 @@ def get_default_time_display(dt, show_timezone=True): else: timezone = " UTC" return dt.strftime("%b %d, %Y at %H:%M") + timezone + + +def almost_same_datetime(dt1, dt2, allowed_delta=datetime.timedelta(minutes=1)): + """ + Returns true if these are w/in a minute of each other. (in case secs saved to db + or timezone aren't same) + + :param dt1: + :param dt2: + """ + return abs(dt1 - dt2) < allowed_delta From 274f1d17f3967d905d3f76e33360bc1f11f2c3f4 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:11:54 -0400 Subject: [PATCH 141/161] Test start_date in course settings --- cms/djangoapps/contentstore/tests/test_course_settings.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 5c2a15ac87..62829f5ae3 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -105,7 +105,6 @@ class CourseDetailsTestCase(CourseTestCase): self.assertEqual(jsondetails['string'], 'string') def test_update_and_fetch(self): - # # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions jsondetails = CourseDetails.fetch(self.course_location) jsondetails.syllabus = "bar" # encode - decode to convert date fields and other data which changes form @@ -128,6 +127,11 @@ class CourseDetailsTestCase(CourseTestCase): CourseDetails.update_from_json(jsondetails.__dict__).effort, jsondetails.effort, "After set effort" ) + jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC()) + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).start_date, + jsondetails.start_date + ) @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) def test_marketing_site_fetch(self): From db59acaa602d3a56e0804ca27d77e122054e45e9 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:14:24 -0400 Subject: [PATCH 142/161] Assert date equality directly, rather than by using a delta of 0 --- cms/djangoapps/contentstore/tests/test_course_settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 62829f5ae3..40ec2ed3c7 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -239,8 +239,7 @@ class CourseDetailsViewTest(CourseTestCase): dt1 = date.from_json(encoded[field]) dt2 = details[field] - expected_delta = datetime.timedelta(0) - self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) + self.assertEqual(dt1, dt2, msg="{} != {} at {}".format(dt1, dt2, context)) else: self.fail(field + " missing from encoded but in details at " + context) elif field in encoded and encoded[field] is not None: From 7892fd7421c39df3190c0b1f7223a8f2083d1893 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 12:42:49 -0400 Subject: [PATCH 143/161] Remove extraneous test for already handled edge case --- common/lib/xmodule/xmodule/util/date_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/util/date_utils.py b/common/lib/xmodule/xmodule/util/date_utils.py index cb723f6c44..ffd135c00d 100644 --- a/common/lib/xmodule/xmodule/util/date_utils.py +++ b/common/lib/xmodule/xmodule/util/date_utils.py @@ -12,7 +12,7 @@ def get_default_time_display(dt, show_timezone=True): if dt is None: return "" timezone = "" - if dt is not None and show_timezone: + if show_timezone: if dt.tzinfo is not None: try: timezone = " " + dt.tzinfo.tzname(dt) From ddfbe5981494a1f13a7e8465a260b9383e55ac1c Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 12:52:50 -0400 Subject: [PATCH 144/161] Make DraftModuleStore mongo only DraftModuleStore was originally designed as a mixin, but never used that way, and with the upcoming changes to use the versioned module store, never will be. This changes removes a circular dependency between mongo.py and draft.py. --- cms/envs/acceptance.py | 4 +- cms/envs/dev.py | 2 +- cms/envs/test.py | 4 +- .../lib/xmodule/xmodule/contentstore/mongo.py | 3 +- .../lib/xmodule/xmodule/modulestore/draft.py | 251 +----------------- .../xmodule/modulestore/mongo/__init__.py | 5 + .../modulestore/{mongo.py => mongo/base.py} | 16 +- .../xmodule/modulestore/mongo/draft.py | 249 +++++++++++++++++ lms/envs/cms/preview_dev.py | 2 +- 9 files changed, 270 insertions(+), 266 deletions(-) create mode 100644 common/lib/xmodule/xmodule/modulestore/mongo/__init__.py rename common/lib/xmodule/xmodule/modulestore/{mongo.py => mongo/base.py} (98%) create mode 100644 common/lib/xmodule/xmodule/modulestore/mongo/draft.py diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index c70ca98902..4d1f1f2a4b 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -28,7 +28,7 @@ MODULESTORE_OPTIONS = { MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS }, 'direct': { @@ -36,7 +36,7 @@ MODULESTORE = { 'OPTIONS': MODULESTORE_OPTIONS }, 'draft': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS } } diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 26d633484e..655092b74b 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -27,7 +27,7 @@ modulestore_options = { MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': modulestore_options }, 'direct': { diff --git a/cms/envs/test.py b/cms/envs/test.py index bd833426d6..8fe9652c07 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -53,7 +53,7 @@ MODULESTORE_OPTIONS = { MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS }, 'direct': { @@ -61,7 +61,7 @@ MODULESTORE = { 'OPTIONS': MODULESTORE_OPTIONS }, 'draft': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS } } diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index fa0fc95181..ce75adc1ee 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -2,7 +2,8 @@ from pymongo import Connection import gridfs from gridfs.errors import NoFile -from xmodule.modulestore.mongo import location_to_query, Location +from xmodule.modulestore import Location +from xmodule.modulestore.mongo.base import location_to_query from xmodule.contentstore.content import XASSET_LOCATION_TAG import logging diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 41c8e2ec1e..d9ab3878a5 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -1,248 +1,7 @@ -from datetime import datetime +""" +Backwards compatibility for old pointers to draft module store -from . import ModuleStoreBase, Location, namedtuple_to_son -from .exceptions import ItemNotFoundError -from .inheritance import own_metadata -from xmodule.exceptions import InvalidVersionError -from pytz import UTC +This modulestore has been moved to xmodule.modulestore.mongo.draft +""" -DRAFT = 'draft' -# Things w/ these categories should never be marked as version='draft' -DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] - - -def as_draft(location): - """ - Returns the Location that is the draft for `location` - """ - return Location(location).replace(revision=DRAFT) - - -def as_published(location): - """ - Returns the Location that is the published version for `location` - """ - return Location(location).replace(revision=None) - - -def wrap_draft(item): - """ - Sets `item.is_draft` to `True` if the item is a - draft, and `False` otherwise. Sets the item's location to the - non-draft location in either case - """ - setattr(item, 'is_draft', item.location.revision == DRAFT) - item.location = item.location.replace(revision=None) - return item - - -class DraftModuleStore(ModuleStoreBase): - """ - This mixin modifies a modulestore to give it draft semantics. - That is, edits made to units are stored to locations that have the revision DRAFT, - and when reads are made, they first read with revision DRAFT, and then fall back - to the baseline revision only if DRAFT doesn't exist. - - This module also includes functionality to promote DRAFT modules (and optionally - their children) to published modules. - """ - - def get_item(self, location, depth=0): - """ - Returns an XModuleDescriptor instance for the item at location. - If location.revision is None, returns the item with the most - recent revision - - If any segment of the location is None except revision, raises - xmodule.modulestore.exceptions.InsufficientSpecificationError - - If no object is found at that location, raises - xmodule.modulestore.exceptions.ItemNotFoundError - - location: Something that can be passed to Location - - depth (int): An argument that some module stores may use to prefetch - descendents of the queried modules for more efficient results later - in the request. The depth is counted in the number of calls to - get_children() to cache. None indicates to cache all descendents - """ - - try: - return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth)) - except ItemNotFoundError: - return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth)) - - def get_instance(self, course_id, location, depth=0): - """ - Get an instance of this location, with policy for course_id applied. - TODO (vshnayder): this may want to live outside the modulestore eventually - """ - - try: - return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, as_draft(location), depth=depth)) - except ItemNotFoundError: - return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth)) - - def get_items(self, location, course_id=None, depth=0): - """ - Returns a list of XModuleDescriptor instances for the items - that match location. Any element of location that is None is treated - as a wildcard that matches any value - - location: Something that can be passed to Location - - depth: An argument that some module stores may use to prefetch - descendents of the queried modules for more efficient results later - in the request. The depth is counted in the number of calls to - get_children() to cache. None indicates to cache all descendents - """ - draft_loc = as_draft(location) - - draft_items = super(DraftModuleStore, self).get_items(draft_loc, course_id=course_id, depth=depth) - items = super(DraftModuleStore, self).get_items(location, course_id=course_id, depth=depth) - - draft_locs_found = set(item.location.replace(revision=None) for item in draft_items) - non_draft_items = [ - item - for item in items - if (item.location.revision != DRAFT - and item.location.replace(revision=None) not in draft_locs_found) - ] - return [wrap_draft(item) for item in draft_items + non_draft_items] - - def clone_item(self, source, location): - """ - Clone a new item that is a copy of the item at the location `source` - and writes it to `location` - """ - if Location(location).category in DIRECT_ONLY_CATEGORIES: - raise InvalidVersionError(location) - return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location))) - - def update_item(self, location, data, allow_not_found=False): - """ - Set the data in the item specified by the location to - data - - location: Something that can be passed to Location - data: A nested dictionary of problem data - """ - draft_loc = as_draft(location) - try: - draft_item = self.get_item(location) - if not getattr(draft_item, 'is_draft', False): - self.clone_item(location, draft_loc) - except ItemNotFoundError, e: - if not allow_not_found: - raise e - - return super(DraftModuleStore, self).update_item(draft_loc, data) - - def update_children(self, location, children): - """ - Set the children for the item specified by the location to - children - - location: Something that can be passed to Location - children: A list of child item identifiers - """ - draft_loc = as_draft(location) - draft_item = self.get_item(location) - if not getattr(draft_item, 'is_draft', False): - self.clone_item(location, draft_loc) - - return super(DraftModuleStore, self).update_children(draft_loc, children) - - def update_metadata(self, location, metadata): - """ - Set the metadata for the item specified by the location to - metadata - - location: Something that can be passed to Location - metadata: A nested dictionary of module metadata - """ - draft_loc = as_draft(location) - draft_item = self.get_item(location) - - if not getattr(draft_item, 'is_draft', False): - self.clone_item(location, draft_loc) - - if 'is_draft' in metadata: - del metadata['is_draft'] - - return super(DraftModuleStore, self).update_metadata(draft_loc, metadata) - - def delete_item(self, location, delete_all_versions=False): - """ - Delete an item from this modulestore - - location: Something that can be passed to Location - """ - super(DraftModuleStore, self).delete_item(as_draft(location)) - if delete_all_versions: - super(DraftModuleStore, self).delete_item(as_published(location)) - - return - - def get_parent_locations(self, location, course_id): - '''Find all locations that are the parents of this location. Needed - for path_to_location(). - - returns an iterable of things that can be passed to Location. - ''' - return super(DraftModuleStore, self).get_parent_locations(location, course_id) - - def publish(self, location, published_by_id): - """ - Save a current draft to the underlying modulestore - """ - draft = self.get_item(location) - - draft.cms.published_date = datetime.now(UTC) - draft.cms.published_by = published_by_id - super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data) - super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children) - super(DraftModuleStore, self).update_metadata(location, own_metadata(draft)) - self.delete_item(location) - - def unpublish(self, location): - """ - Turn the published version into a draft, removing the published version - """ - if Location(location).category in DIRECT_ONLY_CATEGORIES: - raise InvalidVersionError(location) - super(DraftModuleStore, self).clone_item(location, as_draft(location)) - super(DraftModuleStore, self).delete_item(location) - - def _query_children_for_cache_children(self, items): - # first get non-draft in a round-trip - queried_children = [] - to_process_non_drafts = super(DraftModuleStore, self)._query_children_for_cache_children(items) - - to_process_dict = {} - for non_draft in to_process_non_drafts: - to_process_dict[Location(non_draft["_id"])] = non_draft - - # now query all draft content in another round-trip - query = { - '_id': {'$in': [namedtuple_to_son(as_draft(Location(item))) for item in items]} - } - to_process_drafts = list(self.collection.find(query)) - - # now we have to go through all drafts and replace the non-draft - # with the draft. This is because the semantics of the DraftStore is to - # always return the draft - if available - for draft in to_process_drafts: - draft_loc = Location(draft["_id"]) - draft_as_non_draft_loc = draft_loc.replace(revision=None) - - # does non-draft exist in the collection - # if so, replace it - if draft_as_non_draft_loc in to_process_dict: - to_process_dict[draft_as_non_draft_loc] = draft - - # convert the dict - which is used for look ups - back into a list - for key, value in to_process_dict.iteritems(): - queried_children.append(value) - - return queried_children +from xmodule.modulestore.mongo.draft import DIRECT_ONLY_CATEGORIES, DraftModuleStore diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/__init__.py b/common/lib/xmodule/xmodule/modulestore/mongo/__init__.py new file mode 100644 index 0000000000..4638402dbc --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/mongo/__init__.py @@ -0,0 +1,5 @@ +from xmodule.modulestore.mongo.base import MongoModuleStore, MongoKeyValueStore, MongoUsage + +# Backwards compatibility for prod systems that refererence +# xmodule.modulestore.mongo.DraftMongoModuleStore +from xmodule.modulestore.mongo.draft import DraftModuleStore as DraftMongoModuleStore diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py similarity index 98% rename from common/lib/xmodule/xmodule/modulestore/mongo.py rename to common/lib/xmodule/xmodule/modulestore/mongo/base.py index 32323d5892..aa1ce6c140 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -18,11 +18,10 @@ from xmodule.error_module import ErrorDescriptor from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError from xblock.core import Scope -from . import ModuleStoreBase, Location, namedtuple_to_son -from .draft import DraftModuleStore -from .exceptions import (ItemNotFoundError, +from xmodule.modulestore import ModuleStoreBase, Location, namedtuple_to_son +from xmodule.modulestore.exceptions import (ItemNotFoundError, DuplicateItemError) -from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata +from xmodule.modulestore.inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata log = logging.getLogger(__name__) @@ -761,12 +760,3 @@ class MongoModuleStore(ModuleStoreBase): return {} -# DraftModuleStore is first, because it needs to intercept calls to MongoModuleStore -class DraftMongoModuleStore(DraftModuleStore, MongoModuleStore): - """ - Version of MongoModuleStore with draft capability mixed in - """ - """ - Version of MongoModuleStore with draft capability mixed in - """ - pass diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py new file mode 100644 index 0000000000..316640cdab --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py @@ -0,0 +1,249 @@ +from datetime import datetime + +from xmodule.modulestore import Location, namedtuple_to_son +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.inheritance import own_metadata +from xmodule.exceptions import InvalidVersionError +from xmodule.modulestore.mongo.base import MongoModuleStore +from pytz import UTC + +DRAFT = 'draft' +# Things w/ these categories should never be marked as version='draft' +DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] + + +def as_draft(location): + """ + Returns the Location that is the draft for `location` + """ + return Location(location).replace(revision=DRAFT) + + +def as_published(location): + """ + Returns the Location that is the published version for `location` + """ + return Location(location).replace(revision=None) + + +def wrap_draft(item): + """ + Sets `item.is_draft` to `True` if the item is a + draft, and `False` otherwise. Sets the item's location to the + non-draft location in either case + """ + setattr(item, 'is_draft', item.location.revision == DRAFT) + item.location = item.location.replace(revision=None) + return item + + +class DraftModuleStore(MongoModuleStore): + """ + This mixin modifies a modulestore to give it draft semantics. + That is, edits made to units are stored to locations that have the revision DRAFT, + and when reads are made, they first read with revision DRAFT, and then fall back + to the baseline revision only if DRAFT doesn't exist. + + This module also includes functionality to promote DRAFT modules (and optionally + their children) to published modules. + """ + + def get_item(self, location, depth=0): + """ + Returns an XModuleDescriptor instance for the item at location. + If location.revision is None, returns the item with the most + recent revision + + If any segment of the location is None except revision, raises + xmodule.modulestore.exceptions.InsufficientSpecificationError + + If no object is found at that location, raises + xmodule.modulestore.exceptions.ItemNotFoundError + + location: Something that can be passed to Location + + depth (int): An argument that some module stores may use to prefetch + descendents of the queried modules for more efficient results later + in the request. The depth is counted in the number of calls to + get_children() to cache. None indicates to cache all descendents + """ + + try: + return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth)) + except ItemNotFoundError: + return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth)) + + def get_instance(self, course_id, location, depth=0): + """ + Get an instance of this location, with policy for course_id applied. + TODO (vshnayder): this may want to live outside the modulestore eventually + """ + + try: + return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, as_draft(location), depth=depth)) + except ItemNotFoundError: + return wrap_draft(super(DraftModuleStore, self).get_instance(course_id, location, depth=depth)) + + def get_items(self, location, course_id=None, depth=0): + """ + Returns a list of XModuleDescriptor instances for the items + that match location. Any element of location that is None is treated + as a wildcard that matches any value + + location: Something that can be passed to Location + + depth: An argument that some module stores may use to prefetch + descendents of the queried modules for more efficient results later + in the request. The depth is counted in the number of calls to + get_children() to cache. None indicates to cache all descendents + """ + draft_loc = as_draft(location) + + draft_items = super(DraftModuleStore, self).get_items(draft_loc, course_id=course_id, depth=depth) + items = super(DraftModuleStore, self).get_items(location, course_id=course_id, depth=depth) + + draft_locs_found = set(item.location.replace(revision=None) for item in draft_items) + non_draft_items = [ + item + for item in items + if (item.location.revision != DRAFT + and item.location.replace(revision=None) not in draft_locs_found) + ] + return [wrap_draft(item) for item in draft_items + non_draft_items] + + def clone_item(self, source, location): + """ + Clone a new item that is a copy of the item at the location `source` + and writes it to `location` + """ + if Location(location).category in DIRECT_ONLY_CATEGORIES: + raise InvalidVersionError(location) + return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location))) + + def update_item(self, location, data, allow_not_found=False): + """ + Set the data in the item specified by the location to + data + + location: Something that can be passed to Location + data: A nested dictionary of problem data + """ + draft_loc = as_draft(location) + try: + draft_item = self.get_item(location) + if not getattr(draft_item, 'is_draft', False): + self.clone_item(location, draft_loc) + except ItemNotFoundError, e: + if not allow_not_found: + raise e + + return super(DraftModuleStore, self).update_item(draft_loc, data) + + def update_children(self, location, children): + """ + Set the children for the item specified by the location to + children + + location: Something that can be passed to Location + children: A list of child item identifiers + """ + draft_loc = as_draft(location) + draft_item = self.get_item(location) + if not getattr(draft_item, 'is_draft', False): + self.clone_item(location, draft_loc) + + return super(DraftModuleStore, self).update_children(draft_loc, children) + + def update_metadata(self, location, metadata): + """ + Set the metadata for the item specified by the location to + metadata + + location: Something that can be passed to Location + metadata: A nested dictionary of module metadata + """ + draft_loc = as_draft(location) + draft_item = self.get_item(location) + + if not getattr(draft_item, 'is_draft', False): + self.clone_item(location, draft_loc) + + if 'is_draft' in metadata: + del metadata['is_draft'] + + return super(DraftModuleStore, self).update_metadata(draft_loc, metadata) + + def delete_item(self, location, delete_all_versions=False): + """ + Delete an item from this modulestore + + location: Something that can be passed to Location + """ + super(DraftModuleStore, self).delete_item(as_draft(location)) + if delete_all_versions: + super(DraftModuleStore, self).delete_item(as_published(location)) + + return + + def get_parent_locations(self, location, course_id): + '''Find all locations that are the parents of this location. Needed + for path_to_location(). + + returns an iterable of things that can be passed to Location. + ''' + return super(DraftModuleStore, self).get_parent_locations(location, course_id) + + def publish(self, location, published_by_id): + """ + Save a current draft to the underlying modulestore + """ + draft = self.get_item(location) + + draft.cms.published_date = datetime.now(UTC) + draft.cms.published_by = published_by_id + super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data) + super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children) + super(DraftModuleStore, self).update_metadata(location, own_metadata(draft)) + self.delete_item(location) + + def unpublish(self, location): + """ + Turn the published version into a draft, removing the published version + """ + if Location(location).category in DIRECT_ONLY_CATEGORIES: + raise InvalidVersionError(location) + super(DraftModuleStore, self).clone_item(location, as_draft(location)) + super(DraftModuleStore, self).delete_item(location) + + def _query_children_for_cache_children(self, items): + # first get non-draft in a round-trip + queried_children = [] + to_process_non_drafts = super(DraftModuleStore, self)._query_children_for_cache_children(items) + + to_process_dict = {} + for non_draft in to_process_non_drafts: + to_process_dict[Location(non_draft["_id"])] = non_draft + + # now query all draft content in another round-trip + query = { + '_id': {'$in': [namedtuple_to_son(as_draft(Location(item))) for item in items]} + } + to_process_drafts = list(self.collection.find(query)) + + # now we have to go through all drafts and replace the non-draft + # with the draft. This is because the semantics of the DraftStore is to + # always return the draft - if available + for draft in to_process_drafts: + draft_loc = Location(draft["_id"]) + draft_as_non_draft_loc = draft_loc.replace(revision=None) + + # does non-draft exist in the collection + # if so, replace it + if draft_as_non_draft_loc in to_process_dict: + to_process_dict[draft_as_non_draft_loc] = draft + + # convert the dict - which is used for look ups - back into a list + for key, value in to_process_dict.iteritems(): + queried_children.append(value) + + return queried_children diff --git a/lms/envs/cms/preview_dev.py b/lms/envs/cms/preview_dev.py index 1cfaec6159..bfa7fec826 100644 --- a/lms/envs/cms/preview_dev.py +++ b/lms/envs/cms/preview_dev.py @@ -10,7 +10,7 @@ from .dev import * MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': modulestore_options }, } From a2524c517f83afdea09cc021d83d7b075628993c Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 16:39:04 -0400 Subject: [PATCH 145/161] Force locations to be Location objects earlier --- cms/djangoapps/contentstore/views/access.py | 7 ++++--- cms/djangoapps/contentstore/views/assets.py | 2 +- cms/djangoapps/contentstore/views/course.py | 2 +- cms/djangoapps/models/settings/course_details.py | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py index 803313e274..49ce0c8733 100644 --- a/cms/djangoapps/contentstore/views/access.py +++ b/cms/djangoapps/contentstore/views/access.py @@ -2,12 +2,13 @@ from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME from auth.authz import is_user_in_course_group_role from django.core.exceptions import PermissionDenied from ..utils import get_course_location_for_item +from xmodule.modulestore import Location def get_location_and_verify_access(request, org, course, name): """ - Create the location tuple verify that the user has permissions - to view the location. Returns the location. + Create the location, verify that the user has permissions + to view the location. Returns the location as a Location """ location = ['i4x', org, course, 'course', name] @@ -15,7 +16,7 @@ def get_location_and_verify_access(request, org, course, name): if not has_access(request.user, location): raise PermissionDenied() - return location + return Location(location) def has_access(user, location, role=STAFF_ROLE_NAME): diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 41077abd8f..c85570fede 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -258,7 +258,7 @@ def import_course(request, org, course, name): _module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT, [course_subdir], load_error_modules=False, static_content_store=contentstore(), - target_location_namespace=Location(location), + target_location_namespace=location, draft_store=modulestore()) # we can blow this away when we're done importing. diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 8862115c45..d790697612 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -153,7 +153,7 @@ def course_info(request, org, course, name, provided_id=None): course_module = modulestore().get_item(location) # get current updates - location = ['i4x', org, course, 'course_info', "updates"] + location = Location(['i4x', org, course, 'course_info', "updates"]) return render_to_response('course_info.html', { 'active_tab': 'courseinfo-tab', diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 884a4e4fef..8ce8c2db34 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -74,7 +74,7 @@ class CourseDetails(object): Decode the json into CourseDetails and save any changed attrs to the db """ # TODO make it an error for this to be undefined & for it to not be retrievable from modulestore - course_location = jsondict['course_location'] + course_location = Location(jsondict['course_location']) # Will probably want to cache the inflight courses because every blur generates an update descriptor = get_modulestore(course_location).get_item(course_location) From 2fc7d3308c212e19e5e915924c60e504a3527133 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 24 Jun 2013 16:42:54 -0400 Subject: [PATCH 146/161] Customtags can't be inserted by studio --- cms/djangoapps/contentstore/views/component.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 007467f8a6..4377943b36 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -38,7 +38,8 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES', log = logging.getLogger(__name__) -COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video'] +# NOTE: edit_unit assumes this list is disjoint from ADVANCED_COMPONENT_TYPES +COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video'] OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] NOTE_COMPONENT_TYPES = ['notes'] From ef9c299413860751c186a0758544e0759c2397d9 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 25 Jun 2013 11:07:38 -0400 Subject: [PATCH 147/161] Work around XBlock limitations to force persistance of field mutation --- cms/djangoapps/contentstore/views/checklist.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index e9f4e1c7b4..99547a523b 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -67,7 +67,9 @@ def update_checklist(request, org, course, name, checklist_index=None): if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists): index = int(checklist_index) course_module.checklists[index] = json.loads(request.body) - checklists, modified = expand_checklist_action_urls(course_module) + # seeming noop which triggers kvs to record that the metadata is not default + course_module.checklists = course_module.checklists + checklists, _ = expand_checklist_action_urls(course_module) modulestore.update_metadata(location, own_metadata(course_module)) return HttpResponse(json.dumps(checklists[index]), mimetype="application/json") else: From 74d20c20284857494d88da512b71d187a925e42a Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Mon, 1 Jul 2013 13:16:55 -0400 Subject: [PATCH 148/161] Fixed abtests to use the preferences scope correctly. Made abtests display its children in the right order. --- common/lib/xmodule/xmodule/abtest_module.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 2e61076e94..0d65412eae 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -129,7 +129,9 @@ class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor): raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1") group_portions[DEFAULT] = default_portion - children.sort() + # I have no clue why the line of code below ever existed. + # It was scrambling the order of all the elements in abtest. + # children.sort() return { 'group_portions': group_portions, From eedeaf286baf19c8f565f56af7e0763e9db3e04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Mon, 1 Jul 2013 13:23:24 -0400 Subject: [PATCH 149/161] Revert "Fixed abtests to use the preferences scope correctly." This reverts commit 74d20c20284857494d88da512b71d187a925e42a. --- common/lib/xmodule/xmodule/abtest_module.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 0d65412eae..2e61076e94 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -129,9 +129,7 @@ class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor): raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1") group_portions[DEFAULT] = default_portion - # I have no clue why the line of code below ever existed. - # It was scrambling the order of all the elements in abtest. - # children.sort() + children.sort() return { 'group_portions': group_portions, From 72b4561e3ad6920457c324be189572160a465114 Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Fri, 28 Jun 2013 14:47:31 -0400 Subject: [PATCH 150/161] Moved xml_store_config, mongo_store_config, and draft_mongo_store_config to xmodule/modulestore/tests/django_util.py Removed some repeated instances of xml_store_config. Reverted some changes to mongo_store_config so each modulestore gets it's own uuid. --- .../course_groups/tests/__init__.py | 0 .../djangoapps/course_groups/tests/tests.py | 14 +--- .../xmodule/modulestore/tests/django_utils.py | 64 ++++++++++++++++++ .../courseware/tests/modulestore_config.py | 66 +------------------ .../courseware/tests/test_masquerade.py | 2 +- .../courseware/tests/test_module_render.py | 16 +---- lms/djangoapps/courseware/tests/test_views.py | 17 +---- lms/djangoapps/courseware/tests/tests.py | 5 +- .../instructor/tests/test_enrollment.py | 3 +- 9 files changed, 75 insertions(+), 112 deletions(-) create mode 100644 common/djangoapps/course_groups/tests/__init__.py diff --git a/common/djangoapps/course_groups/tests/__init__.py b/common/djangoapps/course_groups/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 94d52ff6df..0dc8ae5aae 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -10,22 +10,12 @@ from course_groups.cohorts import (get_cohort, get_course_cohorts, from xmodule.modulestore.django import modulestore, _MODULESTORES +from xmodule.modulestore.tests.django_utils import xml_store_config + # NOTE: running this with the lms.envs.test config works without # manually overriding the modulestore. However, running with # cms.envs.test doesn't. - -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) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index ad908a2cb1..1a3d2699cc 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -8,6 +8,70 @@ import xmodule.modulestore.django from xmodule.templates import update_templates +def mongo_store_config(data_dir): + """ + Defines default module store using MongoModuleStore. + + Use of this config requires mongo to be running. + """ + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string' + } + } + } + store['direct'] = store['default'] + return store + + +def draft_mongo_store_config(data_dir): + """ + Defines default module store using DraftMongoModuleStore. + """ + + modulestore_options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string' + } + + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'OPTIONS': modulestore_options + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': modulestore_options + } + } + + +def xml_store_config(data_dir): + """ + Defines default module store using XMLModuleStore. + """ + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } + } + + class ModuleStoreTestCase(TestCase): """ Subclass for any test case that uses the mongodb module store. This populates a uniquely named modulestore diff --git a/lms/djangoapps/courseware/tests/modulestore_config.py b/lms/djangoapps/courseware/tests/modulestore_config.py index 9515e449f9..80a7b0a7c1 100644 --- a/lms/djangoapps/courseware/tests/modulestore_config.py +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -1,71 +1,7 @@ -from uuid import uuid4 +from xmodule.modulestore.tests.django_utils import xml_store_config, mongo_store_config, draft_mongo_store_config from django.conf import settings - -def mongo_store_config(data_dir): - """ - Defines default module store using MongoModuleStore. - - Use of this config requires mongo to be running. - """ - store = { - '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' - } - } - } - store['direct'] = store['default'] - return store - - -def draft_mongo_store_config(data_dir): - """ - Defines default module store using DraftMongoModuleStore. - """ - - modulestore_options = { - 'default_class': 'xmodule.raw_module.RawDescriptor', - 'host': 'localhost', - 'db': 'xmodule', - 'collection': 'modulestore_%s' % uuid4().hex, - 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string' - } - - return { - 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', - 'OPTIONS': modulestore_options - }, - 'direct': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': modulestore_options - } - } - - -def xml_store_config(data_dir): - """ - Defines default module store using XMLModuleStore. - """ - 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) TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 0a4dada18e..3dc3d2b6b1 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -15,7 +15,7 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import Group, User from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase -from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE +from modulestore_config import TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django import json diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 775b6ff0fc..ea31f5110c 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -12,6 +12,7 @@ from xmodule.modulestore.django import modulestore import courseware.module_render as render from courseware.tests.tests import LoginEnrollmentTestCase from courseware.model_data import ModelDataCache +from modulestore_config import TEST_DATA_XML_MODULESTORE from .factories import UserFactory @@ -21,21 +22,6 @@ 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) - - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class ModuleRenderTestCase(LoginEnrollmentTestCase): def setUp(self): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 25492ad379..b4689eae6a 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -14,28 +14,13 @@ from xmodule.modulestore.django import modulestore import courseware.views as views from xmodule.modulestore import Location from pytz import UTC +from modulestore_config import TEST_DATA_XML_MODULESTORE class Stub(): pass -# 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) - - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestJumpTo(TestCase): """Check the jumpto link for a course""" diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 43b190c04b..157cd06d4f 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -16,7 +16,10 @@ from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml import XMLModuleStore from helpers import LoginEnrollmentTestCase -from modulestore_config import TEST_DATA_DIR, TEST_DATA_XML_MODULESTORE, TEST_DATA_MONGO_MODULESTORE, TEST_DATA_DRAFT_MONGO_MODULESTORE +from modulestore_config import TEST_DATA_DIR,\ + TEST_DATA_XML_MODULESTORE,\ + TEST_DATA_MONGO_MODULESTORE,\ + TEST_DATA_DRAFT_MONGO_MODULESTORE class ActivateLoginTest(LoginEnrollmentTestCase): diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index 5bf2afc9ea..b3455511b5 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -8,12 +8,11 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase -from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE, LoginEnrollmentTestCase from student.models import CourseEnrollment, CourseEnrollmentAllowed from instructor.views import get_and_clean_student_list, send_mail_to_student from django.core import mail From ce6b3192880cd66a0a16c3d88e0da969f6ce6e42 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 20 Jun 2013 16:45:46 -0400 Subject: [PATCH 151/161] Added jsonable_server_error in common/util/views.py --- common/djangoapps/util/views.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index 851202caec..10492e383d 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -4,7 +4,10 @@ import sys from django.conf import settings from django.core.validators import ValidationError, validate_email -from django.http import Http404, HttpResponse, HttpResponseNotAllowed +from django.views.decorators.csrf import requires_csrf_token +from django.views.defaults import server_error +from django.http import (Http404, HttpResponse, HttpResponseNotAllowed, + HttpResponseServerError) from dogapi import dog_stats_api from mitxmako.shortcuts import render_to_response import zendesk @@ -16,6 +19,19 @@ import track.views log = logging.getLogger(__name__) +@requires_csrf_token +def jsonable_server_error(request, template_name='500.html'): + """ + 500 error handler that serves JSON on an AJAX request, and proxies + to the Django default `server_error` view otherwise. + """ + if request.is_ajax(): + msg = {"error": "The edX servers encountered an error"} + return HttpResponseServerError(json.dumps(msg)) + else: + return server_error(request, template_name=template_name) + + def calculate(request): ''' Calculator in footer of every page. ''' equation = request.GET['equation'] @@ -228,4 +244,3 @@ def accepts(request, media_type): """Return whether this request has an Accept header that matches type""" accept = parse_accept_header(request.META.get("HTTP_ACCEPT", "")) return media_type in [t for (t, p, q) in accept] - From 923bcc555c6a039df649d1c2725cc67ad8c79660 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 20 Jun 2013 17:18:56 -0400 Subject: [PATCH 152/161] Make CMS errors JSON-able if requested via AJAX --- cms/djangoapps/contentstore/views/error.py | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/error.py b/cms/djangoapps/contentstore/views/error.py index 0292b9d389..5a34af36bc 100644 --- a/cms/djangoapps/contentstore/views/error.py +++ b/cms/djangoapps/contentstore/views/error.py @@ -1,20 +1,45 @@ -from django.http import HttpResponseServerError, HttpResponseNotFound +from django.http import (HttpResponse, HttpResponseServerError, + HttpResponseNotFound) from mitxmako.shortcuts import render_to_string, render_to_response +import functools +import json __all__ = ['not_found', 'server_error', 'render_404', 'render_500'] +def jsonable_error(status=500, message="The Studio servers encountered an error"): + """ + A decorator to make an error view return an JSON-formatted message if + it was requested via AJAX. + """ + def outer(func): + @functools.wraps(func) + def inner(request, *args, **kwargs): + if request.is_ajax(): + content = json.dumps({"error": message}) + return HttpResponse(content, content_type="application/json", + status=status) + else: + return func(request, *args, **kwargs) + return inner + return outer + + +@jsonable_error(404, "Resource not found") def not_found(request): return render_to_response('error.html', {'error': '404'}) +@jsonable_error(500, "The Studio servers encountered an error") def server_error(request): return render_to_response('error.html', {'error': '500'}) +@jsonable_error(404, "Resource not found") def render_404(request): return HttpResponseNotFound(render_to_string('404.html', {})) +@jsonable_error(500, "The Studio servers encountered an error") def render_500(request): return HttpResponseServerError(render_to_string('500.html', {})) From 8bfcbb6675ecf04d672c674870d7e802245101b3 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 20 Jun 2013 17:20:33 -0400 Subject: [PATCH 153/161] Make notifyOnError handler try to parse response as JSON --- cms/static/coffee/src/main.coffee | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index 8043b41638..ae64e90375 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -19,7 +19,10 @@ $ -> if ajaxSettings.notifyOnError is false return if jqXHR.responseText - message = _.str.truncate(jqXHR.responseText, 300) + try + message = JSON.parse(jqXHR.responseText).error + catch + message = _.str.truncate(jqXHR.responseText, 300) else message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") msg = new CMS.Views.Notification.Error( From 1fee805c9a5ab7cd55886aafd7356a282e965797 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 26 Jun 2013 11:35:27 -0400 Subject: [PATCH 154/161] Adjust indentation to make coffeescript happy --- cms/static/coffee/src/main.coffee | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index ae64e90375..a6e9d43daa 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -19,15 +19,15 @@ $ -> if ajaxSettings.notifyOnError is false return if jqXHR.responseText - try - message = JSON.parse(jqXHR.responseText).error - catch - message = _.str.truncate(jqXHR.responseText, 300) + try + message = JSON.parse(jqXHR.responseText).error + catch + message = _.str.truncate(jqXHR.responseText, 300) else - message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") + message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") msg = new CMS.Views.Notification.Error( - "title": gettext("Studio's having trouble saving your work") - "message": message + "title": gettext("Studio's having trouble saving your work") + "message": message ) msg.show() From 7824b8c8f22d5e679cb902ac8860a2c8f06c1bbe Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 26 Jun 2013 16:24:53 -0400 Subject: [PATCH 155/161] Fix coffeescript issue on Linux On Linux, coffeescript refuses to compile unless the `catch` keyword has an error variable to bind to. On Mac, it works just fine either way. Go figure. --- cms/static/coffee/src/main.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index a6e9d43daa..863d21d846 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -21,7 +21,7 @@ $ -> if jqXHR.responseText try message = JSON.parse(jqXHR.responseText).error - catch + catch error message = _.str.truncate(jqXHR.responseText, 300) else message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.") From 5cb4c1a7506b6e2e7c69c7e5e863138a8a8f42ed Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 1 Jul 2013 17:06:32 -0400 Subject: [PATCH 156/161] Removed some pep8 violations. --- common/djangoapps/course_groups/tests/tests.py | 13 ++----------- lms/djangoapps/courseware/tests/test_views.py | 5 ++--- lms/djangoapps/instructor/tests/test_enrollment.py | 2 -- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 0dc8ae5aae..2e519edb30 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -23,7 +23,6 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCohorts(django.test.TestCase): - @staticmethod def topic_name_to_id(course, name): """ @@ -34,7 +33,6 @@ class TestCohorts(django.test.TestCase): run=course.url_name, name=name) - @staticmethod def config_course_cohorts(course, discussions, cohorted, @@ -80,7 +78,6 @@ class TestCohorts(django.test.TestCase): course.cohort_config = d - def setUp(self): """ Make sure that course is reloaded every time--clear out the modulestore. @@ -89,7 +86,6 @@ class TestCohorts(django.test.TestCase): # to course. We don't have a course.clone() method. _MODULESTORES.clear() - def test_get_cohort(self): """ Make sure get_cohort() does the right thing when the course is cohorted @@ -105,7 +101,7 @@ class TestCohorts(django.test.TestCase): cohort = CourseUserGroup.objects.create(name="TestCohort", course_id=course.id, - group_type=CourseUserGroup.COHORT) + group_type=CourseUserGroup.COHORT) cohort.users.add(user) @@ -135,7 +131,7 @@ class TestCohorts(django.test.TestCase): cohort = CourseUserGroup.objects.create(name="TestCohort", course_id=course.id, - group_type=CourseUserGroup.COHORT) + group_type=CourseUserGroup.COHORT) # user1 manually added to a cohort cohort.users.add(user1) @@ -169,7 +165,6 @@ class TestCohorts(django.test.TestCase): self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup", "user2 should still be in originally placed cohort") - def test_auto_cohorting_randomization(self): """ Make sure get_cohort() randomizes properly. @@ -199,8 +194,6 @@ class TestCohorts(django.test.TestCase): self.assertGreater(num_users, 1) self.assertLess(num_users, 50) - - def test_get_course_cohorts(self): course1_id = 'a/b/c' course2_id = 'e/f/g' @@ -214,14 +207,12 @@ class TestCohorts(django.test.TestCase): course_id=course1_id, group_type=CourseUserGroup.COHORT) - # second course should have no cohorts self.assertEqual(get_course_cohorts(course2_id), []) cohorts = sorted([c.name for c in get_course_cohorts(course1_id)]) self.assertEqual(cohorts, ['TestCohort', 'TestCohort2']) - def test_is_commentable_cohorted(self): course = modulestore().get_course("edX/toy/2012_Fall") self.assertFalse(course.is_cohorted) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index b4689eae6a..37b81aa96f 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -3,7 +3,6 @@ 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 django.test.client import RequestFactory @@ -52,8 +51,8 @@ class ViewsTestCase(TestCase): self.date = datetime.datetime(2013, 1, 22, tzinfo=UTC) 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] + course_id=self.course_id, + created=self.date)[0] self.location = ['tag', 'org', 'course', 'category', 'name'] self._MODULESTORES = {} # This is a CourseDescriptor object diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index b3455511b5..f84106a52a 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -6,10 +6,8 @@ Unit tests for enrollment methods in views.py from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse -from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase From af75e1f78581cf832c61309db61cb4caf79ee65f Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Wed, 26 Jun 2013 15:45:35 -0400 Subject: [PATCH 157/161] log student answers using POST instead of GET --- common/djangoapps/track/views.py | 17 +++++++++-------- common/static/coffee/spec/logger_spec.coffee | 4 ++-- common/static/coffee/src/logger.coffee | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index 221bab5468..31c5d02b3c 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -34,9 +34,10 @@ def log_event(event): def user_track(request): """ - Log when GET call to "event" URL is made by a user. + Log when POST call to "event" URL is made by a user. Uses request.REQUEST + to allow for GET calls. - GET call should provide "event_type", "event", and "page" arguments. + GET or POST call should provide "event_type", "event", and "page" arguments. """ try: # TODO: Do the same for many of the optional META parameters username = request.user.username @@ -59,13 +60,13 @@ def user_track(request): "session": scookie, "ip": request.META['REMOTE_ADDR'], "event_source": "browser", - "event_type": request.GET['event_type'], - "event": request.GET['event'], + "event_type": request.REQUEST['event_type'], + "event": request.REQUEST['event'], "agent": agent, - "page": request.GET['page'], + "page": request.REQUEST['page'], "time": datetime.datetime.now(UTC).isoformat(), "host": request.META['SERVER_NAME'], - } + } log_event(event) return HttpResponse('success') @@ -92,7 +93,7 @@ def server_track(request, event_type, event, page=None): "page": page, "time": datetime.datetime.now(UTC).isoformat(), "host": request.META['SERVER_NAME'], - } + } if event_type.startswith("/event_logs") and request.user.is_staff: # don't log return @@ -136,7 +137,7 @@ def task_track(request_info, task_info, event_type, event, page=None): "page": page, "time": datetime.datetime.utcnow().isoformat(), "host": request_info.get('host', 'unknown') - } + } log_event(event) diff --git a/common/static/coffee/spec/logger_spec.coffee b/common/static/coffee/spec/logger_spec.coffee index 7fe734d8b5..4a53b8c455 100644 --- a/common/static/coffee/spec/logger_spec.coffee +++ b/common/static/coffee/spec/logger_spec.coffee @@ -14,9 +14,9 @@ describe 'Logger', -> expect(analytics.track).toHaveBeenCalledWith 'seq_goto', value: 'data' it 'send a request to log event', -> - spyOn $, 'getWithPrefix' + spyOn $, 'postWithPrefix' Logger.log 'example', 'data' - expect($.getWithPrefix).toHaveBeenCalledWith '/event', + expect($.postWithPrefix).toHaveBeenCalledWith '/event', event_type: 'example' event: '"data"' page: window.location.href diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index 6eaa497255..dffc14e067 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -28,7 +28,7 @@ class @Logger callback(event_type, data, element) # Regardless of whether any callbacks were made, log this event. - $.getWithPrefix '/event', + $.postWithPrefix '/event', event_type: event_type event: JSON.stringify(data) page: window.location.href From 03aee3ed79188863d333cff041639b1ccf105e83 Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Thu, 27 Jun 2013 16:34:15 -0400 Subject: [PATCH 158/161] add tests --- .../contentstore/tests/test_request_event.py | 35 ++++++++++++ cms/envs/test.py | 3 + common/djangoapps/track/tests.py | 55 +++++++++++++++++++ common/djangoapps/track/views.py | 1 + lms/envs/test.py | 3 + 5 files changed, 97 insertions(+) create mode 100644 cms/djangoapps/contentstore/tests/test_request_event.py create mode 100644 common/djangoapps/track/tests.py diff --git a/cms/djangoapps/contentstore/tests/test_request_event.py b/cms/djangoapps/contentstore/tests/test_request_event.py new file mode 100644 index 0000000000..296dfc3936 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_request_event.py @@ -0,0 +1,35 @@ +from django.test import TestCase +from django.core.urlresolvers import reverse +from contentstore.views.requests import event as cms_user_track + + +class CMSLogTest(TestCase): + """ + Tests that request to logs from CMS return 204s + """ + + def test_post_answers_to_log(self): + """ + Checks that student answer requests submitted to cms's "/event" url + via POST are correctly returned as 204s + """ + requests = [ + {"event": "my_event", "event_type": "my_event_type", "page": "my_page"}, + {"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"} + ] + for request_params in requests: + response = self.client.post(reverse(cms_user_track), request_params) + self.assertEqual(response.status_code, 204) + + def test_get_answers_to_log(self): + """ + Checks that student answer requests submitted to cms's "/event" url + via GET are correctly returned as 204s + """ + requests = [ + {"event": "my_event", "event_type": "my_event_type", "page": "my_page"}, + {"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"} + ] + for request_params in requests: + response = self.client.get(reverse(cms_user_track), request_params) + self.assertEqual(response.status_code, 204) diff --git a/cms/envs/test.py b/cms/envs/test.py index 8fe9652c07..86925caff6 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -140,3 +140,6 @@ SEGMENT_IO_KEY = '***REMOVED***' MITX_FEATURES['STUDIO_NPS_SURVEY'] = False MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True + +# Enabling SQL tracking logs for testing on common/djangoapps/track +MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True diff --git a/common/djangoapps/track/tests.py b/common/djangoapps/track/tests.py new file mode 100644 index 0000000000..b454b06e84 --- /dev/null +++ b/common/djangoapps/track/tests.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from django.core.urlresolvers import reverse, NoReverseMatch +from track.models import TrackingLog +from track.views import user_track +from nose.plugins.skip import SkipTest + + +class TrackingTest(TestCase): + """ + Tests that tracking logs correctly handle events + """ + + def test_post_answers_to_log(self): + """ + Checks that student answer requests submitted to track.views via POST + are correctly logged in the TrackingLog db table + """ + requests = [ + {"event": "my_event", "event_type": "my_event_type", "page": "my_page"}, + {"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"} + ] + for request_params in requests: + try: # because /event maps to two different views in lms and cms, we're only going to test lms here + response = self.client.post(reverse(user_track), request_params) + except NoReverseMatch: + raise SkipTest() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, 'success') + tracking_logs = TrackingLog.objects.order_by('-dtcreated') + log = tracking_logs[0] + self.assertEqual(log.event, request_params["event"]) + self.assertEqual(log.event_type, request_params["event_type"]) + self.assertEqual(log.page, request_params["page"]) + + def test_get_answers_to_log(self): + """ + Checks that student answer requests submitted to track.views via GET + are correctly logged in the TrackingLog db table + """ + requests = [ + {"event": "my_event", "event_type": "my_event_type", "page": "my_page"}, + {"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"} + ] + for request_params in requests: + try: # because /event maps to two different views in lms and cms, we're only going to test lms here + response = self.client.get(reverse(user_track), request_params) + except NoReverseMatch: + raise SkipTest() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, 'success') + tracking_logs = TrackingLog.objects.order_by('-dtcreated') + log = tracking_logs[0] + self.assertEqual(log.event, request_params["event"]) + self.assertEqual(log.event_type, request_params["event_type"]) + self.assertEqual(log.page, request_params["page"]) diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index 31c5d02b3c..b65f9fa043 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -67,6 +67,7 @@ def user_track(request): "time": datetime.datetime.now(UTC).isoformat(), "host": request.META['SERVER_NAME'], } + log_event(event) return HttpResponse('success') diff --git a/lms/envs/test.py b/lms/envs/test.py index d335fcd600..f23be52a51 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -29,6 +29,9 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True +# Enabling SQL tracking logs for testing on common/djangoapps/track +MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True From bdacc7646c087d8fd87feb20c6af1d23d5cb1feb Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Thu, 27 Jun 2013 16:36:23 -0400 Subject: [PATCH 159/161] clean up track/models --- common/djangoapps/track/models.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/track/models.py b/common/djangoapps/track/models.py index b6a16706c1..1ac7656244 100644 --- a/common/djangoapps/track/models.py +++ b/common/djangoapps/track/models.py @@ -1,9 +1,8 @@ from django.db import models -from django.db import models - class TrackingLog(models.Model): + """Defines the fields that are stored in the tracking log database""" dtcreated = models.DateTimeField('creation date', auto_now_add=True) username = models.CharField(max_length=32, blank=True) ip = models.CharField(max_length=32, blank=True) @@ -16,6 +15,9 @@ class TrackingLog(models.Model): host = models.CharField(max_length=64, blank=True) def __unicode__(self): - s = "[%s] %s@%s: %s | %s | %s | %s" % (self.time, self.username, self.ip, self.event_source, - self.event_type, self.page, self.event) - return s + fmt = ( + u"[{self.time}] {self.username}@{self.ip}: " + u"{self.event_source}| {self.event_type} | " + u"{self.page} | {self.event}" + ) + return fmt.format(self=self) From bf25283d542e295542648530889bc9ed207ce9ae Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 1 Jul 2013 17:12:17 -0400 Subject: [PATCH 160/161] add to CHANGELOG and AUTHORS --- AUTHORS | 1 + CHANGELOG.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/AUTHORS b/AUTHORS index 9bb4ede121..03959ca00d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -78,3 +78,4 @@ Peter Fogg Bethany LaPenta Renzo Lucioni Felix Sun +Adam Palay diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ff900d6161..0b50efd677 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Common: Student information is now passed to the tracking log via POST instead of GET. + Common: Add tests for documentation generation to test suite Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems From c692428fe56fd934559f7199784bf99426c4a5e2 Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Tue, 2 Jul 2013 10:22:26 -0400 Subject: [PATCH 161/161] fix pylint violations --- .../contentstore/tests/test_request_event.py | 1 + common/djangoapps/track/tests.py | 1 + lms/djangoapps/courseware/module_render.py | 10 +++++----- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_request_event.py b/cms/djangoapps/contentstore/tests/test_request_event.py index 296dfc3936..a235c71568 100644 --- a/cms/djangoapps/contentstore/tests/test_request_event.py +++ b/cms/djangoapps/contentstore/tests/test_request_event.py @@ -1,3 +1,4 @@ +"""Tests for CMS's requests to logs""" from django.test import TestCase from django.core.urlresolvers import reverse from contentstore.views.requests import event as cms_user_track diff --git a/common/djangoapps/track/tests.py b/common/djangoapps/track/tests.py index b454b06e84..bfa84a620f 100644 --- a/common/djangoapps/track/tests.py +++ b/common/djangoapps/track/tests.py @@ -1,3 +1,4 @@ +"""Tests for student tracking""" from django.test import TestCase from django.core.urlresolvers import reverse, NoReverseMatch from track.models import TrackingLog diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 5c12725d0a..4cafb0979d 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -61,9 +61,9 @@ def make_track_function(request): ''' import track.views - def f(event_type, event): + def function(event_type, event): return track.views.server_track(request, event_type, event, page='x_module') - return f + return function def toc_for_course(user, request, course, active_chapter, active_section, model_data_cache): @@ -171,9 +171,9 @@ def get_xqueue_callback_url_prefix(request): should go back to the LMS, not to the worker. """ prefix = '{proto}://{host}'.format( - proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'), - host=request.get_host() - ) + proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'), + host=request.get_host() + ) return settings.XQUEUE_INTERFACE.get('callback_url', prefix)