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 0e161e4f72..0b50efd677 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,24 @@ 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 + +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 +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. + Studio: Remove XML from the video component editor. All settings are moved to be edited as metadata. @@ -48,6 +66,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 @@ -138,3 +158,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/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/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 58b63abd23..23dde88e94 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 @@ -32,14 +36,14 @@ 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() -''' -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) @@ -55,11 +59,12 @@ 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(): @@ -71,11 +76,12 @@ 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 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 +100,34 @@ 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(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() + 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 +153,29 @@ 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(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) + + +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 +184,40 @@ 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 + + +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. + + 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 + "_"): + 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 new file mode 100644 index 0000000000..658c176498 --- /dev/null +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -0,0 +1,215 @@ +""" +Tests authz.py +""" +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,\ + 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, _grant_instructors_creator_access + + +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') + self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo') + self.admin.is_staff = True + + 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.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 + 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.admin, 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.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.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.admin, 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.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): + """ + 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 test_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.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.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): + """ + 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) + + +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' + 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): + """ + 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)) + 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) + + # 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)) 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). diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature new file mode 100644 index 0000000000..6a18dfa7ab --- /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 editing 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 editing 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..64f088f056 --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.py @@ -0,0 +1,129 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step + +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'] + for selector in css_selectors: + world.css_click(selector) + + +@step(u'I add the following components:') +def add_components(step): + 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_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): + for _ in range(len(COMPONENT_DICTIONARY)): + 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') + + +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': step_selector_list('discussion', None), + 'found_func': found_css_func('section.xmodule_DiscussionModule') + }, + 'Announcement': { + 'steps': step_selector_list('html', 'Announcement'), + 'found_func': found_text_func('Heading of document') + }, + 'Blank HTML': { + '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': step_selector_list('html', 'E-text_Written_in_LaTeX'), + 'found_func': found_text_func('EXAMPLE: E-TEXT PAGE') + }, + 'Blank Problem': { + 'steps': step_selector_list('problem', 'Blank_Common_Problem'), + 'found_func': found_text_func('BLANK COMMON PROBLEM') + }, + 'Dropdown': { + 'steps': step_selector_list('problem', 'Dropdown'), + 'found_func': found_text_func('DROPDOWN') + }, + 'Multi Choice': { + 'steps': step_selector_list('problem', 'Multiple_Choice'), + 'found_func': found_text_func('MULTIPLE CHOICE') + }, + 'Numerical': { + 'steps': step_selector_list('problem', 'Numerical_Input'), + 'found_func': found_text_func('NUMERICAL INPUT') + }, + 'Text Input': { + 'steps': step_selector_list('problem', 'Text_Input'), + 'found_func': found_text_func('TEXT INPUT') + }, + 'Advanced': { + 'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2), + 'found_func': found_text_func('BLANK ADVANCED PROBLEM') + }, + 'Circuit': { + 'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2), + 'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER') + }, + 'Custom Python': { + 'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2), + 'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT') + }, + 'Image Mapped': { + 'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2), + 'found_func': found_text_func('IMAGE MAPPED INPUT') + }, + 'Math Input': { + 'steps': step_selector_list('problem', 'Math_Expression_Input', index=2), + 'found_func': found_text_func('MATH EXPRESSION INPUT') + }, + 'Problem LaTex': { + 'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2), + 'found_func': found_text_func('PROBLEM WRITTEN IN LATEX') + }, + 'Adaptive Hint': { + 'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2), + 'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT') + }, + 'Video': { + 'steps': step_selector_list('video', None), + 'found_func': found_css_func('section.xmodule_VideoModule') + } +} 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..47d770dc47 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 = '#notification-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/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/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py new file mode 100644 index 0000000000..f627df88f5 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -0,0 +1,35 @@ +""" +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 django.contrib.auth.models import User +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: + 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() 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']) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index d24deacecf..b946aac6bb 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 @@ -23,7 +26,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 @@ -43,10 +46,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 pymongo import MongoClient -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' % uuid4().hex class MongoCollectionFindWrapper(object): @@ -59,13 +64,16 @@ class MongoCollectionFindWrapper(object): return self.original(query, *args, **kwargs) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ Tests that rely on the toy courses. 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' @@ -83,6 +91,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + def tearDown(self): + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + def check_components_on_page(self, component_types, expected_types): """ Ensure that the right types end up on the page. @@ -403,7 +416,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 @@ -442,7 +455,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 @@ -519,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 @@ -533,7 +545,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) @@ -581,24 +592,21 @@ 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) - 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) @@ -809,6 +817,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. @@ -845,8 +854,19 @@ class ContentStoreTest(ModuleStoreTestCase): 'display_name': 'Robot Super Course', } + def tearDown(self): + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + 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) @@ -854,41 +874,72 @@ 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.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/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 5c2a15ac87..40ec2ed3c7 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): @@ -235,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: 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..a235c71568 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_request_event.py @@ -0,0 +1,36 @@ +"""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 + + +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/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/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 400013b59b..c85570fede 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)) @@ -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/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: diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 039deb2740..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'] @@ -220,7 +221,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 }) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index dd7573bad5..d790697612 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 @@ -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/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', {})) diff --git a/cms/djangoapps/contentstore/views/session_kv_store.py b/cms/djangoapps/contentstore/views/session_kv_store.py index 309518c27d..87a92a9e2e 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.field_name in self._descriptor_model_data or tuple(key) in self._session 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) diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 6293219f43..4d1f1f2a4b 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -23,12 +23,12 @@ 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 = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': MODULESTORE_OPTIONS }, 'direct': { @@ -36,10 +36,25 @@ MODULESTORE = { 'OPTIONS': MODULESTORE_OPTIONS }, 'draft': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', '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 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 diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 07630bdf31..655092b74b 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -22,12 +22,12 @@ 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 = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', 'OPTIONS': modulestore_options }, 'direct': { @@ -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/cms/envs/test.py b/cms/envs/test.py index 954a553e10..86925caff6 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -48,12 +48,12 @@ 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 = { '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 } } @@ -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': { @@ -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/cms/static/coffee/spec/views/feedback_spec.coffee b/cms/static/coffee/spec/views/feedback_spec.coffee index a3950c0b3c..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 -> @@ -100,11 +110,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 +133,75 @@ 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 -> + @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/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index 8043b41638..863d21d846 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -19,12 +19,15 @@ $ -> if ajaxSettings.notifyOnError is false return if jqXHR.responseText + try + message = JSON.parse(jqXHR.responseText).error + 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.") + 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() diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index 18ef131f52..224ec928fb 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -23,7 +23,12 @@ 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.Notification.Confirmation({ + title: gettext("Your file has been deleted."), + closeIcon: false, + maxShown: 2000 + }); + deleted.show(); row.remove(); analytics.track('Deleted Asset', { 'course': course_location_analytics, diff --git a/cms/static/js/views/feedback.js b/cms/static/js/views/feedback.js index 0cfd6fa4ef..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", @@ -49,6 +53,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() @@ -101,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); } @@ -116,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); } diff --git a/cms/static/js/views/settings/advanced_view.js b/cms/static/js/views/settings/advanced_view.js index 863393d341..302a918de1 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,65 @@ 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(); + if(this.saved) { + this.saved.hide(); } }, - 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."); + self.saved = new CMS.Views.Alert.Confirmation({ + title: gettext("Your policy changes have been saved."), + message: message, + closeIcon: false + }); + self.saved.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/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); - 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/edit_subsection.html b/cms/templates/edit_subsection.html index cbce91ab44..fcda977324 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! import logging - from xmodule.util.date_utils import get_default_time_display + from xmodule.util.date_utils import get_default_time_display, almost_same_datetime %> <%! from django.core.urlresolvers import reverse %> @@ -47,9 +47,10 @@ placeholder="HH:MM" class="time" size='10' autocomplete="off"/> - % 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/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 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 -
- 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/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.

-
-
-
- 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..2e519edb30 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) @@ -33,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): """ @@ -44,7 +33,6 @@ class TestCohorts(django.test.TestCase): run=course.url_name, name=name) - @staticmethod def config_course_cohorts(course, discussions, cohorted, @@ -90,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. @@ -99,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 @@ -115,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) @@ -145,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) @@ -179,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. @@ -209,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' @@ -224,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/common/djangoapps/external_auth/tests/test_openid_provider.py b/common/djangoapps/external_auth/tests/test_openid_provider.py index 570dfbf9ee..1f7f201087 100644 --- a/common/djangoapps/external_auth/tests/test_openid_provider.py +++ b/common/djangoapps/external_auth/tests/test_openid_provider.py @@ -9,9 +9,11 @@ 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 +from unittest import skipUnless class MyFetcher(HTTPFetcher): @@ -59,21 +61,17 @@ 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): - # 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 + @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): # the provider URL must be converted to an absolute URL in order to be # used as an openid provider. @@ -99,10 +97,9 @@ class OpenIdProviderTest(TestCase): "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) - def testBeginLoginWithLoginUrl(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 + @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): # the provider URL must be converted to an absolute URL in order to be # used as an openid provider. @@ -150,49 +147,70 @@ class OpenIdProviderTest(TestCase): # # - - def testOpenIdSetup(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", - "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", + } + # 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""" + 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""" + self.attempt_login(403, return_to="http://apps.cs50.edx.or") + -# 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): - # 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 - + @skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or + settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True) + def test_begin_login(self): # 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 93ab70debb..2a673acdf8 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 @@ -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, UntrustedReturnURL 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=%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=%s' % 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=%s' % eamap) + log.debug('Created eamap=%s', 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 %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 %s' % 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 %s yet, doing signup' % 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 %s yet, doing signup' % 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 %s' % 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 %s / %s" % - (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 %s is not active" % (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 %s' % 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: %r" % shib) + log.info("SHIB creds returned: %r", 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 (UntrustedReturnURL, ProtocolError): + openid_request = None if not openid_request: return default_render_failure(request, "Invalid OpenID request") @@ -697,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...) @@ -708,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 @@ -720,10 +722,8 @@ 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)) + log.info("OpenID login success - %s (%s)", + user.username, user.email) # redirect user to return_to location url = endpoint + urlquote(user.username) @@ -753,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 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/student/forms.py b/common/djangoapps/student/forms.py new file mode 100644 index 0000000000..1096092117 --- /dev/null +++ b/common/djangoapps/student/forms.py @@ -0,0 +1,21 @@ +from django import forms +from django.contrib.auth.models import User +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.hashers import UNUSABLE_PASSWORD + +class PasswordResetFormNoActive(PasswordResetForm): + def clean_email(self): + """ + 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) + 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 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/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 4638da44b2..844ddb536e 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -5,18 +5,127 @@ 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.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 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.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""" + + 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'})) + + 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'})) + + @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) + 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 + 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""" + + bad_reset_req = self.request_factory.get('/password_reset_confirm/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) + + @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'], 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""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e065333409..f0eab39781 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -4,16 +4,16 @@ import json import logging import random import re -import string +import string # pylint: disable=W0402 import urllib import uuid 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, @@ -982,7 +975,21 @@ 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. + 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): 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/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/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) diff --git a/common/djangoapps/track/tests.py b/common/djangoapps/track/tests.py index 501deb776c..bfa84a620f 100644 --- a/common/djangoapps/track/tests.py +++ b/common/djangoapps/track/tests.py @@ -1,16 +1,56 @@ -""" -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. -""" - +"""Tests for student tracking""" 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 SimpleTest(TestCase): - def test_basic_addition(self): +class TrackingTest(TestCase): + """ + Tests that tracking logs correctly handle events + """ + + def test_post_answers_to_log(self): """ - Tests that 1 + 1 always equals 2. + Checks that student answer requests submitted to track.views via POST + are correctly logged in the TrackingLog db table """ - self.assertEqual(1 + 1, 2) + 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 221bab5468..b65f9fa043 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,14 @@ 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 +94,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 +138,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/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) 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/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] - 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/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 c40a74ac41..d7204e516c 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. @@ -739,20 +739,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): @@ -813,7 +813,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: @@ -827,7 +827,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'] @@ -1013,16 +1013,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'. @@ -1036,7 +1036,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/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_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/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 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/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/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d9f7fc61aa..eeb8f19439 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -47,6 +47,9 @@ def randomization_bin(seed, problem_id): class Randomization(String): + """ + Define a field to store how to randomize a problem. + """ def from_json(self, value): if value in ("", "true"): return "always" @@ -58,24 +61,39 @@ 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 "{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) class CapaFields(object): - attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) + """ + 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( 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 +104,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 ) @@ -114,12 +142,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'), @@ -134,7 +162,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 @@ -167,7 +197,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 +206,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 @@ -192,7 +225,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'): @@ -206,6 +241,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 @@ -218,6 +256,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, @@ -227,6 +268,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'] @@ -235,26 +279,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, @@ -265,6 +319,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" """ @@ -350,27 +405,26 @@ 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) + log.exception(err.message) # 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 @@ -416,8 +470,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() @@ -454,22 +512,24 @@ 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) - 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, 'progress' : 'none'/'in_progress'/'done', } - ''' + """ handlers = { 'problem_get': self.get_problem, 'problem_check': self.check_problem, @@ -487,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): """ @@ -508,7 +569,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(): @@ -527,18 +590,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": @@ -565,66 +634,68 @@ 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: empty dictionary 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. Returns the answers: {'answers' : answers} - ''' + """ event_info = dict() event_info['problem_id'] = self.location.url() self.system.track_function('showanswer', event_info) @@ -641,51 +712,55 @@ 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) return {'answers': new_answers} # 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. + 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 - 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). + def make_dict_of_responses(data): + """ + Make dictionary of student responses (aka "answers") - The *get* dict has keys of the form 'x_y', which are mapped + `data` is POST dictionary (Django QueryDict). + + 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 >= 1 underscores - (e.g. "input" is invalid; "input_1" is valid) + -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. - (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: + for key in data: # e.g. input_resistor_1 ==> resistor_1 _, _, name = key.partition('_') @@ -693,7 +768,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 @@ -704,14 +779,14 @@ 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 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 @@ -728,19 +803,21 @@ class CapaModule(CapaFields, XModule): 'max_value': score['total'], }) - def check_problem(self, get): - ''' Checks whether answers to a problem are correct, and - returns a map of correct/incorrect answers: + def check_problem(self, data): + """ + 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() - 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' @@ -759,7 +836,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,19 +854,19 @@ 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 else: - msg = "Error: %s" % str(inst.message) + msg = u"Error: {msg}".format(msg=inst.message) return {'success': msg} except Exception as err: if self.system.DEBUG: - msg = "Error checking problem: " + str(err) - 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 @@ -897,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 } @@ -907,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 @@ -936,17 +1014,18 @@ class CapaModule(CapaFields, XModule): return {'success': True, 'msg': msg} - def reset_problem(self, get): - ''' Changes problem state to unfinished -- removes student answers, - and causes problem to rerender itself. + def reset_problem(self, _data): + """ + 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() @@ -993,7 +1072,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 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/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/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/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: diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py new file mode 100644 index 0000000000..f84b366d2c --- /dev/null +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -0,0 +1,311 @@ +""" +Adds crowdsourced hinting functionality to lon-capa numerical response problems. + +Currently experimental - not for instructor use, yet. +""" + +import logging +import json +import random + +from pkg_resources import resource_string + +from lxml import etree + +from xmodule.x_module import XModule +from xmodule.xml_module import XmlDescriptor +from xblock.core import Scope, String, Integer, Boolean, Dict, List + +from django.utils.html import escape + +log = logging.getLogger(__name__) + + +class CrowdsourceHinterFields(object): + """Defines fields for the crowdsource hinter module.""" + has_children = True + + 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') + # 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 + # 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. + 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' + css = {'scss': [resource_string(__name__, 'css/crowdsource_hinter/display.scss')]} + 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): + """ + Puts a wrapper around the problem html. This wrapper includes ajax urls of the + hinter and of the problem. + - Dependent on lon-capa problem. + """ + 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 = {} + + 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 + 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_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) + elif dispatch == 'get_feedback': + out = self.get_feedback(get) + elif dispatch == 'vote': + out = self.tally_vote(get) + elif dispatch == 'submit_hint': + out = self.submit_hint(get) + else: + return json.dumps({'contents': 'Error - invalid operation.'}) + + 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.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. + self.previous_answers += [[answer, [None, None, None]]] + return + # Get the top hint, plus two random hints. + n_hints = len(local_hints[answer]) + best_hint_index = max(local_hints[answer], key=lambda key: local_hints[answer][key][1]) + best_hint = local_hints[answer][best_hint_index][0] + if len(local_hints[answer]) == 1: + rand_hint_1 = '' + rand_hint_2 = '' + self.previous_answers += [[answer, [best_hint_index, None, None]]] + elif n_hints == 2: + best_hint = local_hints[answer].values()[0][0] + best_hint_index = local_hints[answer].keys()[0] + rand_hint_1 = local_hints[answer].values()[1][0] + hint_index_1 = local_hints[answer].keys()[1] + rand_hint_2 = '' + self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] + else: + (hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ + 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]]] + + return {'best_hint': best_hint, + '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? + if len(self.previous_answers) == 0: + # No. Nothing to do here. + return + # 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. + # index_to_hints[previous answer #] = [(hint text, hint pk), + ] + index_to_hints = {} + # 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 is not None: + try: + index_to_hints[i].append((self.hints[answer][str(hint_id)][0], hint_id)) + except KeyError: + # Sometimes, the hint that a user saw will have been deleted by the instructor. + continue + + 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. + + Args: + `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. + """ + if self.user_voted: + 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 + self.hints = temp_dict + # Don't let the user vote again! + self.user_voted = True + + # 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 is None: + continue + hint_and_votes.append(temp_dict[answer][str(hint_no)]) + + # Reset self.previous_answers. + self.previous_answers = [] + return {'hint_and_votes': hint_and_votes} + + def submit_hint(self, get): + """ + Take a hint submission and add it to the database. + + 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 or self.mod_queue. (Awkward because a direct write + # is necessary.) + if self.moderate == 'True': + 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 == 'True': + self.mod_queue = temp_dict + else: + self.hints = temp_dict + # Mark the user has having voted; reset previous_answers + self.user_voted = True + self.previous_answers = [] + return {'message': '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 diff --git a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss new file mode 100644 index 0000000000..07d183eb36 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss @@ -0,0 +1,65 @@ +.crowdsource-wrapper { + @include box-shadow(inset 0 1px 2px 1px rgba(0,0,0,0.1)); + @include border-radius(2px); + display: none; + margin-top: 20px; + padding: (15px); + background: rgb(253, 248, 235); +} + +#answer-tabs { + background: #FFFFFF; + border: none; + margin-bottom: 20px; + padding-bottom: 20px; +} + +#answer-tabs .ui-widget-header { + border-bottom: 1px solid #DCDCDC; + background: #F3F3F3; +} + +#answer-tabs .ui-tabs-nav .ui-state-default { + border: 1px solid #DCDCDC; + background: #F8F8F8; + margin-bottom: 0px; +} + +#answer-tabs .ui-tabs-nav .ui-state-default:hover { + background: #FFFFFF; +} + +#answer-tabs .ui-tabs-nav .ui-state-active:hover { + background: #FFFFFF; +} + +#answer-tabs .ui-tabs-nav .ui-state-active { + border: 1px solid #DCDCDC; + background: #FFFFFF; +} + +#answer-tabs .ui-tabs-nav .ui-state-active a { + color: #222222; + background: #FFFFFF; +} + +#answer-tabs .ui-tabs-nav .ui-state-default a:hover { + color: #222222; + background: #FFFFFF; +} + +#answer-tabs .custom-hint { + height: 100px; + width: 100%; +} + +.hint-inner-container { + padding-left: 15px; + padding-right: 15px; + font-size: 16px; +} + +.vote { + padding-top: 0px !important; + padding-bottom: 0px !important; +} diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index df24abcc00..e29276936b 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -162,7 +162,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 @@ -247,6 +248,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 @@ -389,8 +391,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 @@ -404,7 +404,6 @@ class @Problem inputtypeHideAnswerMethods: choicegroup: (element, display) => element = $(element) - element.find('input').attr('disabled', null) element.find('label').removeClass('choicegroup_correct') javascriptinput: (element, display) => 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..f8bc6037db --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -0,0 +1,74 @@ +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) + @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 + @$('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) + 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') + post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} + $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => + @render(response.contents) + + clear_default_text: (eventObj) => + target = @$(eventObj.currentTarget) + if target.data('cleared') == undefined + target.val('') + target.data('cleared', true) + + render: (content) -> + if content + # Trim leading and trailing whitespace + content = content.replace /^\s+|\s+$/g, "" + + if content + @el.html(content) + @el.show() + JavascriptLoader.executeModuleScripts @el, () => + @bind() + @$('#previous-answer-0').css('display', 'inline') diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index e7b207ca28..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[^/]+)/ @@ -52,8 +43,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__ = () @@ -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) 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/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 94823b0be4..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 40288a933b..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__) @@ -195,7 +194,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 @@ -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/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 04e79ce521..1a3d2699cc 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -8,17 +8,97 @@ 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 collection with templates before running the TestCase and drops it they are finished. """ + @staticmethod + def update_course(course, data): + """ + 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_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 @@ -27,14 +107,15 @@ class ModuleStoreTestCase(TestCase): # Remove everything except templates modulestore.collection.remove(query) + modulestore.collection.drop() @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 @@ -46,9 +127,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. @@ -66,9 +147,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() @@ -80,9 +161,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() @@ -94,9 +175,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 0a62849d8d..a7f0a71a59 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 @@ -59,6 +60,10 @@ 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 @@ -147,6 +152,10 @@ 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 @@ -181,6 +190,7 @@ def get_test_xmodule_for_descriptor(descriptor): ) return descriptor.xmodule(module_sys) + def _test_xblock_model_data_accessor(descriptor): simple_map = {} for field in descriptor.fields: 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(): 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/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..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 @@ -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,35 +618,35 @@ 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 { '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.' @@ -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..580475e1ae 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -59,13 +59,13 @@ 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, 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/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()) 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.

diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 696ef58268..1e84174291 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1,4 +1,7 @@ -"""Tests of the Capa XModule""" +# -*- coding: utf-8 -*- +""" +Tests of the Capa XModule +""" #pylint: disable=C0111 #pylint: disable=R0904 #pylint: disable=C0103 @@ -8,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 @@ -47,12 +51,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") @@ -361,7 +369,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. @@ -496,9 +506,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) @@ -520,6 +531,60 @@ 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 + exception_classes = [StudentInputError, + LoncapaProblemError, + ResponseError] + for exception_class in exception_classes: + + # 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 @@ -1021,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 @@ -1126,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 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..f57e28ef46 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -0,0 +1,439 @@ +""" +Tests the crowdsourced hinter xmodule. +""" + +from mock import Mock +import unittest +import copy + +from xmodule.crowdsource_hinter import CrowdsourceHinterModule +from xmodule.vertical_module import VerticalModule, VerticalDescriptor + +from . import get_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(): + """ + Helps make unique names for our mock CrowdsourceHinterModule's + """ + CHModuleFactory.num += 1 + return CHModuleFactory.num + + @staticmethod + def create(hints=None, + previous_answers=None, + user_voted=None, + moderate=None, + mod_queue=None): + """ + A factory method for making CHM's + """ + model_data = {'data': CHModuleFactory.sample_problem_xml} + + 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]}, + '25.0': {'1': ['Really popular hint', 100]} + } + + if mod_queue is not 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 is not 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 is not None: + model_data['user_voted'] = user_voted + + 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. + 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(): + 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): + """ + A fake Xmodule. + """ + def __init__(self): + self.system = Mock() + 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 + 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_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) + + @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 + """ + 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. + - Output should be blank. + - New entry should be added to previous_answers + """ + m = CHModuleFactory.create() + json_in = {'problem_name': '26.0'} + out = m.get_hint(json_in) + self.assertTrue(out is None) + 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'} + 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. + - 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'} + out = m.get_hint(json_in) + self.assertTrue(out['best_hint'] == 'Best hint') + 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. + Output should be empty. + """ + m = CHModuleFactory.create(previous_answers=[]) + json_in = {'problem_name': '42.5'} + out = m.get_feedback(json_in) + self.assertTrue(out is None) + + 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'} + 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]]]) + 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) + + 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! + Should not change any vote tallies. + """ + m = CHModuleFactory.create(user_voted=True) + json_in = {'answer': 0, 'hint': 1} + old_hints = copy.deepcopy(m.hints) + m.tally_vote(json_in) + self.assertTrue(m.hints == old_hints) + + def test_vote_withpermission(self): + """ + A user votes for a hint. + Also tests vote result rendering. + """ + m = CHModuleFactory.create( + previous_answers=[['24.0', [0, 3, None]]]) + json_in = {'answer': 0, 'hint': 3} + 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(['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. + """ + m = CHModuleFactory.create(user_voted=True) + json_in = {'answer': 1, 'hint': 'This is a new hint.'} + print m.user_voted + m.submit_hint(json_in) + 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 + exist yet. + """ + m = CHModuleFactory.create() + json_in = {'answer': 1, 'hint': 'This is a new hint.'} + 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]]]) + 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.')) + + def test_submithint_moderate(self): + """ + A user submits a hint, but moderation is on. The hint should + show up in the mod_queue, not the public-facing hints + dict. + """ + m = CHModuleFactory.create(moderate='True') + json_in = {'answer': 1, 'hint': 'This is a new hint.'} + m.submit_hint(json_in) + 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): + """ + Test the templates for get_hint. + """ + m = CHModuleFactory.create() + + def fake_get_hint(get): + """ + Creates a rendering dictionary, with which we can test + the templates. + """ + return {'best_hint': 'This is the best hint.', + 'rand_hint_1': 'A random hint', + 'rand_hint_2': 'Another random hint', + 'answer': '42.5'} + + m.get_hint = fake_get_hint + json_in = {'problem_name': '42.5'} + out = json.loads(m.handle_ajax('get_hint', json_in))['contents'] + self.assertTrue('This is the best hint.' in out) + 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() + + def fake_get_feedback(get): + index_to_answer = {'0': '42.0', '1': '9000.01'} + index_to_hints = {'0': [('A hint for 42', 12), + ('Another hint for 42', 14)], + '1': [('A hint for 9000.01', 32)]} + return {'index_to_hints': index_to_hints, 'index_to_answer': index_to_answer} + + 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/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 a001339311..94baabcf98 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,61 @@ 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_fallthrough(self): + with self.assertRaises(TypeError): + self.encoder.default(None) + + with self.assertRaises(TypeError): + self.encoder.default({}) 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_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/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/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/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/util/date_utils.py b/common/lib/xmodule/xmodule/util/date_utils.py index 933226ede6..ffd135c00d 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 @@ -11,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) @@ -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 diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 6344da7994..3c6203107d 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() @@ -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 diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index a64e094a58..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') @@ -125,9 +128,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/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 33120ec180..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): @@ -141,9 +142,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 +348,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 +410,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/common/static/coffee/spec/logger_spec.coffee b/common/static/coffee/spec/logger_spec.coffee index 8866daa570..4a53b8c455 100644 --- a/common/static/coffee/spec/logger_spec.coffee +++ b/common/static/coffee/spec/logger_spec.coffee @@ -3,15 +3,20 @@ 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' + 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 6da4929fb0..dffc14e067 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -1,17 +1,49 @@ 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) -> + # 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 - # 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', + # 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. + $.postWithPrefix '/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 diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html new file mode 100644 index 0000000000..bc49bf18bd --- /dev/null +++ b/common/templates/hinter_display.html @@ -0,0 +1,125 @@ +## The hinter module passes in a field called ${op}, which determines which +## sub-function to render. + + +<%def name="get_hint()"> + % if best_hint != '': +

Hints from students who made similar mistakes:

+
    +
  • ${best_hint}
  • + % endif + % if rand_hint_1 != '': +
  • ${rand_hint_1}
  • + % endif + % if rand_hint_2 != '': +
  • ${rand_hint_2}
  • + % endif +
+ + +<%def name="get_feedback()"> +

Participation in the hinting system is strictly optional, and will not influence your grade.

+

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

+ +
+
    + % for index, answer in index_to_answer.items(): +
  • ${answer}
  • + % endfor +
+ + % for index, answer in index_to_answer.items(): +
+
+ % if index in index_to_hints and len(index_to_hints[index]) > 0: +

+ 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. +

+ % endif +

+ What hint would you give a student who made the same mistake you did? Please don't give away the answer. +

+ +

+ +
+ % endfor +
+ +

Read about what makes a good hint.

+ + + + +<%def name="show_votes()"> + Thank you for voting! +
+ % for hint, votes in hint_and_votes: + ${votes} votes. + ${hint} +
+ % endfor + + +<%def name="simple_message()"> + ${message} + + +% if op == "get_hint": + ${get_hint()} +% endif + +% if op == "get_feedback": + ${get_feedback()} +% endif + +% if op == "submit_hint": + ${simple_message()} +% endif + +% if op == "vote": + ${show_votes()} +% endif 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" + } } 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. diff --git a/jenkins/test.sh b/jenkins/test.sh index c7728ab367..70a9e168bc 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -60,10 +60,7 @@ 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 +source $VIRTUALENV_DIR/bin/activate bundle install 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 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/courseware/access.py b/lms/djangoapps/courseware/access.py index ec90260928..8259507617 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 @@ -588,7 +586,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/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/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/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 3ffb1d1b1d..4cafb0979d 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__) @@ -59,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): @@ -169,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) @@ -221,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 @@ -352,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: @@ -397,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 @@ -464,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) @@ -493,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: @@ -505,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: @@ -527,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. @@ -540,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/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py new file mode 100644 index 0000000000..6890a6df2a --- /dev/null +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -0,0 +1,134 @@ +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. + + `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, + "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. + + `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, + "got code %d for url '%s'. Expected code %d" + % (resp.status_code, url, code)) + return resp + + +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. + """ + 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 = 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 the HTTP response code indicates redirection + as expected. + """ + # should redirect + check_for_get_code(self, 302, reverse('logout')) + + def create_account(self, username, email, password): + """ + Create the account and check that it worked. + """ + resp = check_for_post_code(self, 200, reverse('create_account'), { + 'username': username, + 'email': email, + 'password': password, + 'name': 'username', + 'terms_of_service': 'true', + 'honor_code': 'true', + }) + 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 + 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) + + def enroll(self, course, verify=False): + """ + Try to enroll and return boolean indicating result. + `course` is an instance of CourseDescriptor. + `verify` is an optional boolean 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, + }) + result = resp.status_code == 200 + if verify: + self.assertTrue(result) + return result + + def unenroll(self, course): + """ + Unenroll the currently logged-in user, and check that it worked. + `course` is an instance of CourseDescriptor. + """ + 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 new file mode 100644 index 0000000000..80a7b0a7c1 --- /dev/null +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -0,0 +1,8 @@ +from xmodule.modulestore.tests.django_utils import xml_store_config, mongo_store_config, draft_mongo_store_config + +from django.conf import settings + +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/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íáßç') 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 47d437a316..3dc3d2b6b1 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -12,13 +12,15 @@ from django.test.utils import override_settings from django.core.urlresolvers import reverse -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, User 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 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_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_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py new file mode 100644 index 0000000000..dd1f00711c --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -0,0 +1,100 @@ +from django.core.urlresolvers import reverse +from django.test.utils import override_settings + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from helpers import LoginEnrollmentTestCase, check_for_get_code +from modulestore_config import TEST_DATA_MONGO_MODULESTORE + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + 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') + 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, + 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 student accounts and activate them. + for i in range(len(self.STUDENT_INFO)): + 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. + """ + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + self.enroll(self.test_course, True) + + resp = self.client.get(reverse('courseware', + kwargs={'course_id': self.course.id})) + + self.assertRedirects(resp, reverse( + 'courseware_section', kwargs={'course_id': self.course.id, + 'chapter': 'Overview', + 'section': 'Welcome'})) + + def test_redirects_second_time(self): + """ + Verify the accordion remembers we've already visited the Welcome section + and redirects correpondingly. + """ + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, True) + self.enroll(self.test_course, 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.assertRedirects(resp, reverse('courseware_chapter', + kwargs={'course_id': self.course.id, + 'chapter': 'Overview'})) + + def test_accordion_state(self): + """ + Verify the accordion remembers which chapter you were last viewing. + """ + email, password = self.STUDENT_INFO[0] + self.login(email, password) + self.enroll(self.course, 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', + 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.assertRedirects(resp, reverse('courseware_chapter', + kwargs={'course_id': self.course.id, + 'chapter': 'factory_chapter'})) 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) 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..055c860fcc --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -0,0 +1,374 @@ +import datetime +import pytz + +from mock import patch + +from django.contrib.auth.models import User, Group +from django.core.urlresolvers import reverse +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, settings as access_settings) + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +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(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Check that view authentication works properly. + """ + + ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] + + @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 _check_non_staff_light(self, course): + """ + Check that non-staff have access to light urls. + + `course` is an instance of CourseDescriptor. + """ + urls = [reverse('about_course', kwargs={'course_id': course.id}), reverse('courses')] + for url in urls: + 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. + """ + + 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: + check_for_get_code(self, 404, url) + + def _check_staff(self, course): + """ + Check that access is right for staff in course. + """ + 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: + 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}) + 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.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.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') + self.welcome_section = ItemFactory.create(parent_location=self.overview_chapter.location, + display_name='Welcome') + + # Create two accounts and activate them. + for i in range(len(self.ACCOUNT_INFO)): + 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): + """ + 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', + 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. + """ + email, password = self.ACCOUNT_INFO[0] + self.login(email, password) + self.enroll(self.course) + + response = self.client.get(reverse('courseware', + kwargs={'course_id': 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. + """ + email, password = self.ACCOUNT_INFO[0] + self.login(email, password) + + self.enroll(self.course) + self.enroll(self.test_course) + + urls = [reverse('instructor_dashboard', kwargs={'course_id': self.course.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: + check_for_get_code(self, 404, url) + + def test_instructor_course_access(self): + """ + 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 + group_name = _course_staff_group_name(self.course.location) + group = Group.objects.create(name=group_name) + group.user_set.add(User.objects.get(email=email)) + + self.login(email, password) + + # Now should be able to get to self.course, but not self.test_course + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + check_for_get_code(self, 200, 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): + """ + Verify the instructor can load staff pages if he is given + staff permissions. + """ + email, password = self.ACCOUNT_INFO[1] + self.login(email, password) + + # now make the instructor also staff + instructor = User.objects.get(email=email) + instructor.is_staff = True + instructor.save() + + # 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.test_course.id})] + + for url in urls: + check_for_get_code(self, 200, url) + + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_dark_launch_enrolled_student(self): + """ + Make sure that before course start, students can't access course + pages. + """ + + student_email, student_password = self.ACCOUNT_INFO[0] + + # Make courses start in the future + now = datetime.datetime.now(pytz.UTC) + tomorrow = now + datetime.timedelta(days=1) + course_data = {'start': tomorrow} + test_course_data = {'start': tomorrow} + self.course = self.update_course(self.course, course_data) + self.test_course = self.update_course(self.test_course, test_course_data) + + self.assertFalse(self.course.has_started()) + self.assertFalse(self.test_course.has_started()) + + # First, try with an enrolled student + self.login(student_email, student_password) + self.enroll(self.course, 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.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): + """ + 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} + test_course_data = {'start': tomorrow} + self.course = self.update_course(self.course, course_data) + self.test_course = self.update_course(self.test_course, test_course_data) + + # 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. + self.enroll(self.course, True) + self.enroll(self.test_course, True) + + # should now be able to get to everything for self.course + 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}) + 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] + + now = datetime.datetime.now(pytz.UTC) + tomorrow = now + datetime.timedelta(days=1) + course_data = {'start': tomorrow} + test_course_data = {'start': tomorrow} + self.course = self.update_course(self.course, course_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.test_course, True) + + # now also make the instructor staff + instructor = User.objects.get(email=instructor_email) + instructor.is_staff = True + instructor.save() + + # and now should be able to load both + self._check_staff(self.course) + 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] + + # 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} + test_course_data = {'enrollment_start': yesterday, 'enrollment_end': tomorrow} + + # self.course's enrollment period hasn't started + self.course = self.update_course(self.course, course_data) + # test_course course's has + self.test_course = self.update_course(self.test_course, test_course_data) + + # First, try with an enrolled student + self.login(student_email, student_password) + self.assertFalse(self.enroll(self.course)) + self.assertTrue(self.enroll(self.test_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(User.objects.get(email=instructor_email)) + + self.logout() + self.login(instructor_email, instructor_password) + self.assertTrue(self.enroll(self.course)) + + # 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) + instructor.is_staff = True + instructor.save() + + # unenroll and try again + self.unenroll(self.course) + self.assertTrue(self.enroll(self.course)) + + @patch.dict(access_settings.MITX_FEATURES, {'DISABLE_START_DATES': False}) + def test_beta_period(self): + """ + Check that beta-test access works. + """ + 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) + course_data = {'start': tomorrow} + + # self.course's hasn't started + 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 = User.objects.get(email=student_email) + 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/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 25492ad379..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 @@ -14,28 +13,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""" @@ -67,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/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 056a73e7c8..157cd06d4f 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,298 +1,61 @@ ''' 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 -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 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): - """ - 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 +from helpers import LoginEnrollmentTestCase +from modulestore_config import TEST_DATA_DIR,\ + TEST_DATA_XML_MODULESTORE,\ + TEST_DATA_MONGO_MODULESTORE,\ + TEST_DATA_DRAFT_MONGO_MODULESTORE class ActivateLoginTest(LoginEnrollmentTestCase): - '''Test logging in and logging out''' + """ + Test logging in and logging out. + """ def setUp(self): - self.setup_viewtest_user() + self.setup_user() def test_activate_login(self): - '''Test login -- the setup function does all the work''' + """ + Test login -- the setup function does all the work. + """ pass def test_logout(self): - '''Test logout -- setup function does login''' + """ + Test logout -- setup function does login. + """ self.logout() class PageLoaderTestCase(LoginEnrollmentTestCase): - ''' Base class that adds a function to load all pages in a modulestore ''' + """ + 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 + """ + 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) + self.enroll(course, True) course_id = course.id # Search for items in the course @@ -339,12 +102,12 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): 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) @@ -364,11 +127,13 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): - '''Check that all pages in test courses load properly from XML''' + """ + Check that all pages in test courses load properly from XML. + """ def setUp(self): super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() - self.setup_viewtest_user() + self.setup_user() xmodule.modulestore.django._MODULESTORES = {} def test_toy_course_loads(self): @@ -383,11 +148,13 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): - '''Check that all pages in test courses load properly from Mongo''' + """ + Check that all pages in test courses load properly from Mongo. + """ def setUp(self): super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() - self.setup_viewtest_user() + self.setup_user() xmodule.modulestore.django._MODULESTORES = {} modulestore().collection.drop() @@ -405,67 +172,6 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): self.assertGreater(len(course.textbooks), 0) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -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") - - # 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.toy) - self.enroll(self.full) - - # First request should redirect to ToyVideos - resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.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, - 'chapter': 'Overview', - 'section': 'Toy_Videos'})) - - # Hitting the couseware tab again should - # redirect to the first chapter: 'Overview' - resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) - - self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.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'})) - - # And now hitting the courseware tab should redirect to 'secret:magic' - resp = self.client.get(reverse('courseware', - kwargs={'course_id': self.toy.id})) - - self.assertRedirectsNoFollow(resp, reverse('courseware_chapter', - kwargs={'course_id': self.toy.id, - 'chapter': 'secret:magic'})) - - @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) class TestDraftModuleStore(TestCase): def test_get_items_with_course_items(self): @@ -478,558 +184,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 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""" - - # 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 - - -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.""" - - 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/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/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 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) diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index b04bd787d8..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) 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): + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError, User.DoesNotExist): raise Http404 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/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/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 = {} diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py new file mode 100644 index 0000000000..73c4ba220f --- /dev/null +++ b/lms/djangoapps/instructor/hint_manager.py @@ -0,0 +1,238 @@ +""" +Views for hint management. + +Along with the crowdsource_hinter xmodule, this code is still +experimental, and should not be used in new courses, yet. +""" + +import json +import re + +from django.http import HttpResponse, Http404 +from django_future.csrf import ensure_csrf_cookie + +from mitxmako.shortcuts import render_to_response, render_to_string + +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 +def hint_manager(request, course_id): + try: + 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 HttpResponse(out) + 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) + 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': + 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})) + + +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. + + 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' + 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' + # The course_id is of the form school/number/classname. + # We want to use the course_id to find all matching definition_id's. + # To do this, just take the school/number part - leave off the classname. + 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 maps a problem id to a list of [answer, hints] pairs, sorted in order of answer. + big_out_dict = {} + # id_to name maps a problem id to the name of the problem. + # id_to_name[problem id] = Display name of problem + id_to_name = {} + + for hints_by_problem in all_hints: + loc = Location(hints_by_problem.definition_id) + name = location_to_problem_name(loc) + if name is None: + continue + id_to_name[hints_by_problem.definition_id] = name + + 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') + + # Answer list contains [answer, dict_of_hints] pairs. + 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': 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): + """ + 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 + 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] points to a [hint_text, #votes] pair. + problem_dict[answer][pk][1] = int(new_votes) + this_problem.value = json.dumps(problem_dict) + this_problem.save() + + +def add_hint(request, course_id, field): + """ + 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'] + 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/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/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 3ce82b700b..f84106a52a 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -1,177 +1,254 @@ -''' +""" 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 courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_MONGO_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 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/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/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py new file mode 100644 index 0000000000..8f12572875 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -0,0 +1,164 @@ +import json + +from django.test.client import Client, RequestFactory +from django.test.utils import override_settings + +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 +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@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. + """ + 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. + """ + c = Client() + 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) + + def test_staff_access(self): + """ + Makes sure that staff can access the hint management view. + """ + 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'}) + 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. + """ + 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_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. + """ + 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) + + 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) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index e9fff63698..9f9b7a2399 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__) @@ -197,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)) @@ -205,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: @@ -218,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: @@ -259,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: @@ -275,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) @@ -329,9 +331,8 @@ 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') + 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,13 +346,12 @@ 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') + 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. " @@ -362,8 +362,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) @@ -375,13 +374,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) #---------------------------------------- @@ -450,7 +443,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) @@ -460,7 +453,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'] @@ -479,7 +472,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'] @@ -498,7 +491,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 @@ -547,7 +540,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'] @@ -571,55 +564,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 @@ -634,13 +621,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': @@ -669,7 +658,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) @@ -922,8 +911,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 @@ -1068,9 +1056,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 +1084,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 +1110,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 +1163,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 +1193,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 +1209,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 +1230,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/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/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 diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index 99b8b1a929..a46b4b12fe 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -8,7 +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.contrib.auth.models import Group, User from django.conf import settings from mitxmako.shortcuts import render_to_string @@ -20,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 @@ -30,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): @@ -57,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) @@ -74,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) @@ -83,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) @@ -112,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) @@ -129,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/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/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/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/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 }, } 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/envs/dev.py b/lms/envs/dev.py index b1519b77bc..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 @@ -258,6 +259,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 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', } } } diff --git a/lms/envs/test.py b/lms/envs/test.py index e9b683487e..f23be52a51 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -27,6 +27,11 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = False 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 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/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) 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 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) 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 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"], diff --git a/lms/templates/courseware/hint_manager.html b/lms/templates/courseware/hint_manager.html new file mode 100644 index 0000000000..ebd7091a09 --- /dev/null +++ b/lms/templates/courseware/hint_manager.html @@ -0,0 +1,124 @@ +<%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..c69539522f --- /dev/null +++ b/lms/templates/courseware/hint_manager_inner.html @@ -0,0 +1,45 @@ +<%block name="main"> + + +

    ${field_label}

    +Switch to ${other_field_label} + + +% 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: +

    +

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

    + % endif + % endfor + +

    Add a hint to this problem

    +

    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 diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index ef1eb174fc..bc49cda427 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:

    @@ -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 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 - 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..4f9af149bc 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -37,7 +37,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"), @@ -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'), @@ -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', @@ -430,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: @@ -438,5 +445,3 @@ if settings.DEBUG: #Custom error pages handler404 = 'static_template_view.views.render_404' handler500 = 'static_template_view.views.render_500' - - 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 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| diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 3cb5e8f4e5..ebc2b92973 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 @@ -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 @@ -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 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 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##*/}