diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 897ea3ae3a..e108a31594 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,12 +5,31 @@ 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. +Blades: Took videoalpha out of alpha, replacing the old video player + +LMS: Enable beta instructor dashboard. The beta dashboard is a rearchitecture +of the existing instructor dashboard and is available by clicking a link at +the top right of the existing dashboard. + +Common: CourseEnrollment has new fields `is_active` and `mode`. The mode will be +used to differentiate different kinds of enrollments (currently, all enrollments +are honor certificate enrollments). The `is_active` flag will be used to +deactivate enrollments without deleting them, so that we know what course you +*were* enrolled in. Because of the latter change, enrollment and unenrollment +logic has been consolidated into the model -- you should use new class methods +to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating +CourseEnrollment objects or querying them directly. + Studio: Email will be sent to admin address when a user requests course creator privileges for Studio (edge only). Studio: Studio course authors (both instructors and staff) will be auto-enrolled for their courses so that "View Live" works. +Common: Add a new input type ```` for Formula/Numerical +Responses. It periodically makes AJAX calls to preview and validate the +student's input. + Common: Added ratelimiting to our authentication backend. Common: Add additional logging to cover login attempts and logouts. @@ -214,6 +233,12 @@ LMS: Fixed failing numeric response (decimal but no trailing digits). LMS: XML Error module no longer shows students a stack trace. +Studio: Add feedback to end user if there is a problem exporting a course + +Studio: Improve link re-writing on imports into a different course-id + +Studio: Allow for intracourse linking in Capa Problems + Blades: Videoalpha. XModules: Added partial credit for foldit module. @@ -222,6 +247,10 @@ XModules: Added "randomize" XModule to list of XModule types. XModules: Show errors with full descriptors. +Studio: Add feedback to end user if there is a problem exporting a course + +Studio: Improve link re-writing on imports into a different course-id + XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is dropped suddenly. diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 69d2213eb4..516659fadb 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -146,12 +146,13 @@ def fill_in_course_info( def log_into_studio( uname='robot', email='robot+studio@edx.org', - password='test'): + password='test', + name='Robot Studio'): - world.log_in(username=uname, password=password, email=email, name='Robot Studio') + world.log_in(username=uname, password=password, email=email, name=name) # Navigate to the studio dashboard world.visit('/') - + world.wait_for(lambda _driver: uname in world.css_find('h2.title')[0].text) def create_a_course(): course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') @@ -209,27 +210,6 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): time.sleep(float(1)) -@step('I have created a Video component$') -def i_created_a_video_component(step): - world.create_component_instance( - step, '.large-video-icon', - 'video', - '.xmodule_VideoModule', - has_multiple_templates=False - ) - - -@step('I have created a Video Alpha component$') -def i_created_video_alpha(step): - step.given('I have enabled the videoalpha advanced module') - world.css_click('a.course-link') - step.given('I have added a new subsection') - step.given('I expand the first section') - world.css_click('a.new-unit-item') - world.css_click('.large-advanced-icon') - world.click_component_from_menu('videoalpha', None, '.xmodule_VideoAlphaModule') - - @step('I have enabled the (.*) advanced module$') def i_enabled_the_advanced_module(step, module): step.given('I have opened a new course section in Studio') @@ -247,16 +227,6 @@ def open_new_unit(step): world.css_click('a.new-unit-item') -@step('when I view the (video.*) it (.*) show the captions') -def shows_captions(_step, video_type, show_captions): - # Prevent cookies from overriding course settings - world.browser.cookies.delete('hide_captions') - if show_captions == 'does not': - assert world.css_has_class('.%s' % video_type, 'closed') - else: - assert world.is_css_not_present('.%s.closed' % video_type) - - @step('the save button is disabled$') def save_button_disabled(step): button_css = '.action-save' diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature index 95843fc423..de5bb6556a 100644 --- a/cms/djangoapps/contentstore/features/course-team.feature +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -71,7 +71,7 @@ Feature: Course Team And she selects the new course And she views the course team settings And she deletes me from the course team - And I log in + And I am logged into studio Then I do not see the course on my page Scenario: Admins should be able to remove their own admin rights diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index 07c30e1187..db7b4d81f9 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -2,9 +2,10 @@ #pylint: disable=W0621 from lettuce import world, step -from common import create_studio_user, log_into_studio +from common import create_studio_user from django.contrib.auth.models import Group -from auth.authz import get_course_groupname_for_role +from auth.authz import get_course_groupname_for_role, get_user_by_email +from nose.tools import assert_true PASSWORD = 'test' EMAIL_EXTENSION = '@edx.org' @@ -66,6 +67,7 @@ def other_delete_self(_step): email="robot+studio@edx.org") world.css_click(to_delete_css) # confirm prompt + world.wait(.5) world.css_click(".wrapper-prompt-warning .action-primary") @@ -89,7 +91,21 @@ def remove_course_team_admin(_step, outer_capture, name): @step(u'"([^"]*)" logs in$') def other_user_login(_step, name): - log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION) + world.browser.cookies.delete() + world.visit('/') + + signin_css = 'a.action-signin' + world.is_css_present(signin_css) + world.css_click(signin_css) + + def fill_login_form(): + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill(name + EMAIL_EXTENSION) + login_form.find_by_name('password').fill(PASSWORD) + login_form.find_by_name('submit').click() + world.retry_on_exception(fill_login_form) + assert_true(world.is_css_present('.new-course-button')) + world.scenario_dict['USER'] = get_user_by_email(name + EMAIL_EXTENSION) @step(u'I( do not)? see the course on my page') diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature index 455313b0e2..a0ba8099ac 100644 --- a/cms/djangoapps/contentstore/features/courses.feature +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -8,6 +8,6 @@ Feature: Create Course And I am logged into Studio When I click the New Course button And I fill in the new course information - And I press the "Save" button + And I press the "Create" button Then the Courseware page has loaded in Studio And I see a link for adding a new section diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index f28ee568dc..a53183e37c 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -1,16 +1,16 @@ Feature: Video Component Editor As a course author, I want to be able to create video components. - Scenario: User can view metadata + Scenario: User can view Video metadata Given I have created a Video component - And I edit and select Settings - Then I see the correct settings and default values + And I edit the component + Then I see the correct video settings and default values - Scenario: User can modify display name + Scenario: User can modify Video display name Given I have created a Video component - And I edit and select Settings + And I edit the component Then I can modify the display name - And my display name change is persisted on save + And my video display name change is persisted on save Scenario: Captions are hidden when "show captions" is false Given I have created a Video component diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index ad3229ab53..f9d433fc02 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -2,18 +2,7 @@ # pylint: disable=C0111 from lettuce import world, step - - -@step('I see the correct settings and default values$') -def i_see_the_correct_settings_and_values(step): - world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False], - ['Display Name', 'Video', False], - ['Download Track', '', False], - ['Download Video', '', False], - ['Show Captions', 'True', False], - ['Speed: .75x', '', False], - ['Speed: 1.25x', '', False], - ['Speed: 1.5x', '', False]]) +from terrain.steps import reload_the_page @step('I have set "show captions" to (.*)') @@ -24,9 +13,19 @@ def set_show_captions(step, setting): world.css_click('a.save-button') -@step('I see the correct videoalpha settings and default values$') -def correct_videoalpha_settings(_step): - world.verify_all_setting_entries([['Display Name', 'Video Alpha', False], +@step('when I view the (video.*) it (.*) show the captions') +def shows_captions(_step, video_type, show_captions): + # Prevent cookies from overriding course settings + world.browser.cookies.delete('hide_captions') + if show_captions == 'does not': + assert world.css_has_class('.%s' % video_type, 'closed') + else: + assert world.is_css_not_present('.%s.closed' % video_type) + + +@step('I see the correct video settings and default values$') +def correct_video_settings(_step): + world.verify_all_setting_entries([['Display Name', 'Video', False], ['Download Track', '', False], ['Download Video', '', False], ['End Time', '0', False], @@ -38,3 +37,12 @@ def correct_videoalpha_settings(_step): ['Youtube ID for .75x speed', '', False], ['Youtube ID for 1.25x speed', '', False], ['Youtube ID for 1.5x speed', '', False]]) + + +@step('my video display name change is persisted on save') +def video_name_persisted(step): + world.css_click('a.save-button') + reload_the_page(step) + world.edit_component() + world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True) + diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 634bb8a17f..50c06fde63 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -1,6 +1,7 @@ Feature: Video Component As a course author, I want to be able to view my created videos in Studio. + # Video Alpha Features will work in Firefox only when Firefox is the active window Scenario: Autoplay is disabled in Studio Given I have created a Video component Then when I view the video it does not have autoplay enabled @@ -23,32 +24,6 @@ Feature: Video Component And I have toggled captions Then when I view the video it does show the captions - # Video Alpha Features will work in Firefox only when Firefox is the active window - Scenario: Autoplay is disabled in Studio for Video Alpha - Given I have created a Video Alpha component - Then when I view the videoalpha it does not have autoplay enabled - - Scenario: User can view Video Alpha metadata - Given I have created a Video Alpha component - And I edit the component - Then I see the correct videoalpha settings and default values - - Scenario: User can modify Video Alpha display name - Given I have created a Video Alpha component - And I edit the component - Then I can modify the display name - And my videoalpha display name change is persisted on save - - Scenario: Video Alpha captions are hidden when "show captions" is false - Given I have created a Video Alpha component - And I have set "show captions" to False - Then when I view the videoalpha it does not show the captions - - Scenario: Video Alpha captions are shown when "show captions" is true - Given I have created a Video Alpha component - And I have set "show captions" to True - Then when I view the videoalpha it does show the captions - Scenario: Video data is shown correctly Given I have created a video with only XML data Then the correct Youtube video is shown diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index e27ca28eb7..2c3d2cdfa9 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -9,6 +9,16 @@ from contentstore.utils import get_modulestore ############### ACTIONS #################### +@step('I have created a Video component$') +def i_created_a_video_component(step): + world.create_component_instance( + step, '.large-video-icon', + 'video', + '.xmodule_VideoModule', + has_multiple_templates=False + ) + + @step('when I view the (.*) it does not have autoplay enabled') def does_not_autoplay(_step, video_type): assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False' @@ -22,6 +32,11 @@ def video_takes_a_single_click(_step): assert(world.is_css_present('.xmodule_VideoModule')) +@step('I edit the component') +def i_edit_the_component(_step): + world.edit_component() + + @step('I have (hidden|toggled) captions') def hide_or_show_captions(step, shown): button_css = 'a.hide-subtitles' @@ -38,18 +53,6 @@ def hide_or_show_captions(step, shown): button.mouse_out() world.css_click(button_css) -@step('I edit the component') -def i_edit_the_component(_step): - world.edit_component() - - -@step('my videoalpha display name change is persisted on save') -def videoalpha_name_persisted(step): - world.css_click('a.save-button') - reload_the_page(step) - world.edit_component() - world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True) - @step('I have created a video with only XML data') def xml_only_video(step): @@ -84,4 +87,5 @@ def xml_only_video(step): @step('The correct Youtube video is shown') def the_youtube_video_is_shown(_step): ele = world.css_find('.video').first - assert ele['data-youtube-id-1-0'] == world.scenario_dict['YOUTUBE_ID'] + assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID'] + diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 4d8c4eda55..3e0ecfb8d9 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -48,4 +48,7 @@ class Command(BaseCommand): print 'removing User permissions from course....' # in the django layer, we need to remove all the user permissions groups associated with this course if commit: - _delete_course_group(loc) + try: + _delete_course_group(loc) + except Exception as err: + print("Error in deleting course groups for {0}: {1}".format(loc, err)) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 23135964a9..e70df4164a 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -49,7 +49,7 @@ import datetime from pytz import UTC from uuid import uuid4 from pymongo import MongoClient -from student.views import is_enrolled_in_course +from student.models import CourseEnrollment TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -107,8 +107,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): expected_types is the list of elements that should appear on the page. expected_types and component_types should be similar, but not - exactly the same -- for example, 'videoalpha' in - component_types should cause 'Video Alpha' to be present. + exactly the same -- for example, 'video' in + component_types should cause 'Video' to be present. """ store = modulestore('direct') import_from_xml(store, 'common/test/data/', ['simple']) @@ -136,14 +136,13 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_advanced_components_in_edit_unit(self): # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page # response HTML - self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha', - 'Word cloud', + self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Word cloud', 'Annotation', 'Open Response Assessment', 'Peer Grading Interface']) def test_advanced_components_require_two_clicks(self): - self.check_components_on_page(['videoalpha'], ['Video Alpha']) + self.check_components_on_page(['word_cloud'], ['Word cloud']) def test_malformed_edit_unit_request(self): store = modulestore('direct') @@ -644,6 +643,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content_store = contentstore() + # now do the actual cloning clone_course(module_store, content_store, source_location, dest_location) # first assert that all draft content got cloned as well @@ -693,6 +693,56 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): expected_children.append(child_loc.url()) self.assertEqual(expected_children, lookup_item.children) + def test_portable_link_rewrites_during_clone_course(self): + course_data = { + 'org': 'MITx', + 'number': '999', + 'display_name': 'Robot Super Course', + 'run': '2013_Spring' + } + + module_store = modulestore('direct') + draft_store = modulestore('draft') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy']) + + source_course_id = 'edX/toy/2012_Fall' + dest_course_id = 'MITx/999/2013_Spring' + source_location = CourseDescriptor.id_to_location(source_course_id) + dest_location = CourseDescriptor.id_to_location(dest_course_id) + + # let's force a non-portable link in the clone source + # as a final check, make sure that any non-portable links are rewritten during cloning + html_module_location = Location([ + source_location.tag, source_location.org, source_location.course, 'html', 'nonportable']) + html_module = module_store.get_instance(source_location.course_id, html_module_location) + + self.assertTrue(isinstance(html_module.data, basestring)) + new_data = html_module.data.replace('/static/', '/c4x/{0}/{1}/asset/'.format( + source_location.org, source_location.course)) + module_store.update_item(html_module_location, new_data) + + html_module = module_store.get_instance(source_location.course_id, html_module_location) + self.assertEqual(new_data, html_module.data) + + # create the destination course + + resp = self.client.post(reverse('create_new_course'), course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/2013_Spring') + + # do the actual cloning + clone_course(module_store, content_store, source_location, dest_location) + + # make sure that any non-portable links are rewritten during cloning + html_module_location = Location([ + dest_location.tag, dest_location.org, dest_location.course, 'html', 'nonportable']) + html_module = module_store.get_instance(dest_location.course_id, html_module_location) + + self.assertIn('/static/foo.jpg', html_module.data) + def test_illegal_draft_crud_ops(self): draft_store = modulestore('draft') direct_store = modulestore('direct') @@ -720,6 +770,22 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) + def test_rewrite_nonportable_links_on_import(self): + module_store = modulestore('direct') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) + + # first check a static asset link + html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable']) + html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location) + self.assertIn('/static/foo.jpg', html_module.data) + + # then check a intra courseware link + html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable_link']) + html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location) + self.assertIn('/jump_to_id/nonportable_link', html_module.data) + def test_delete_course(self): """ This test will import a course, make a draft item, and delete it. This will also assert that the @@ -1102,7 +1168,7 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertNotIn('ErrMsg', data) self.assertEqual(data['id'], 'i4x://MITx/{0}/course/2013_Spring'.format(test_course_data['number'])) # Verify that the creator is now registered in the course. - self.assertTrue(is_enrolled_in_course(self.user, self._get_course_id(test_course_data))) + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self._get_course_id(test_course_data))) return test_course_data def test_create_course_check_forum_seeding(self): @@ -1124,14 +1190,14 @@ class ContentStoreTest(ModuleStoreTestCase): Checks that the course did not get created """ course_id = self._get_course_id(self.course_data) - initially_enrolled = is_enrolled_in_course(self.user, course_id) + initially_enrolled = CourseEnrollment.is_enrolled(self.user, course_id) resp = self.client.post(reverse('create_new_course'), self.course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['ErrMsg'], error_message) # One test case involves trying to create the same course twice. Hence for that course, # the user will be enrolled. In the other cases, initially_enrolled will be False. - self.assertEqual(initially_enrolled, is_enrolled_in_course(self.user, course_id)) + self.assertEqual(initially_enrolled, CourseEnrollment.is_enrolled(self.user, course_id)) def test_create_course_duplicate_number(self): """Test new course creation - error path""" @@ -1287,7 +1353,7 @@ class ContentStoreTest(ModuleStoreTestCase): 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) self.assertContains(resp, 'Chapter 2') # go to various pages @@ -1297,92 +1363,117 @@ class ContentStoreTest(ModuleStoreTestCase): kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # export page resp = self.client.get(reverse('export_course', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # manage users resp = self.client.get(reverse('manage_users', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # course info resp = self.client.get(reverse('course_info', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # settings_details resp = self.client.get(reverse('settings_details', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # settings_details resp = self.client.get(reverse('settings_grading', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # static_pages resp = self.client.get(reverse('static_pages', kwargs={'org': loc.org, 'course': loc.course, 'coursename': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # static_pages resp = self.client.get(reverse('asset_index', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # go look at a subsection page subsection_location = loc.replace(category='sequential', name='test_sequence') resp = self.client.get(reverse('edit_subsection', kwargs={'location': subsection_location.url()})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # go look at the Edit page unit_location = loc.replace(category='vertical', name='test_vertical') resp = self.client.get(reverse('edit_unit', kwargs={'location': unit_location.url()})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a component del_loc = loc.replace(category='html', name='test_html') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a unit del_loc = loc.replace(category='vertical', name='test_vertical') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a unit del_loc = loc.replace(category='sequential', name='test_sequence') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a chapter del_loc = loc.replace(category='chapter', name='chapter_2') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) + + def test_import_into_new_course_id(self): + module_store = modulestore('direct') + target_location = Location(['i4x', 'MITx', '999', 'course', '2013_Spring']) + + course_data = { + 'org': target_location.org, + 'number': target_location.course, + 'display_name': 'Robot Super Course', + 'run': target_location.name + } + + resp = self.client.post(reverse('create_new_course'), course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], target_location.url()) + + import_from_xml(module_store, 'common/test/data/', ['simple'], target_location_namespace=target_location) + + modules = module_store.get_items(Location([ + target_location.tag, target_location.org, target_location.course, None, None, None])) + + # we should have a number of modules in there + # we can't specify an exact number since it'll always be changing + self.assertGreater(len(modules), 10) def test_import_metadata_with_attempts_empty_string(self): module_store = modulestore('direct') @@ -1505,12 +1596,15 @@ class ContentStoreTest(ModuleStoreTestCase): class MetadataSaveTestCase(ModuleStoreTestCase): - """ - Test that metadata is correctly decached. - """ + """Test that metadata is correctly cached and decached.""" def setUp(self): - sample_xml = ''' + CourseFactory.create( + org='edX', course='999', display_name='Robot Super Course') + course_location = Location( + ['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + + video_sample_xml = ''' ''' - CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') - course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + self.video_descriptor = ItemFactory.create( + parent_location=course_location, category='video', + data={'data': video_sample_xml} + ) - model_data = {'data': sample_xml} - self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data) - - def test_metadata_persistence(self): + def test_metadata_not_persistence(self): """ Test that descriptors which set metadata fields in their - constructor are correctly persisted. + constructor are correctly deleted. """ - # We should start with a source field, from the XML's tag - self.assertIn('source', own_metadata(self.descriptor)) + self.assertIn('html5_sources', own_metadata(self.video_descriptor)) attrs_to_strip = { 'show_captions', 'youtube_id_1_0', @@ -1542,23 +1634,27 @@ class MetadataSaveTestCase(ModuleStoreTestCase): 'start_time', 'end_time', 'source', + 'html5_sources', 'track' } - # We strip out all metadata fields to reproduce a bug where - # constructors which set their fields (e.g. Video) didn't have - # those changes persisted. So in the end we have the XML data - # in `descriptor.data`, but not in the individual fields - fields = self.descriptor.fields + + fields = self.video_descriptor.fields + location = self.video_descriptor.location + for field in fields: if field.name in attrs_to_strip: - field.delete_from(self.descriptor) + field.delete_from(self.video_descriptor) - # Assert that we correctly stripped the field - self.assertNotIn('source', own_metadata(self.descriptor)) - get_modulestore(self.descriptor.location).update_metadata( - self.descriptor.location, - own_metadata(self.descriptor) + self.assertNotIn('html5_sources', own_metadata(self.video_descriptor)) + get_modulestore(location).update_metadata( + location, + own_metadata(self.video_descriptor) ) - module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location) - # Assert that get_item correctly sets the metadata - self.assertIn('source', own_metadata(module)) + module = get_modulestore(location).get_item(location) + + self.assertNotIn('html5_sources', own_metadata(module)) + + def test_metadata_persistence(self): + # TODO: create the same test as `test_metadata_not_persistence`, + # but check persistence for some other module. + pass diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 827dd1b054..260444a8f7 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -34,7 +34,7 @@ class DeleteItem(CourseTestCase): resp.content, "application/json" ) - self.assertEqual(resp.status_code, 200) + self.assert2XX(resp.status_code) class TestCreateItem(CourseTestCase): diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index a9216da612..cbb8aa8b01 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -6,7 +6,7 @@ from .utils import CourseTestCase from django.contrib.auth.models import User, Group from django.core.urlresolvers import reverse from auth.authz import get_course_groupname_for_role -from student.views import is_enrolled_in_course +from student.models import CourseEnrollment class UsersTestCase(CourseTestCase): @@ -372,13 +372,13 @@ class UsersTestCase(CourseTestCase): def assert_not_enrolled(self): """ Asserts that self.ext_user is not enrolled in self.course. """ self.assertFalse( - is_enrolled_in_course(self.ext_user, self.course.location.course_id), + CourseEnrollment.is_enrolled(self.ext_user, self.course.location.course_id), 'Did not expect ext_user to be enrolled in course' ) def assert_enrolled(self): """ Asserts that self.ext_user is enrolled in self.course. """ self.assertTrue( - is_enrolled_in_course(self.ext_user, self.course.location.course_id), + CourseEnrollment.is_enrolled(self.ext_user, self.course.location.course_id), 'User ext_user should have been enrolled in the course' ) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 2334c61b4c..94bfa55b58 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -315,6 +315,8 @@ def import_course(request, org, course, name): create_all_course_groups(request.user, course_items[0].location) + logging.debug('created all course groups at {0}'.format(course_items[0].location)) + return HttpResponse(json.dumps({'Status': 'OK'})) else: course_module = modulestore().get_item(location) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 7cb503db1e..d7b41acb24 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -49,7 +49,6 @@ NOTE_COMPONENT_TYPES = ['notes'] ADVANCED_COMPONENT_TYPES = [ 'annotatable', 'word_cloud', - 'videoalpha', 'graphical_slider_tool' ] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 8ac1d223cb..a6b1b29aab 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -44,7 +44,7 @@ from .component import ( from django_comment_common.utils import seed_permissions_roles -from student.views import enroll_in_course +from student.models import CourseEnrollment from xmodule.html_module import AboutDescriptor __all__ = ['course_index', 'create_new_course', 'course_info', @@ -165,7 +165,7 @@ def create_new_course(request): seed_permissions_roles(new_course.location.course_id) # auto-enroll the course creator in the course so that "View Live" will work. - enroll_in_course(request.user, new_course.location.course_id) + CourseEnrollment.enroll(request.user, new_course.location.course_id) return JsonResponse({'id': new_course.location.url()}) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index efebded9b9..ff347a2878 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -1,15 +1,13 @@ -import json from uuid import uuid4 from django.core.exceptions import PermissionDenied -from django.http import HttpResponse from django.contrib.auth.decorators import login_required from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.inheritance import own_metadata -from util.json_request import expect_json +from util.json_request import expect_json, JsonResponse from ..utils import get_modulestore from .access import has_access from .requests import _xmodule_recurse @@ -20,6 +18,7 @@ __all__ = ['save_item', 'create_item', 'delete_item'] # cdodge: these are categories which should not be parented, they are detached from the hierarchy DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] + @login_required @expect_json def save_item(request): @@ -80,7 +79,7 @@ def save_item(request): # commit to datastore store.update_metadata(item_location, own_metadata(existing_item)) - return HttpResponse() + return JsonResponse() # [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level @@ -139,13 +138,17 @@ def create_item(request): if display_name is not None: metadata['display_name'] = display_name - get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data, - metadata=metadata, system=parent.system) + get_modulestore(category).create_and_save_xmodule( + dest_location, + definition_data=data, + metadata=metadata, + system=parent.system, + ) if category not in DETACHED_CATEGORIES: get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()]) - return HttpResponse(json.dumps({'id': dest_location.url()})) + return JsonResponse({'id': dest_location.url()}) @login_required @@ -184,4 +187,4 @@ def delete_item(request): parent.children = children modulestore('direct').update_children(parent.location, parent.children) - return HttpResponse() + return JsonResponse() diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index f2a07abe32..0591ff0dc4 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -75,9 +75,15 @@ def preview_component(request, location): component = modulestore().get_item(location) + component.get_html = wrap_xmodule( + component.get_html, + component, + 'xmodule_edit.html' + ) + return render_to_response('component.html', { - 'preview': get_module_previews(request, component)[0], - 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), + 'preview': get_preview_html(request, component, 0), + 'editor': component.runtime.render(component, None, 'studio_view').content, }) @@ -163,15 +169,10 @@ def load_preview_module(request, preview_id, descriptor): return module -def get_module_previews(request, descriptor): +def get_preview_html(request, descriptor, idx): """ - Returns a list of preview XModule html contents. One preview is returned for each - pair of states returned by get_sample_state() for the supplied descriptor. - - descriptor: An XModuleDescriptor + Returns the HTML returned by the XModule's student_view, + specified by the descriptor and idx. """ - preview_html = [] - for idx, (_instance_state, _shared_state) in enumerate(descriptor.get_sample_state()): - module = load_preview_module(request, str(idx), descriptor) - preview_html.append(module.get_html()) - return preview_html + module = load_preview_module(request, str(idx), descriptor) + return module.runtime.render(module, None, "student_view").content diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index a5c495597c..d98931f65e 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -13,6 +13,7 @@ from django.core.context_processors import csrf from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location +from xmodule.error_module import ErrorDescriptor from contentstore.utils import get_lms_link_for_item from util.json_request import JsonResponse from auth.authz import ( @@ -23,7 +24,7 @@ from course_creators.views import ( from .access import has_access -from student.views import enroll_in_course +from student.models import CourseEnrollment @login_required @@ -62,7 +63,7 @@ def index(request): ) return render_to_response('index.html', { - 'courses': [format_course_for_view(c) for c in courses], + 'courses': [format_course_for_view(c) for c in courses if not isinstance(c, ErrorDescriptor)], 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -207,7 +208,7 @@ def course_team_user(request, org, course, name, email): user.groups.add(groups["instructor"]) user.save() # auto-enroll the course creator in the course so that "View Live" will work. - enroll_in_course(user, location.course_id) + CourseEnrollment.enroll(user, location.course_id) elif role == "staff": # if we're trying to downgrade a user from "instructor" to "staff", # make sure we have at least one other instructor in the course team. @@ -222,7 +223,7 @@ def course_team_user(request, org, course, name, email): user.groups.add(groups["staff"]) user.save() # auto-enroll the course creator in the course so that "View Live" will work. - enroll_in_course(user, location.course_id) + CourseEnrollment.enroll(user, location.course_id) return JsonResponse() diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 7c3b883283..78c5dcff33 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -173,7 +173,7 @@ class CourseDetails(object): # the right thing result = None if video_key: - result = '' return result diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 339425fee5..c2ba51a5f8 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -107,6 +107,7 @@ DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBA ADMINS = ENV_TOKENS.get('ADMINS', ADMINS) SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) +TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) diff --git a/cms/envs/common.py b/cms/envs/common.py index 40084c20ae..9d246edece 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -25,7 +25,7 @@ Longer TODO: import sys import lms.envs.common -from lms.envs.common import USE_TZ +from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL from path import path ############################ FEATURE CONFIGURATION ############################# @@ -39,9 +39,6 @@ MITX_FEATURES = { 'AUTH_USE_MIT_CERTIFICATES': False, - # do not display video when running automated acceptance tests - 'STUB_VIDEO_FOR_TESTING': False, - # email address for studio staff (eg to request course creation) 'STUDIO_REQUEST_EMAIL': '', @@ -204,7 +201,7 @@ STATICFILES_DIRS = [ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -USE_I18N = True +USE_I18N = False USE_L10N = True # Localization strings (e.g. django.po) are under this directory diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 0b0a62f05d..42a6f706b6 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -150,14 +150,13 @@ DEBUG_TOOLBAR_PANELS = ( 'debug_toolbar.panels.sql.SQLDebugPanel', 'debug_toolbar.panels.signals.SignalDebugPanel', 'debug_toolbar.panels.logger.LoggingPanel', - 'debug_toolbar_mongo.panel.MongoDebugPanel', # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and # Django=1.3.1/1.4 where requests to views get duplicated (your method gets # hit twice). So you can uncomment when you need to diagnose performance # problems, but you shouldn't leave it on. # 'debug_toolbar.panels.profiling.ProfilingDebugPanel', - ) +) DEBUG_TOOLBAR_CONFIG = { 'INTERCEPT_REDIRECTS': False @@ -165,7 +164,7 @@ DEBUG_TOOLBAR_CONFIG = { # To see stacktraces for MongoDB queries, set this to True. # Stacktraces slow down page loads drastically (for pages with lots of queries). -DEBUG_TOOLBAR_MONGO_STACKTRACES = True +DEBUG_TOOLBAR_MONGO_STACKTRACES = False # disable NPS survey in dev mode MITX_FEATURES['STUDIO_NPS_SURVEY'] = False diff --git a/cms/envs/dev_dbperf.py b/cms/envs/dev_dbperf.py new file mode 100644 index 0000000000..2ea131b69e --- /dev/null +++ b/cms/envs/dev_dbperf.py @@ -0,0 +1,31 @@ +""" +This configuration is to turn on the Django Toolbar stats for DB access stats, for performance analysis +""" + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + +from .dev import * + +DEBUG_TOOLBAR_PANELS = ( + 'debug_toolbar.panels.version.VersionDebugPanel', + 'debug_toolbar.panels.timer.TimerDebugPanel', + 'debug_toolbar.panels.settings_vars.SettingsVarsDebugPanel', + 'debug_toolbar.panels.headers.HeaderDebugPanel', + 'debug_toolbar.panels.request_vars.RequestVarsDebugPanel', + 'debug_toolbar.panels.sql.SQLDebugPanel', + 'debug_toolbar.panels.signals.SignalDebugPanel', + 'debug_toolbar.panels.logger.LoggingPanel', + 'debug_toolbar_mongo.panel.MongoDebugPanel' + + # Enabling the profiler has a weird bug as of django-debug-toolbar==0.9.4 and + # Django=1.3.1/1.4 where requests to views get duplicated (your method gets + # hit twice). So you can uncomment when you need to diagnose performance + # problems, but you shouldn't leave it on. + # 'debug_toolbar.panels.profiling.ProfilingDebugPanel', +) + +# To see stacktraces for MongoDB queries, set this to True. +# Stacktraces slow down page loads drastically (for pages with lots of queries). +DEBUG_TOOLBAR_MONGO_STACKTRACES = True diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee index 926e5be315..b3e4567d82 100644 --- a/cms/static/coffee/spec/views/metadata_edit_spec.coffee +++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee @@ -1,3 +1,11 @@ +verifyInputType = (input, expectedType) -> + # Some browsers (e.g. FireFox) do not support the "number" + # input type. We can accept a "text" input instead + # and still get acceptable behavior in the UI. + if expectedType == 'number' and input.type != 'number' + expectedType = 'text' + expect(input.type).toBe(expectedType) + describe "Test Metadata Editor", -> editorTemplate = readFixtures('metadata-editor.underscore') numberEntryTemplate = readFixtures('metadata-number-entry.underscore') @@ -113,7 +121,7 @@ describe "Test Metadata Editor", -> verifyEntry = (index, display_name, type) -> expect(childModels[index].get('display_name')).toBe(display_name) - expect(childViews[index].type).toBe(type) + verifyInputType(childViews[index], type) verifyEntry(0, 'Display Name', 'text') verifyEntry(1, 'Inputs', 'number') @@ -164,7 +172,7 @@ describe "Test Metadata Editor", -> assertInputType = (view, expectedType) -> input = view.$el.find('.setting-input') expect(input.length).toEqual(1) - expect(input[0].type).toEqual(expectedType) + verifyInputType(input[0], expectedType) assertValueInView = (view, expectedValue) -> expect(view.getValueFromEditor()).toEqual(expectedValue) diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index fd679c289b..0154b4f51a 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View @model.save() deleteComponent: (event) => + event.preventDefault() msg = new CMS.Views.Prompt.Warning( title: gettext('Delete this component?'), message: gettext('Deleting this component is permanent and cannot be undone.'), diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 80b24776da..9ccac6f602 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -605,81 +605,118 @@ function cancelNewSection(e) { function addNewCourse(e) { e.preventDefault(); $('.new-course-button').addClass('is-disabled'); + $('.new-course-save').addClass('is-disabled'); var $newCourse = $('.wrapper-create-course').addClass('is-shown'); var $cancelButton = $newCourse.find('.new-course-cancel'); - $newCourse.find('.new-course-name').focus().select(); - $newCourse.find('form').bind('submit', saveNewCourse); + var $courseName = $('.new-course-name'); + $courseName.focus().select(); + $('.new-course-save').on('click', saveNewCourse); $cancelButton.bind('click', cancelNewCourse); $body.bind('keyup', { $cancelButton: $cancelButton }, checkForCancel); + + // Check that a course (org, number, run) doesn't use any special characters + var validateCourseItemEncoding = function(item) { + var required = validateRequiredField(item); + if(required) { + return required; + } + if(item !== encodeURIComponent(item)) { + return gettext('Please do not use any spaces or special characters in this field.'); + } + return ''; + } + + // Ensure that all items are less than 80 characters. + var validateTotalCourseItemsLength = function() { + var totalLength = _.reduce( + ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], + function(sum, ele) { + return sum + $(ele).val().length; + }, 0 + ); + if(totalLength > 80) { + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

' + gettext('Course fields must have a combined length of no more than 80 characters.') + '

'); + $('.new-course-save').addClass('is-disabled'); + } + else { + $('.wrap-error').removeClass('is-shown'); + } + } + + // Handle validation asynchronously + _.each( + ['.new-course-org', '.new-course-number', '.new-course-run'], + function(ele) { + var $ele = $(ele); + $ele.on('keyup', function(event) { + // Don't bother showing "required field" error when + // the user tabs into a new field; this is distracting + // and unnecessary + if(event.keyCode === 9) { + return; + } + var error = validateCourseItemEncoding($ele.val()); + setNewCourseFieldInErr($ele.parent('li'), error); + validateTotalCourseItemsLength(); + }); + } + ); + var $name = $('.new-course-name'); + $name.on('keyup', function() { + var error = validateRequiredField($name.val()); + setNewCourseFieldInErr($name.parent('li'), error); + validateTotalCourseItemsLength(); + }); } +function validateRequiredField(msg) { + return msg.length === 0 ? gettext('Required field.') : ''; +} + +function setNewCourseFieldInErr(el, msg) { + if(msg) { + el.addClass('error'); + el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg); + $('.new-course-save').addClass('is-disabled'); + } + else { + el.removeClass('error'); + el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing'); + // One "error" div is always present, but hidden or shown + if($('.error').length === 1) { + $('.new-course-save').removeClass('is-disabled'); + } + } +}; + function saveNewCourse(e) { e.preventDefault(); + // One final check for empty values + var errors = _.reduce( + ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], + function(acc, ele) { + var $ele = $(ele); + var error = validateRequiredField($ele.val()); + setNewCourseFieldInErr($ele.parent('li'), error); + return error ? true : acc; + }, + false + ); + + if(errors) { + return; + } + var $newCourseForm = $(this).closest('#create-course-form'); var display_name = $newCourseForm.find('.new-course-name').val(); var org = $newCourseForm.find('.new-course-org').val(); var number = $newCourseForm.find('.new-course-number').val(); var run = $newCourseForm.find('.new-course-run').val(); - var required_field_text = gettext('Required field'); - - var display_name_errMsg = (display_name === '') ? required_field_text : null; - var org_errMsg = (org === '') ? required_field_text : null; - var number_errMsg = (number === '') ? required_field_text : null; - var run_errMsg = (run === '') ? required_field_text : null; - - var bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg); - - // check for suitable encoding - if (!bInErr) { - var encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.'); - - if (encodeURIComponent(org) != org) - org_errMsg = encoding_errMsg; - if (encodeURIComponent(number) != number) - number_errMsg = encoding_errMsg; - if (encodeURIComponent(run) != run) - run_errMsg = encoding_errMsg; - - bInErr = (org_errMsg || number_errMsg || run_errMsg); - } - - var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null; - - var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) { - if (header_err_msg) { - $('.wrapper-create-course').addClass('has-errors'); - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

' + header_err_msg + '

'); - } else { - $('.wrap-error').removeClass('is-shown'); - $('#course_creation_error').html(''); - } - - var setNewCourseFieldInErr = function(el, msg) { - el.children('.tip-error').remove(); - if (msg !== null && msg !== '') { - el.addClass('error'); - el.append('' + msg + ''); - } else { - el.removeClass('error'); - } - }; - - setNewCourseFieldInErr($('#field-course-name'), display_name_errMsg); - setNewCourseFieldInErr($('#field-organization'), org_errMsg); - setNewCourseFieldInErr($('#field-course-number'), number_errMsg); - setNewCourseFieldInErr($('#field-course-run'), run_errMsg); - }; - - setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg); - - if (bInErr) - return; - analytics.track('Created a Course', { 'org': org, 'number': number, @@ -697,9 +734,9 @@ function saveNewCourse(e) { if (data.id !== undefined) { window.location = '/' + data.id.replace(/.*:\/\//, ''); } else if (data.ErrMsg !== undefined) { - var orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null; - var courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null; - setNewCourseErrMsgs(data.ErrMsg, null, orgErrMsg, courseErrMsg, null); + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

' + data.ErrMsg + '

'); + $('.new-course-save').addClass('is-disabled'); } } ); @@ -709,6 +746,16 @@ function cancelNewCourse(e) { e.preventDefault(); $('.new-course-button').removeClass('is-disabled'); $('.wrapper-create-course').removeClass('is-shown'); + // Clear out existing fields and errors + _.each( + ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], + function(field) { + $(field).val(''); + } + ); + $('#course_creation_error').html(''); + $('.wrap-error').removeClass('is-shown'); + $('.new-course-save').off('click'); } function addNewSubsection(e) { diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index d7e11d5689..4d048bab81 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -75,7 +75,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ return this.videosourceSample(); }, videosourceSample : function() { - if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video'); + if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video'); else return ""; } }); diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index c78e2f3692..69bf3a6566 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -225,8 +225,15 @@ form[class^="create-"] { color: $red; } + .is-showing { + @extend .anim-fadeIn; + } + + .is-hiding { + @extend .anim-fadeOut; + } + .tip-error { - @extend .anim-fadeIn; display: block; color: $red; } diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss index a8ec208b02..db1a50b40c 100644 --- a/cms/static/sass/elements/_xmodules.scss +++ b/cms/static/sass/elements/_xmodules.scss @@ -2,7 +2,7 @@ // ==================== // Video Alpha -.xmodule_VideoAlphaModule { +.xmodule_VideoModule { // display mode &.xmodule_display { diff --git a/cms/templates/index.html b/cms/templates/index.html index 9702eedada..e5cf2fa54a 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -99,23 +99,27 @@ ${_("The public display name for your course.")} +
  • ${_("The name of the organization sponsoring the course")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
  • ${_("The unique number that identifies your course within your organization")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
  • ${_("The term in which your course will run")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
  • @@ -123,7 +127,7 @@
    - +
    diff --git a/cms/templates/widgets/videoalpha/codemirror-edit.html b/cms/templates/widgets/video/codemirror-edit.html similarity index 100% rename from cms/templates/widgets/videoalpha/codemirror-edit.html rename to cms/templates/widgets/video/codemirror-edit.html diff --git a/cms/templates/widgets/videoalpha/subtitles.html b/cms/templates/widgets/video/subtitles.html similarity index 100% rename from cms/templates/widgets/videoalpha/subtitles.html rename to cms/templates/widgets/video/subtitles.html diff --git a/common/djangoapps/django_comment_common/models.py b/common/djangoapps/django_comment_common/models.py index ec722b718a..7878f1b453 100644 --- a/common/djangoapps/django_comment_common/models.py +++ b/common/djangoapps/django_comment_common/models.py @@ -19,6 +19,20 @@ FORUM_ROLE_STUDENT = 'Student' @receiver(post_save, sender=CourseEnrollment) def assign_default_role(sender, instance, **kwargs): + # The code below would remove all forum Roles from a user when they unenroll + # from a course. Concerns were raised that it should apply only to students, + # or that even the history of student roles is important for research + # purposes. Since this was new functionality being added in this release, + # I'm just going to comment it out for now and let the forums team deal with + # implementing the right behavior. + # + # # We've unenrolled the student, so remove all roles for this course + # if not instance.is_active: + # course_roles = list(Role.objects.filter(course_id=instance.course_id)) + # instance.user.roles.remove(*course_roles) + # return + + # We've enrolled the student, so make sure they have a default role if instance.user.is_staff: role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] else: diff --git a/common/djangoapps/django_comment_common/tests.py b/common/djangoapps/django_comment_common/tests.py new file mode 100644 index 0000000000..47790f1e1e --- /dev/null +++ b/common/djangoapps/django_comment_common/tests.py @@ -0,0 +1,58 @@ +from django.test import TestCase + +from django_comment_common.models import Role +from student.models import CourseEnrollment, User + +class RoleAssignmentTest(TestCase): + """ + Basic checks to make sure our Roles get assigned and unassigned as students + are enrolled and unenrolled from a course. + """ + + def setUp(self): + self.staff_user = User.objects.create_user( + "patty", + "patty@fake.edx.org", + ) + self.staff_user.is_staff = True + + self.student_user = User.objects.create_user( + "hacky", + "hacky@fake.edx.org" + ) + self.course_id = "edX/Fake101/2012" + CourseEnrollment.enroll(self.staff_user, self.course_id) + CourseEnrollment.enroll(self.student_user, self.course_id) + + def test_enrollment_auto_role_creation(self): + moderator_role = Role.objects.get( + course_id=self.course_id, + name="Moderator" + ) + student_role = Role.objects.get( + course_id=self.course_id, + name="Student" + ) + self.assertIn(moderator_role, self.staff_user.roles.all()) + + self.assertIn(student_role, self.student_user.roles.all()) + self.assertNotIn(moderator_role, self.student_user.roles.all()) + + # The following was written on the assumption that unenrolling from a course + # should remove all forum Roles for that student for that course. This is + # not necessarily the case -- please see comments at the top of + # django_comment_client.models.assign_default_role(). Leaving it for the + # forums team to sort out. + # + # def test_unenrollment_auto_role_removal(self): + # another_student = User.objects.create_user("sol", "sol@fake.edx.org") + # CourseEnrollment.enroll(another_student, self.course_id) + # + # CourseEnrollment.unenroll(self.student_user, self.course_id) + # # Make sure we didn't delete the actual Role + # student_role = Role.objects.get( + # course_id=self.course_id, + # name="Student" + # ) + # self.assertNotIn(student_role, self.student_user.roles.all()) + # self.assertIn(student_role, another_student.roles.all()) diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py index 428119b886..6bb9c38e6f 100644 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -431,12 +431,12 @@ class ShibSPTest(ModuleStoreTestCase): # If course is not limited or student has correct shib extauth then enrollment should be allowed if course is open_enroll_course or student is shib_student: self.assertEqual(response.status_code, 200) - self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1) + self.assertTrue(CourseEnrollment.is_enrolled(student, course.id)) # Clean up - CourseEnrollment.objects.filter(user=student, course_id=course.id).delete() + CourseEnrollment.unenroll(student, course.id) else: self.assertEqual(response.status_code, 400) - self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0) + self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) def test_shib_login_enrollment(self): @@ -462,7 +462,7 @@ class ShibSPTest(ModuleStoreTestCase): # use django test client for sessions and url processing # no enrollment before trying - self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0) + self.assertFalse(CourseEnrollment.is_enrolled(student, course.id)) self.client.logout() request_kwargs = {'path': '/shib-login/', 'data': {'enrollment_action': 'enroll', 'course_id': course.id}, @@ -474,4 +474,4 @@ class ShibSPTest(ModuleStoreTestCase): self.assertEqual(response.status_code, 302) self.assertEqual(response['location'], 'http://testserver/') # now there is enrollment - self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1) + self.assertTrue(CourseEnrollment.is_enrolled(student, course.id)) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py new file mode 100644 index 0000000000..6156d4bf1d --- /dev/null +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -0,0 +1,60 @@ +# -*- coding: utf8 -*- +"""Dump username,unique_id_for_user pairs as CSV. + +Give instructors easy access to the mapping from anonymized IDs to user IDs +with a simple Django management command to generate a CSV mapping. To run, use +the following: + +rake django-admin[anonymized_id_mapping,x,y,z] + +[Naturally, substitute the appropriate values for x, y, and z. (I.e., + lms, dev, and MITx/6.002x/Circuits)]""" + +import csv + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError + +from student.models import unique_id_for_user + + +class Command(BaseCommand): + """Add our handler to the space where django-admin looks up commands.""" + + # It appears that with the way Rake invokes these commands, we can't + # have more than one arg passed through...annoying. + args = ("course_id", ) + + help = """Export a CSV mapping usernames to anonymized ids + + Exports a CSV document mapping each username in the specified course to + the anonymized, unique user ID. + """ + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("Usage: unique_id_mapping %s" % + " ".join(("<%s>" % arg for arg in Command.args))) + + course_id = args[0] + + # Generate the output filename from the course ID. + # Change slashes to dashes first, and then append .csv extension. + output_filename = course_id.replace('/', '-') + ".csv" + + # Figure out which students are enrolled in the course + students = User.objects.filter(courseenrollment__course_id=course_id) + if len(students) == 0: + self.stdout.write("No students enrolled in %s" % course_id) + return + + # Write mapping to output file in CSV format with a simple header + try: + with open(output_filename, 'wb') as output_file: + csv_writer = csv.writer(output_file) + csv_writer.writerow(("User ID", "Anonymized user ID")) + for student in students: + csv_writer.writerow((student.id, unique_id_for_user(student))) + except IOError: + raise CommandError("Error writing to file: %s" % output_filename) + diff --git a/common/djangoapps/student/management/commands/create_random_users.py b/common/djangoapps/student/management/commands/create_random_users.py index 3000c86601..db4bb796cc 100644 --- a/common/djangoapps/student/management/commands/create_random_users.py +++ b/common/djangoapps/student/management/commands/create_random_users.py @@ -12,7 +12,7 @@ def create(n, course_id): for i in range(n): (user, user_profile, _) = _do_create_account(get_random_post_override()) if course_id is not None: - CourseEnrollment.objects.create(user=user, course_id=course_id) + CourseEnrollment.enroll(user, course_id) class Command(BaseCommand): diff --git a/common/djangoapps/student/management/commands/get_grades.py b/common/djangoapps/student/management/commands/get_grades.py new file mode 100644 index 0000000000..f0d5b5ec5d --- /dev/null +++ b/common/djangoapps/student/management/commands/get_grades.py @@ -0,0 +1,95 @@ +from courseware import grades, courses +from django.test.client import RequestFactory +from django.core.management.base import BaseCommand, CommandError +import os +from django.contrib.auth.models import User +from optparse import make_option +import datetime +from django.core.handlers.base import BaseHandler +import csv + + +class RequestMock(RequestFactory): + def request(self, **request): + "Construct a generic request object." + request = RequestFactory.request(self, **request) + handler = BaseHandler() + handler.load_middleware() + for middleware_method in handler._request_middleware: + if middleware_method(request): + raise Exception("Couldn't create request mock object - " + "request middleware returned a response") + return request + + +class Command(BaseCommand): + + help = """ + Generate a list of grades for all students + that are enrolled in a course. + + Outputs grades to a csv file. + + Example: + sudo -u www-data SERVICE_VARIANT=lms /opt/edx/bin/django-admin.py get_grades \ + -c MITx/Chi6.00intro/A_Taste_of_Python_Programming -o /tmp/20130813-6.00x.csv \ + --settings=lms.envs.aws --pythonpath=/opt/wwc/edx-platform + """ + + option_list = BaseCommand.option_list + ( + make_option('-c', '--course', + metavar='COURSE_ID', + dest='course', + default=False, + help='Course ID for grade distribution'), + make_option('-o', '--output', + metavar='FILE', + dest='output', + default=False, + help='Filename for grade output')) + + def handle(self, *args, **options): + if os.path.exists(options['output']): + raise CommandError("File {0} already exists".format( + options['output'])) + + STATUS_INTERVAL = 100 + course_id = options['course'] + print "Fetching enrolled students for {0}".format(course_id) + enrolled_students = User.objects.filter( + courseenrollment__course_id=course_id).prefetch_related( + "groups").order_by('username') + factory = RequestMock() + request = factory.get('/') + + total = enrolled_students.count() + print "Total enrolled: {0}".format(total) + course = courses.get_course_by_id(course_id) + total = enrolled_students.count() + start = datetime.datetime.now() + rows = [] + header = None + for count, student in enumerate(enrolled_students): + count += 1 + if count % STATUS_INTERVAL == 0: + # Print a status update with an approximation of + # how much time is left based on how long the last + # interval took + diff = datetime.datetime.now() - start + timeleft = diff * (total - count) / STATUS_INTERVAL + hours, remainder = divmod(timeleft.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + print "{0}/{1} completed ~{2:02}:{3:02}m remaining".format( + count, total, hours, minutes) + start = datetime.datetime.now() + request.user = student + grade = grades.grade(student, request, course) + if not header: + header = [section['label'] for section in grade[u'section_breakdown']] + rows.append(["email", "username"] + header) + percents = {section['label']: section['percent'] for section in grade[u'section_breakdown']} + row_percents = [percents[label] for label in header] + rows.append([student.email, student.username] + row_percents) + with open(options['output'], 'wb') as f: + writer = csv.writer(f) + writer.writerows(rows) diff --git a/common/djangoapps/student/migrations/0023_add_test_center_registration.py b/common/djangoapps/student/migrations/0023_add_test_center_registration.py index 4c7de6dcd9..6186f5deef 100644 --- a/common/djangoapps/student/migrations/0023_add_test_center_registration.py +++ b/common/djangoapps/student/migrations/0023_add_test_center_registration.py @@ -21,7 +21,7 @@ class Migration(SchemaMigration): ('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)), ('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)), ('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), - ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=1024, blank=True)), + ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=False, max_length=1024, blank=True)), ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), ('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)), @@ -163,7 +163,7 @@ class Migration(SchemaMigration): 'student.testcenterregistration': { 'Meta': {'object_name': 'TestCenterRegistration'}, 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), diff --git a/common/djangoapps/student/migrations/0024_add_allow_certificate.py b/common/djangoapps/student/migrations/0024_add_allow_certificate.py index 56eccf8d70..5753f0176e 100644 --- a/common/djangoapps/student/migrations/0024_add_allow_certificate.py +++ b/common/djangoapps/student/migrations/0024_add_allow_certificate.py @@ -93,7 +93,7 @@ class Migration(SchemaMigration): 'student.testcenterregistration': { 'Meta': {'object_name': 'TestCenterRegistration'}, 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), diff --git a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py index 8ce1d0cda1..1cb21e9b33 100644 --- a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py +++ b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py @@ -94,7 +94,7 @@ class Migration(SchemaMigration): 'student.testcenterregistration': { 'Meta': {'object_name': 'TestCenterRegistration'}, 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), diff --git a/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py b/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py new file mode 100644 index 0000000000..23fc476348 --- /dev/null +++ b/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models +from django.db.utils import DatabaseError + + +class Migration(SchemaMigration): + """ + Remove an unwanted index from environments that have it. + This is a one-way migration in that backwards is a no-op and will not undo the removal. + This migration is only relevant to dev environments that existed before a migration rewrite + which removed the creation of this index. + """ + + def forwards(self, orm): + try: + # Removing index on 'TestCenterRegistration', fields ['accommodation_request'] + db.delete_index('student_testcenterregistration', ['accommodation_request']) + except DatabaseError: + print "-- skipping delete_index of student_testcenterregistration.accommodation_request (index does not exist)" + + + def backwards(self, orm): + pass + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py b/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py new file mode 100644 index 0000000000..bba8cc6e34 --- /dev/null +++ b/common/djangoapps/student/migrations/0027_add_active_flag_and_mode_to_courseware_enrollment.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'CourseEnrollment.is_active' + db.add_column('student_courseenrollment', 'is_active', + self.gf('django.db.models.fields.BooleanField')(default=True), + keep_default=False) + + # Adding field 'CourseEnrollment.mode' + db.add_column('student_courseenrollment', 'mode', + self.gf('django.db.models.fields.CharField')(default='honor', max_length=100), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CourseEnrollment.is_active' + db.delete_column('student_courseenrollment', 'is_active') + + # Deleting field 'CourseEnrollment.mode' + db.delete_column('student_courseenrollment', 'mode') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 4c41427ca6..6b5897e97d 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -11,11 +11,11 @@ file and check it in at the same time as your model changes. To do that, 3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/ """ from datetime import datetime +from random import randint import hashlib import json import logging import uuid -from random import randint from django.conf import settings @@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model): accommodation_code = models.CharField(max_length=64, blank=True) # store the original text of the accommodation request. - accommodation_request = models.CharField(max_length=1024, blank=True, db_index=True) + accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False) # time at which edX sent the registration to the test center uploaded_at = models.DateTimeField(null=True, db_index=True) @@ -645,16 +645,223 @@ class PendingEmailChange(models.Model): class CourseEnrollment(models.Model): + """ + Represents a Student's Enrollment record for a single Course. You should + generally not manipulate CourseEnrollment objects directly, but use the + classmethods provided to enroll, unenroll, or check on the enrollment status + of a given student. + + We're starting to consolidate course enrollment logic in this class, but + more should be brought in (such as checking against CourseEnrollmentAllowed, + checking course dates, user permissions, etc.) This logic is currently + scattered across our views. + """ user = models.ForeignKey(User) course_id = models.CharField(max_length=255, db_index=True) - created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) + # If is_active is False, then the student is not considered to be enrolled + # in the course (is_enrolled() will return False) + is_active = models.BooleanField(default=True) + + # Represents the modes that are possible. We'll update this later with a + # list of possible values. + mode = models.CharField(default="honor", max_length=100) + + class Meta: unique_together = (('user', 'course_id'),) + ordering = ('user', 'course_id') def __unicode__(self): - return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created) + return ( + "[CourseEnrollment] {}: {} ({}); active: ({})" + ).format(self.user, self.course_id, self.created, self.is_active) + + @classmethod + def create_enrollment(cls, user, course_id, mode="honor", is_active=False): + """ + Create an enrollment for a user in a class. By default *this enrollment + is not active*. This is useful for when an enrollment needs to go + through some sort of approval process before being activated. If you + don't need this functionality, just call `enroll()` instead. + + Returns a CoursewareEnrollment object. + + `user` is a Django User object. If it hasn't been saved yet (no `.id` + attribute), this method will automatically save it before + adding an enrollment for it. + + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + + `mode` is a string specifying what kind of enrollment this is. The + default is "honor", meaning honor certificate. Future options + may include "audit", "verified_id", etc. Please don't use it + until we have these mapped out. + + `is_active` is a boolean. If the CourseEnrollment object has + `is_active=False`, then calling + `CourseEnrollment.is_enrolled()` for that user/course_id + will return False. + + It is expected that this method is called from a method which has already + verified the user authentication and access. + """ + # If we're passing in a newly constructed (i.e. not yet persisted) User, + # save it to the database so that it can have an ID that we can throw + # into our CourseEnrollment object. Otherwise, we'll get an + # IntegrityError for having a null user_id. + if user.id is None: + user.save() + + enrollment, _ = CourseEnrollment.objects.get_or_create( + user=user, + course_id=course_id, + ) + # In case we're reactivating a deactivated enrollment, or changing the + # enrollment mode. + if enrollment.mode != mode or enrollment.is_active != is_active: + enrollment.mode = mode + enrollment.is_active = is_active + enrollment.save() + + return enrollment + + @classmethod + def enroll(cls, user, course_id, mode="honor"): + """ + Enroll a user in a course. This saves immediately. + + Returns a CoursewareEnrollment object. + + `user` is a Django User object. If it hasn't been saved yet (no `.id` + attribute), this method will automatically save it before + adding an enrollment for it. + + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + + `mode` is a string specifying what kind of enrollment this is. The + default is "honor", meaning honor certificate. Future options + may include "audit", "verified_id", etc. Please don't use it + until we have these mapped out. + + It is expected that this method is called from a method which has already + verified the user authentication and access. + """ + return cls.create_enrollment(user, course_id, mode, is_active=True) + + @classmethod + def enroll_by_email(cls, email, course_id, mode="honor", ignore_errors=True): + """ + Enroll a user in a course given their email. This saves immediately. + + Note that enrolling by email is generally done in big batches and the + error rate is high. For that reason, we supress User lookup errors by + default. + + Returns a CoursewareEnrollment object. If the User does not exist and + `ignore_errors` is set to `True`, it will return None. + + `email` Email address of the User to add to enroll in the course. + + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + + `mode` is a string specifying what kind of enrollment this is. The + default is "honor", meaning honor certificate. Future options + may include "audit", "verified_id", etc. Please don't use it + until we have these mapped out. + + `ignore_errors` is a boolean indicating whether we should suppress + `User.DoesNotExist` errors (returning None) or let it + bubble up. + + It is expected that this method is called from a method which has already + verified the user authentication and access. + """ + try: + user = User.objects.get(email=email) + return cls.enroll(user, course_id, mode) + except User.DoesNotExist: + err_msg = u"Tried to enroll email {} into course {}, but user not found" + log.error(err_msg.format(email, course_id)) + if ignore_errors: + return None + raise + + @classmethod + def unenroll(cls, user, course_id): + """ + Remove the user from a given course. If the relevant `CourseEnrollment` + object doesn't exist, we log an error but don't throw an exception. + + `user` is a Django User object. If it hasn't been saved yet (no `.id` + attribute), this method will automatically save it before + adding an enrollment for it. + + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + """ + try: + record = CourseEnrollment.objects.get(user=user, course_id=course_id) + record.is_active = False + record.save() + except cls.DoesNotExist: + log.error("Tried to unenroll student {} from {} but they were not enrolled") + + @classmethod + def unenroll_by_email(cls, email, course_id): + """ + Unenroll a user from a course given their email. This saves immediately. + User lookup errors are logged but will not throw an exception. + + `email` Email address of the User to unenroll from the course. + + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + """ + try: + user = User.objects.get(email=email) + return cls.unenroll(user, course_id) + except User.DoesNotExist: + err_msg = u"Tried to unenroll email {} from course {}, but user not found" + log.error(err_msg.format(email, course_id)) + + @classmethod + def is_enrolled(cls, user, course_id): + """ + Remove the user from a given course. If the relevant `CourseEnrollment` + object doesn't exist, we log an error but don't throw an exception. + + Returns True if the user is enrolled in the course (the entry must exist + and it must have `is_active=True`). Otherwise, returns False. + + `user` is a Django User object. If it hasn't been saved yet (no `.id` + attribute), this method will automatically save it before + adding an enrollment for it. + + `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall) + """ + try: + record = CourseEnrollment.objects.get(user=user, course_id=course_id) + return record.is_active + except cls.DoesNotExist: + return False + + @classmethod + def enrollments_for_user(cls, user): + return CourseEnrollment.objects.filter(user=user, is_active=1) + + def activate(self): + """Makes this `CourseEnrollment` record active. Saves immediately.""" + if not self.is_active: + self.is_active = True + self.save() + + def deactivate(self): + """Makes this `CourseEnrollment` record inactive. Saves immediately. An + inactive record means that the student is not enrolled in this course. + """ + if self.is_active: + self.is_active = False + self.save() class CourseEnrollmentAllowed(models.Model): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 513216ba17..397816ec00 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -21,9 +21,8 @@ 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.models import unique_id_for_user, CourseEnrollment from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper -from student.views import enroll_in_course, is_enrolled_in_course from student.tests.factories import UserFactory from student.tests.test_email import mock_render_to_string COURSE_1 = 'edX/toy/2012_Fall' @@ -209,12 +208,127 @@ class CourseEndingTest(TestCase): class EnrollInCourseTest(TestCase): - """ Tests the helper method for enrolling a user in a class """ + """Tests enrolling and unenrolling in courses.""" - def test_enroll_in_course(self): + def test_enrollment(self): user = User.objects.create_user("joe", "joe@joe.com", "password") - user.save() - course_id = "course_id" - self.assertFalse(is_enrolled_in_course(user, course_id)) - enroll_in_course(user, course_id) - self.assertTrue(is_enrolled_in_course(user, course_id)) + course_id = "edX/Test101/2013" + + # Test basic enrollment + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + CourseEnrollment.enroll(user, course_id) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + + # Enrolling them again should be harmless + CourseEnrollment.enroll(user, course_id) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + + # Now unenroll the user + CourseEnrollment.unenroll(user, course_id) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Unenrolling them again should also be harmless + CourseEnrollment.unenroll(user, course_id) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # The enrollment record should still exist, just be inactive + enrollment_record = CourseEnrollment.objects.get( + user=user, + course_id=course_id + ) + self.assertFalse(enrollment_record.is_active) + + def test_enrollment_non_existent_user(self): + # Testing enrollment of newly unsaved user (i.e. no database entry) + user = User(username="rusty", email="rusty@fake.edx.org") + course_id = "edX/Test101/2013" + + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Unenroll does nothing + CourseEnrollment.unenroll(user, course_id) + + # Implicit save() happens on new User object when enrolling, so this + # should still work + CourseEnrollment.enroll(user, course_id) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + + def test_enrollment_by_email(self): + user = User.objects.create(username="jack", email="jack@fake.edx.org") + course_id = "edX/Test101/2013" + + CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + + # This won't throw an exception, even though the user is not found + self.assertIsNone( + CourseEnrollment.enroll_by_email("not_jack@fake.edx.org", course_id) + ) + + self.assertRaises( + User.DoesNotExist, + CourseEnrollment.enroll_by_email, + "not_jack@fake.edx.org", + course_id, + ignore_errors=False + ) + + # Now unenroll them by email + CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Harmless second unenroll + CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Unenroll on non-existent user shouldn't throw an error + CourseEnrollment.unenroll_by_email("not_jack@fake.edx.org", course_id) + + def test_enrollment_multiple_classes(self): + user = User(username="rusty", email="rusty@fake.edx.org") + course_id1 = "edX/Test101/2013" + course_id2 = "MITx/6.003z/2012" + + CourseEnrollment.enroll(user, course_id1) + CourseEnrollment.enroll(user, course_id2) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id1)) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id2)) + + CourseEnrollment.unenroll(user, course_id1) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id1)) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id2)) + + CourseEnrollment.unenroll(user, course_id2) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id1)) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id2)) + + def test_activation(self): + user = User.objects.create(username="jack", email="jack@fake.edx.org") + course_id = "edX/Test101/2013" + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Creating an enrollment doesn't actually enroll a student + # (calling CourseEnrollment.enroll() would have) + enrollment = CourseEnrollment.create_enrollment(user, course_id) + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Until you explicitly activate it + enrollment.activate() + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + + # Activating something that's already active does nothing + enrollment.activate() + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + + # Now deactive + enrollment.deactivate() + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # Deactivating something that's already inactive does nothing + enrollment.deactivate() + self.assertFalse(CourseEnrollment.is_enrolled(user, course_id)) + + # A deactivated enrollment should be activated if enroll() is called + # for that user/course_id combination + CourseEnrollment.enroll(user, course_id) + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 0b061f5a94..7795a13c47 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -254,13 +254,12 @@ def register_user(request, extra_context=None): @ensure_csrf_cookie def dashboard(request): user = request.user - enrollments = CourseEnrollment.objects.filter(user=user) # Build our courses list for the user, but ignore any courses that no longer # exist (because the course IDs have changed). Still, we don't delete those # enrollments, because it could have been a data push snafu. courses = [] - for enrollment in enrollments: + for enrollment in CourseEnrollment.enrollments_for_user(user): try: courses.append(course_from_id(enrollment.course_id)) except ItemNotFoundError: @@ -377,18 +376,13 @@ def change_enrollment(request): "course:{0}".format(course_num), "run:{0}".format(run)]) - try: - enroll_in_course(user, course.id) - except IntegrityError: - # If we've already created this enrollment in a separate transaction, - # then just continue - pass + CourseEnrollment.enroll(user, course.id) + return HttpResponse() elif action == "unenroll": try: - enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id) - enrollment.delete() + CourseEnrollment.unenroll(user, course_id) org, course_num, run = course_id.split("/") statsd.increment("common.student.unenrollment", @@ -402,30 +396,10 @@ def change_enrollment(request): else: return HttpResponseBadRequest(_("Enrollment action is invalid")) - -def enroll_in_course(user, course_id): - """ - Helper method to enroll a user in a particular class. - - It is expected that this method is called from a method which has already - verified the user authentication and access. - """ - CourseEnrollment.objects.get_or_create(user=user, course_id=course_id) - - -def is_enrolled_in_course(user, course_id): - """ - Helper method that returns whether or not the user is enrolled in a particular course. - """ - return CourseEnrollment.objects.filter(user=user, course_id=course_id).count() > 0 - - @ensure_csrf_cookie def accounts_login(request, error=""): - return render_to_response('login.html', {'error': error}) - # Need different levels of logging @ensure_csrf_cookie def login_user(request, error=""): @@ -1008,13 +982,21 @@ def activate_account(request, key): ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email) for cea in ceas: if cea.auto_enroll: - course_id = cea.course_id - _enrollment, _created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id) + CourseEnrollment.enroll(student[0], cea.course_id) - resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active}) + resp = render_to_response( + "registration/activation_complete.html", + { + 'user_logged_in': user_logged_in, + 'already_active': already_active + } + ) return resp if len(r) == 0: - return render_to_response("registration/activation_invalid.html", {'csrf': csrf(request)['csrf_token']}) + return render_to_response( + "registration/activation_invalid.html", + {'csrf': csrf(request)['csrf_token']} + ) return HttpResponse(_("Unknown error. Please e-mail us to let us know how it happened.")) @@ -1037,7 +1019,11 @@ def password_reset(request): 'error': _('Invalid e-mail or user')})) -def password_reset_confirm_wrapper(request, uidb36=None, token=None): +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. ''' @@ -1049,7 +1035,12 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None): user.save() except (ValueError, User.DoesNotExist): pass - return password_reset_confirm(request, uidb36=uidb36, token=token) + # we also want to pass settings.PLATFORM_NAME in as extra_context + + extra_context = {"platform_name": settings.PLATFORM_NAME} + return password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) def reactivation_email_for_user(user): diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index afef8bf2e1..eca3290080 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -54,7 +54,7 @@ def register_by_course_id(course_id, is_staff=False): if is_staff: u.is_staff = True u.save() - CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + CourseEnrollment.enroll(u, course_id) @world.absorb diff --git a/common/lib/calc/calc.py b/common/lib/calc/calc.py index bbfd9545f6..ab300f121b 100644 --- a/common/lib/calc/calc.py +++ b/common/lib/calc/calc.py @@ -4,129 +4,100 @@ Parser and evaluator for FormulaResponse and NumericalResponse Uses pyparsing to parse. Main function as of now is evaluator(). """ -import copy import math import operator -import re - +import numbers import numpy import scipy.constants import calcfunctions -# have numpy raise errors on functions outside its domain -# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html -numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise' +from pyparsing import ( + Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward, + Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums +) -from pyparsing import (Word, nums, Literal, - ZeroOrMore, MatchFirst, - Optional, Forward, - CaselessLiteral, - stringEnd, Suppress, Combine) - -DEFAULT_FUNCTIONS = {'sin': numpy.sin, - 'cos': numpy.cos, - 'tan': numpy.tan, - 'sec': calcfunctions.sec, - 'csc': calcfunctions.csc, - 'cot': calcfunctions.cot, - 'sqrt': numpy.sqrt, - 'log10': numpy.log10, - 'log2': numpy.log2, - 'ln': numpy.log, - 'exp': numpy.exp, - 'arccos': numpy.arccos, - 'arcsin': numpy.arcsin, - 'arctan': numpy.arctan, - 'arcsec': calcfunctions.arcsec, - 'arccsc': calcfunctions.arccsc, - 'arccot': calcfunctions.arccot, - 'abs': numpy.abs, - 'fact': math.factorial, - 'factorial': math.factorial, - 'sinh': numpy.sinh, - 'cosh': numpy.cosh, - 'tanh': numpy.tanh, - 'sech': calcfunctions.sech, - 'csch': calcfunctions.csch, - 'coth': calcfunctions.coth, - 'arcsinh': numpy.arcsinh, - 'arccosh': numpy.arccosh, - 'arctanh': numpy.arctanh, - 'arcsech': calcfunctions.arcsech, - 'arccsch': calcfunctions.arccsch, - 'arccoth': calcfunctions.arccoth - } -DEFAULT_VARIABLES = {'i': numpy.complex(0, 1), - 'j': numpy.complex(0, 1), - 'e': numpy.e, - 'pi': numpy.pi, - 'k': scipy.constants.k, - 'c': scipy.constants.c, - 'T': 298.15, - 'q': scipy.constants.e - } +DEFAULT_FUNCTIONS = { + 'sin': numpy.sin, + 'cos': numpy.cos, + 'tan': numpy.tan, + 'sec': calcfunctions.sec, + 'csc': calcfunctions.csc, + 'cot': calcfunctions.cot, + 'sqrt': numpy.sqrt, + 'log10': numpy.log10, + 'log2': numpy.log2, + 'ln': numpy.log, + 'exp': numpy.exp, + 'arccos': numpy.arccos, + 'arcsin': numpy.arcsin, + 'arctan': numpy.arctan, + 'arcsec': calcfunctions.arcsec, + 'arccsc': calcfunctions.arccsc, + 'arccot': calcfunctions.arccot, + 'abs': numpy.abs, + 'fact': math.factorial, + 'factorial': math.factorial, + 'sinh': numpy.sinh, + 'cosh': numpy.cosh, + 'tanh': numpy.tanh, + 'sech': calcfunctions.sech, + 'csch': calcfunctions.csch, + 'coth': calcfunctions.coth, + 'arcsinh': numpy.arcsinh, + 'arccosh': numpy.arccosh, + 'arctanh': numpy.arctanh, + 'arcsech': calcfunctions.arcsech, + 'arccsch': calcfunctions.arccsch, + 'arccoth': calcfunctions.arccoth +} +DEFAULT_VARIABLES = { + 'i': numpy.complex(0, 1), + 'j': numpy.complex(0, 1), + 'e': numpy.e, + 'pi': numpy.pi, + 'k': scipy.constants.k, # Boltzmann: 1.3806488e-23 (Joules/Kelvin) + 'c': scipy.constants.c, # Light Speed: 2.998e8 (m/s) + 'T': 298.15, # 0 deg C = T Kelvin + 'q': scipy.constants.e # Fund. Charge: 1.602176565e-19 (Coulombs) +} # We eliminated the following extreme suffixes: -# P (1e15), E (1e18), Z (1e21), Y (1e24), -# f (1e-15), a (1e-18), z (1e-21), y (1e-24) -# since they're rarely used, and potentially -# confusing. They may also conflict with variables if we ever allow e.g. -# 5R instead of 5*R -SUFFIXES = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12, - 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12} +# P (1e15), E (1e18), Z (1e21), Y (1e24), +# f (1e-15), a (1e-18), z (1e-21), y (1e-24) +# since they're rarely used, and potentially confusing. +# They may also conflict with variables if we ever allow e.g. +# 5R instead of 5*R +SUFFIXES = { + '%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12, + 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12 +} class UndefinedVariable(Exception): """ - Used to indicate the student input of a variable, which was unused by the - instructor. + Indicate when a student inputs a variable which was not expected. """ pass -def check_variables(string, variables): - """ - Confirm the only variables in string are defined. - - Otherwise, raise an UndefinedVariable containing all bad variables. - - Pyparsing uses a left-to-right parser, which makes a more - elegant approach pretty hopeless. - """ - general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii - # List of all alnums in string - possible_variables = re.split(general_whitespace, string) - bad_variables = [] - for var in possible_variables: - if len(var) == 0: - continue - if var[0].isdigit(): # Skip things that begin with numbers - continue - if var not in variables: - bad_variables.append(var) - if len(bad_variables) > 0: - raise UndefinedVariable(' '.join(bad_variables)) - - def lower_dict(input_dict): """ - takes each key in the dict and makes it lowercase, still mapping to the - same value. + Convert all keys in a dictionary to lowercase; keep their original values. - keep in mind that it is possible (but not useful?) to define different + Keep in mind that it is possible (but not useful?) to define different variables that have the same lowercase representation. It would be hard to tell which is used in the final dict and which isn't. """ return {k.lower(): v for k, v in input_dict.iteritems()} -# The following few functions define parse actions, which are run on lists of -# results from each parse component. They convert the strings and (previously +# The following few functions define evaluation actions, which are run on lists +# of results from each parse component. They convert the strings and (previously # calculated) numbers into the number that component represents. def super_float(text): """ - Like float, but with si extensions. 1k goes to 1000 + Like float, but with SI extensions. 1k goes to 1000. """ if text[-1] in SUFFIXES: return float(text[:-1]) * SUFFIXES[text[-1]] @@ -134,168 +105,314 @@ def super_float(text): return float(text) -def number_parse_action(parse_result): +def eval_number(parse_result): """ - Create a float out of its string parts + Create a float out of its string parts. - e.g. [ '7', '.', '13' ] -> [ 7.13 ] - Calls super_float above + e.g. [ '7.13', 'e', '3' ] -> 7130 + Calls super_float above. """ return super_float("".join(parse_result)) -def exp_parse_action(parse_result): +def eval_atom(parse_result): """ - Take a list of numbers and exponentiate them, right to left + Return the value wrapped by the atom. - e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561 + In the case of parenthesis, ignore them. """ - # pyparsing.ParseResults doesn't play well with reverse() - parse_result = reversed(parse_result) - # the result of an exponentiation is called a power + # Find first number in the list + result = next(k for k in parse_result if isinstance(k, numbers.Number)) + return result + + +def eval_power(parse_result): + """ + Take a list of numbers and exponentiate them, right to left. + + e.g. [ 2, 3, 2 ] -> 2^3^2 = 2^(3^2) -> 512 + (not to be interpreted (2^3)^2 = 64) + """ + # `reduce` will go from left to right; reverse the list. + parse_result = reversed( + [k for k in parse_result + if isinstance(k, numbers.Number)] # Ignore the '^' marks. + ) + # Having reversed it, raise `b` to the power of `a`. power = reduce(lambda a, b: b ** a, parse_result) return power -def parallel(parse_result): +def eval_parallel(parse_result): """ - Compute numbers according to the parallel resistors operator + Compute numbers according to the parallel resistors operator. BTW it is commutative. Its formula is given by out = 1 / (1/in1 + 1/in2 + ...) - e.g. [ 1, 2 ] => 2/3 + e.g. [ 1, 2 ] -> 2/3 - Return NaN if there is a zero among the inputs + Return NaN if there is a zero among the inputs. """ - # convert from pyparsing.ParseResults, which doesn't support '0 in parse_result' - parse_result = parse_result.asList() if len(parse_result) == 1: return parse_result[0] if 0 in parse_result: return float('nan') - reciprocals = [1. / e for e in parse_result] + reciprocals = [1. / e for e in parse_result + if isinstance(e, numbers.Number)] return 1. / sum(reciprocals) -def sum_parse_action(parse_result): +def eval_sum(parse_result): """ - Add the inputs + Add the inputs, keeping in mind their sign. [ 1, '+', 2, '-', 3 ] -> 0 - Allow a leading + or - + Allow a leading + or -. """ total = 0.0 current_op = operator.add for token in parse_result: - if token is '+': + if token == '+': current_op = operator.add - elif token is '-': + elif token == '-': current_op = operator.sub else: total = current_op(total, token) return total -def prod_parse_action(parse_result): +def eval_product(parse_result): """ - Multiply the inputs + Multiply the inputs. - [ 1, '*', 2, '/', 3 ] => 0.66 + [ 1, '*', 2, '/', 3 ] -> 0.66 """ prod = 1.0 current_op = operator.mul for token in parse_result: - if token is '*': + if token == '*': current_op = operator.mul - elif token is '/': + elif token == '/': current_op = operator.truediv else: prod = current_op(prod, token) return prod -def evaluator(variables, functions, string, cs=False): +def add_defaults(variables, functions, case_sensitive): """ - Evaluate an expression. Variables are passed as a dictionary - from string to value. Unary functions are passed as a dictionary - from string to function. Variables must be floats. - cs: Case sensitive - + Create dictionaries with both the default and user-defined variables. """ - - all_variables = copy.copy(DEFAULT_VARIABLES) - all_functions = copy.copy(DEFAULT_FUNCTIONS) + all_variables = dict(DEFAULT_VARIABLES) + all_functions = dict(DEFAULT_FUNCTIONS) all_variables.update(variables) all_functions.update(functions) - if not cs: - string_cs = string.lower() - all_functions = lower_dict(all_functions) + if not case_sensitive: all_variables = lower_dict(all_variables) - CasedLiteral = CaselessLiteral - else: - string_cs = string - CasedLiteral = Literal + all_functions = lower_dict(all_functions) - check_variables(string_cs, set(all_variables.keys() + all_functions.keys())) + return (all_variables, all_functions) - if string.strip() == "": + +def evaluator(variables, functions, math_expr, case_sensitive=False): + """ + Evaluate an expression; that is, take a string of math and return a float. + + -Variables are passed as a dictionary from string to value. They must be + python numbers. + -Unary functions are passed as a dictionary from string to function. + """ + # No need to go further. + if math_expr.strip() == "": return float('nan') - # SI suffixes and percent - number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()]) - plus_minus = Literal('+') | Literal('-') - times_div = Literal('*') | Literal('/') + # Parse the tree. + math_interpreter = ParseAugmenter(math_expr, case_sensitive) + math_interpreter.parse_algebra() - number_part = Word(nums) + # Get our variables together. + all_variables, all_functions = add_defaults(variables, functions, case_sensitive) - # 0.33 or 7 or .34 or 16. - inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part) - # by default pyparsing allows spaces between tokens--Combine prevents that - inner_number = Combine(inner_number) + # ...and check them + math_interpreter.check_variables(all_variables, all_functions) - # 0.33k or -17 - number = (inner_number - + Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part) - + Optional(number_suffix)) - number.setParseAction(number_parse_action) # Convert to number + # Create a recursion to evaluate the tree. + if case_sensitive: + casify = lambda x: x + else: + casify = lambda x: x.lower() # Lowercase for case insens. - # Predefine recursive variables - expr = Forward() + evaluate_actions = { + 'number': eval_number, + 'variable': lambda x: all_variables[casify(x[0])], + 'function': lambda x: all_functions[casify(x[0])](x[1]), + 'atom': eval_atom, + 'power': eval_power, + 'parallel': eval_parallel, + 'product': eval_product, + 'sum': eval_sum + } - # Handle variables passed in. - # E.g. if we have {'R':0.5}, we make the substitution. - # We sort the list so that var names (like "e2") match before - # mathematical constants (like "e"). This is kind of a hack. - all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True) - varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys]) - varnames.setParseAction( - lambda x: [all_variables[k] for k in x] - ) + return math_interpreter.reduce_tree(evaluate_actions) - # if all_variables were empty, then pyparsing wants - # varnames = NoMatch() - # this is not the case, as all_variables contains the defaults - # Same thing for functions. - all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True) - funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys]) - function = funcnames + Suppress("(") + expr + Suppress(")") - function.setParseAction( - lambda x: [all_functions[x[0]](x[1])] - ) +class ParseAugmenter(object): + """ + Holds the data for a particular parse. - atom = number | function | varnames | Suppress("(") + expr + Suppress(")") + Retains the `math_expr` and `case_sensitive` so they needn't be passed + around method to method. + Eventually holds the parse tree and sets of variables as well. + """ + def __init__(self, math_expr, case_sensitive=False): + """ + Create the ParseAugmenter for a given math expression string. - # Do the following in the correct order to preserve order of operation - pow_term = atom + ZeroOrMore(Suppress("^") + atom) - pow_term.setParseAction(exp_parse_action) # 7^6 - par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k - par_term.setParseAction(parallel) - prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3 - prod_term.setParseAction(prod_parse_action) - sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3 - sum_term.setParseAction(sum_parse_action) - expr << sum_term # finish the recursion - return (expr + stringEnd).parseString(string)[0] + Do the parsing later, when called like `OBJ.parse_algebra()`. + """ + self.case_sensitive = case_sensitive + self.math_expr = math_expr + self.tree = None + self.variables_used = set() + self.functions_used = set() + + def vpa(tokens): + """ + When a variable is recognized, store it in `variables_used`. + """ + varname = tokens[0][0] + self.variables_used.add(varname) + + def fpa(tokens): + """ + When a function is recognized, store it in `functions_used`. + """ + varname = tokens[0][0] + self.functions_used.add(varname) + + self.variable_parse_action = vpa + self.function_parse_action = fpa + + def parse_algebra(self): + """ + Parse an algebraic expression into a tree. + + Store a `pyparsing.ParseResult` in `self.tree` with proper groupings to + reflect parenthesis and order of operations. Leave all operators in the + tree and do not parse any strings of numbers into their float versions. + + Adding the groups and result names makes the `repr()` of the result + really gross. For debugging, use something like + print OBJ.tree.asXML() + """ + # 0.33 or 7 or .34 or 16. + number_part = Word(nums) + inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part) + # pyparsing allows spaces between tokens--`Combine` prevents that. + inner_number = Combine(inner_number) + + # SI suffixes and percent. + number_suffix = MatchFirst(Literal(k) for k in SUFFIXES.keys()) + + # 0.33k or 17 + plus_minus = Literal('+') | Literal('-') + number = Group( + Optional(plus_minus) + + inner_number + + Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part) + + Optional(number_suffix) + ) + number = number("number") + + # Predefine recursive variables. + expr = Forward() + + # Handle variables passed in. They must start with letters/underscores + # and may contain numbers afterward. + inner_varname = Word(alphas + "_", alphanums + "_") + varname = Group(inner_varname)("variable") + varname.setParseAction(self.variable_parse_action) + + # Same thing for functions. + function = Group(inner_varname + Suppress("(") + expr + Suppress(")"))("function") + function.setParseAction(self.function_parse_action) + + atom = number | function | varname | "(" + expr + ")" + atom = Group(atom)("atom") + + # Do the following in the correct order to preserve order of operation. + pow_term = atom + ZeroOrMore("^" + atom) + pow_term = Group(pow_term)("power") + + par_term = pow_term + ZeroOrMore('||' + pow_term) # 5k || 4k + par_term = Group(par_term)("parallel") + + prod_term = par_term + ZeroOrMore((Literal('*') | Literal('/')) + par_term) # 7 * 5 / 4 + prod_term = Group(prod_term)("product") + + sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3 + sum_term = Group(sum_term)("sum") + + # Finish the recursion. + expr << sum_term # pylint: disable=W0104 + self.tree = (expr + stringEnd).parseString(self.math_expr)[0] + + def reduce_tree(self, handle_actions, terminal_converter=None): + """ + Call `handle_actions` recursively on `self.tree` and return result. + + `handle_actions` is a dictionary of node names (e.g. 'product', 'sum', + etc&) to functions. These functions are of the following form: + -input: a list of processed child nodes. If it includes any terminal + nodes in the list, they will be given as their processed forms also. + -output: whatever to be passed to the level higher, and what to + return for the final node. + `terminal_converter` is a function that takes in a token and returns a + processed form. The default of `None` just leaves them as strings. + """ + def handle_node(node): + """ + Return the result representing the node, using recursion. + + Call the appropriate `handle_action` for this node. As its inputs, + feed it the output of `handle_node` for each child node. + """ + if not isinstance(node, ParseResults): + # Then treat it as a terminal node. + if terminal_converter is None: + return node + else: + return terminal_converter(node) + + node_name = node.getName() + if node_name not in handle_actions: # pragma: no cover + raise Exception(u"Unknown branch name '{}'".format(node_name)) + + action = handle_actions[node_name] + handled_kids = [handle_node(k) for k in node] + return action(handled_kids) + + # Find the value of the entire tree. + return handle_node(self.tree) + + def check_variables(self, valid_variables, valid_functions): + """ + Confirm that all the variables used in the tree are valid/defined. + + Otherwise, raise an UndefinedVariable containing all bad variables. + """ + if self.case_sensitive: + casify = lambda x: x + else: + casify = lambda x: x.lower() # Lowercase for case insens. + + # Test if casify(X) is valid, but return the actual bad input (i.e. X) + bad_vars = set(var for var in self.variables_used + if casify(var) not in valid_variables) + bad_vars.update(func for func in self.functions_used + if casify(func) not in valid_functions) + + if bad_vars: + raise UndefinedVariable(' '.join(sorted(bad_vars))) diff --git a/common/lib/calc/preview.py b/common/lib/calc/preview.py new file mode 100644 index 0000000000..b800b7604b --- /dev/null +++ b/common/lib/calc/preview.py @@ -0,0 +1,390 @@ +""" +Provide a `latex_preview` method similar in syntax to `evaluator`. + +That is, given a math string, parse it and render each branch of the result, +always returning valid latex. + +Because intermediate values of the render contain more data than simply the +string of latex, store it in a custom class `LatexRendered`. +""" + +from calc import ParseAugmenter, DEFAULT_VARIABLES, DEFAULT_FUNCTIONS, SUFFIXES + + +class LatexRendered(object): + """ + Data structure to hold a typeset representation of some math. + + Fields: + -`latex` is a generated, valid latex string (as if it were standalone). + -`sans_parens` is usually the same as `latex` except without the outermost + parens (if applicable). + -`tall` is a boolean representing if the latex has any elements extending + above or below a normal height, specifically things of the form 'a^b' and + '\frac{a}{b}'. This affects the height of wrapping parenthesis. + """ + def __init__(self, latex, parens=None, tall=False): + """ + Instantiate with the latex representing the math. + + Optionally include parenthesis to wrap around it and the height. + `parens` must be one of '(', '[' or '{'. + `tall` is a boolean (see note above). + """ + self.latex = latex + self.sans_parens = latex + self.tall = tall + + # Generate parens and overwrite `self.latex`. + if parens is not None: + left_parens = parens + if left_parens == '{': + left_parens = r'\{' + + pairs = {'(': ')', + '[': ']', + r'\{': r'\}'} + if left_parens not in pairs: + raise Exception( + u"Unknown parenthesis '{}': coder error".format(left_parens) + ) + right_parens = pairs[left_parens] + + if self.tall: + left_parens = r"\left" + left_parens + right_parens = r"\right" + right_parens + + self.latex = u"{left}{expr}{right}".format( + left=left_parens, + expr=latex, + right=right_parens + ) + + def __repr__(self): # pragma: no cover + """ + Give a sensible representation of the object. + + If `sans_parens` is different, include both. + If `tall` then have '<[]>' around the code, otherwise '<>'. + """ + if self.latex == self.sans_parens: + latex_repr = u'"{}"'.format(self.latex) + else: + latex_repr = u'"{}" or "{}"'.format(self.latex, self.sans_parens) + + if self.tall: + wrap = u'<[{}]>' + else: + wrap = u'<{}>' + + return wrap.format(latex_repr) + + +def render_number(children): + """ + Combine the elements forming the number, escaping the suffix if needed. + """ + children_latex = [k.latex for k in children] + + suffix = "" + if children_latex[-1] in SUFFIXES: + suffix = children_latex.pop() + suffix = ur"\text{{{s}}}".format(s=suffix) + + # Exponential notation-- the "E" splits the mantissa and exponent + if "E" in children_latex: + pos = children_latex.index("E") + mantissa = "".join(children_latex[:pos]) + exponent = "".join(children_latex[pos + 1:]) + latex = ur"{m}\!\times\!10^{{{e}}}{s}".format( + m=mantissa, e=exponent, s=suffix + ) + return LatexRendered(latex, tall=True) + else: + easy_number = "".join(children_latex) + return LatexRendered(easy_number + suffix) + + +def enrich_varname(varname): + """ + Prepend a backslash if we're given a greek character. + """ + greek = ("alpha beta gamma delta epsilon varepsilon zeta eta theta " + "vartheta iota kappa lambda mu nu xi pi rho sigma tau upsilon " + "phi varphi chi psi omega").split() + + if varname in greek: + return ur"\{letter}".format(letter=varname) + else: + return varname.replace("_", r"\_") + + +def variable_closure(variables, casify): + """ + Wrap `render_variable` so it knows the variables allowed. + """ + def render_variable(children): + """ + Replace greek letters, otherwise escape the variable names. + """ + varname = children[0].latex + if casify(varname) not in variables: + pass # TODO turn unknown variable red or give some kind of error + + first, _, second = varname.partition("_") + + if second: + # Then 'a_b' must become 'a_{b}' + varname = ur"{a}_{{{b}}}".format( + a=enrich_varname(first), + b=enrich_varname(second) + ) + else: + varname = enrich_varname(varname) + + return LatexRendered(varname) # .replace("_", r"\_")) + return render_variable + + +def function_closure(functions, casify): + """ + Wrap `render_function` so it knows the functions allowed. + """ + def render_function(children): + """ + Escape function names and give proper formatting to exceptions. + + The exceptions being 'sqrt', 'log2', and 'log10' as of now. + """ + fname = children[0].latex + if casify(fname) not in functions: + pass # TODO turn unknown function red or give some kind of error + + # Wrap the input of the function with parens or braces. + inner = children[1].latex + if fname == "sqrt": + inner = u"{{{expr}}}".format(expr=inner) + else: + if children[1].tall: + inner = ur"\left({expr}\right)".format(expr=inner) + else: + inner = u"({expr})".format(expr=inner) + + # Correctly format the name of the function. + if fname == "sqrt": + fname = ur"\sqrt" + elif fname == "log10": + fname = ur"\log_{10}" + elif fname == "log2": + fname = ur"\log_2" + else: + fname = ur"\text{{{fname}}}".format(fname=fname) + + # Put it together. + latex = fname + inner + return LatexRendered(latex, tall=children[1].tall) + # Return the function within the closure. + return render_function + + +def render_power(children): + """ + Combine powers so that the latex is wrapped in curly braces correctly. + + Also, if you have 'a^(b+c)' don't include that last set of parens: + 'a^{b+c}' is correct, whereas 'a^{(b+c)}' is extraneous. + """ + if len(children) == 1: + return children[0] + + children_latex = [k.latex for k in children if k.latex != "^"] + children_latex[-1] = children[-1].sans_parens + + raise_power = lambda x, y: u"{}^{{{}}}".format(y, x) + latex = reduce(raise_power, reversed(children_latex)) + return LatexRendered(latex, tall=True) + + +def render_parallel(children): + """ + Simply join the child nodes with a double vertical line. + """ + if len(children) == 1: + return children[0] + + children_latex = [k.latex for k in children if k.latex != "||"] + latex = r"\|".join(children_latex) + tall = any(k.tall for k in children) + return LatexRendered(latex, tall=tall) + + +def render_frac(numerator, denominator): + r""" + Given a list of elements in the numerator and denominator, return a '\frac' + + Avoid parens if they are unnecessary (i.e. the only thing in that part). + """ + if len(numerator) == 1: + num_latex = numerator[0].sans_parens + else: + num_latex = r"\cdot ".join(k.latex for k in numerator) + + if len(denominator) == 1: + den_latex = denominator[0].sans_parens + else: + den_latex = r"\cdot ".join(k.latex for k in denominator) + + latex = ur"\frac{{{num}}}{{{den}}}".format(num=num_latex, den=den_latex) + return latex + + +def render_product(children): + r""" + Format products and division nicely. + + Group bunches of adjacent, equal operators. Every time it switches from + denominator to the next numerator, call `render_frac`. Join these groupings + together with '\cdot's, ending on a numerator if needed. + + Examples: (`children` is formed indirectly by the string on the left) + 'a*b' -> 'a\cdot b' + 'a/b' -> '\frac{a}{b}' + 'a*b/c/d' -> '\frac{a\cdot b}{c\cdot d}' + 'a/b*c/d*e' -> '\frac{a}{b}\cdot \frac{c}{d}\cdot e' + """ + if len(children) == 1: + return children[0] + + position = "numerator" # or denominator + fraction_mode_ever = False + numerator = [] + denominator = [] + latex = "" + + for kid in children: + if position == "numerator": + if kid.latex == "*": + pass # Don't explicitly add the '\cdot' yet. + elif kid.latex == "/": + # Switch to denominator mode. + fraction_mode_ever = True + position = "denominator" + else: + numerator.append(kid) + else: + if kid.latex == "*": + # Switch back to numerator mode. + # First, render the current fraction and add it to the latex. + latex += render_frac(numerator, denominator) + r"\cdot " + + # Reset back to beginning state + position = "numerator" + numerator = [] + denominator = [] + elif kid.latex == "/": + pass # Don't explicitly add a '\frac' yet. + else: + denominator.append(kid) + + # Add the fraction/numerator that we ended on. + if position == "denominator": + latex += render_frac(numerator, denominator) + else: + # We ended on a numerator--act like normal multiplication. + num_latex = r"\cdot ".join(k.latex for k in numerator) + latex += num_latex + + tall = fraction_mode_ever or any(k.tall for k in children) + return LatexRendered(latex, tall=tall) + + +def render_sum(children): + """ + Concatenate elements, including the operators. + """ + if len(children) == 1: + return children[0] + + children_latex = [k.latex for k in children] + latex = "".join(children_latex) + tall = any(k.tall for k in children) + return LatexRendered(latex, tall=tall) + + +def render_atom(children): + """ + Properly handle parens, otherwise this is trivial. + """ + if len(children) == 3: + return LatexRendered( + children[1].latex, + parens=children[0].latex, + tall=children[1].tall + ) + else: + return children[0] + + +def add_defaults(var, fun, case_sensitive=False): + """ + Create sets with both the default and user-defined variables. + + Compare to calc.add_defaults + """ + var_items = set(DEFAULT_VARIABLES) + fun_items = set(DEFAULT_FUNCTIONS) + + var_items.update(var) + fun_items.update(fun) + + if not case_sensitive: + var_items = set(k.lower() for k in var_items) + fun_items = set(k.lower() for k in fun_items) + + return var_items, fun_items + + +def latex_preview(math_expr, variables=(), functions=(), case_sensitive=False): + """ + Convert `math_expr` into latex, guaranteeing its parse-ability. + + Analagous to `evaluator`. + """ + # No need to go further + if math_expr.strip() == "": + return "" + + # Parse tree + latex_interpreter = ParseAugmenter(math_expr, case_sensitive) + latex_interpreter.parse_algebra() + + # Get our variables together. + variables, functions = add_defaults(variables, functions, case_sensitive) + + # Create a recursion to evaluate the tree. + if case_sensitive: + casify = lambda x: x + else: + casify = lambda x: x.lower() # Lowercase for case insens. + + render_actions = { + 'number': render_number, + 'variable': variable_closure(variables, casify), + 'function': function_closure(functions, casify), + 'atom': render_atom, + 'power': render_power, + 'parallel': render_parallel, + 'product': render_product, + 'sum': render_sum + } + + backslash = "\\" + wrap_escaped_strings = lambda s: LatexRendered( + s.replace(backslash, backslash * 2) + ) + + output = latex_interpreter.reduce_tree( + render_actions, + terminal_converter=wrap_escaped_strings + ) + return output.latex diff --git a/common/lib/calc/tests/test_calc.py b/common/lib/calc/tests/test_calc.py index 13cd9e9471..3b8981f5c3 100644 --- a/common/lib/calc/tests/test_calc.py +++ b/common/lib/calc/tests/test_calc.py @@ -7,6 +7,12 @@ import numpy import calc from pyparsing import ParseException +# numpy's default behavior when it evaluates a function outside its domain +# is to raise a warning (not an exception) which is then printed to STDOUT. +# To prevent this from polluting the output of the tests, configure numpy to +# ignore it instead. +# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html +numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise' class EvaluatorTest(unittest.TestCase): """ @@ -14,7 +20,7 @@ class EvaluatorTest(unittest.TestCase): Go through all functionalities as specifically as possible-- work from number input to functions and complex expressions Also test custom variable substitutions (i.e. - `evaluator({'x':3.0},{}, '3*x')` + `evaluator({'x':3.0}, {}, '3*x')` gives 9.0) and more. """ @@ -41,37 +47,40 @@ class EvaluatorTest(unittest.TestCase): """ The string '.' should not evaluate to anything. """ - self.assertRaises(ParseException, calc.evaluator, {}, {}, '.') - self.assertRaises(ParseException, calc.evaluator, {}, {}, '1+.') + with self.assertRaises(ParseException): + calc.evaluator({}, {}, '.') + with self.assertRaises(ParseException): + calc.evaluator({}, {}, '1+.') def test_trailing_period(self): """ Test that things like '4.' will be 4 and not throw an error """ - try: - self.assertEqual(4.0, calc.evaluator({}, {}, '4.')) - except ParseException: - self.fail("'4.' is a valid input, but threw an exception") + self.assertEqual(4.0, calc.evaluator({}, {}, '4.')) def test_exponential_answer(self): """ Test for correct interpretation of scientific notation """ answer = 50 - correct_responses = ["50", "50.0", "5e1", "5e+1", - "50e0", "50.0e0", "500e-1"] + correct_responses = [ + "50", "50.0", "5e1", "5e+1", + "50e0", "50.0e0", "500e-1" + ] incorrect_responses = ["", "3.9", "4.1", "0", "5.01e1"] for input_str in correct_responses: result = calc.evaluator({}, {}, input_str) fail_msg = "Expected '{0}' to equal {1}".format( - input_str, answer) + input_str, answer + ) self.assertEqual(answer, result, msg=fail_msg) for input_str in incorrect_responses: result = calc.evaluator({}, {}, input_str) fail_msg = "Expected '{0}' to not equal {1}".format( - input_str, answer) + input_str, answer + ) self.assertNotEqual(answer, result, msg=fail_msg) def test_si_suffix(self): @@ -80,17 +89,21 @@ class EvaluatorTest(unittest.TestCase): For instance 'k' stand for 'kilo-' so '1k' should be 1,000 """ - test_mapping = [('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000), - ('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074), - ('5.4m', 0.0054), ('8.7u', 0.0000087), - ('5.6n', 5.6e-9), ('4.2p', 4.2e-12)] + test_mapping = [ + ('4.2%', 0.042), ('2.25k', 2250), ('8.3M', 8300000), + ('9.9G', 9.9e9), ('1.2T', 1.2e12), ('7.4c', 0.074), + ('5.4m', 0.0054), ('8.7u', 0.0000087), + ('5.6n', 5.6e-9), ('4.2p', 4.2e-12) + ] for (expr, answer) in test_mapping: tolerance = answer * 1e-6 # Make rel. tolerance, because of floats fail_msg = "Failure in testing suffix '{0}': '{1}' was not {2}" fail_msg = fail_msg.format(expr[-1], expr, answer) - self.assertAlmostEqual(calc.evaluator({}, {}, expr), answer, - delta=tolerance, msg=fail_msg) + self.assertAlmostEqual( + calc.evaluator({}, {}, expr), answer, + delta=tolerance, msg=fail_msg + ) def test_operator_sanity(self): """ @@ -104,19 +117,20 @@ class EvaluatorTest(unittest.TestCase): input_str = "{0} {1} {2}".format(var1, operator, var2) result = calc.evaluator({}, {}, input_str) fail_msg = "Failed on operator '{0}': '{1}' was not {2}".format( - operator, input_str, answer) + operator, input_str, answer + ) self.assertEqual(answer, result, msg=fail_msg) def test_raises_zero_division_err(self): """ Ensure division by zero gives an error """ - self.assertRaises(ZeroDivisionError, calc.evaluator, - {}, {}, '1/0') - self.assertRaises(ZeroDivisionError, calc.evaluator, - {}, {}, '1/0.0') - self.assertRaises(ZeroDivisionError, calc.evaluator, - {'x': 0.0}, {}, '1/x') + with self.assertRaises(ZeroDivisionError): + calc.evaluator({}, {}, '1/0') + with self.assertRaises(ZeroDivisionError): + calc.evaluator({}, {}, '1/0.0') + with self.assertRaises(ZeroDivisionError): + calc.evaluator({'x': 0.0}, {}, '1/x') def test_parallel_resistors(self): """ @@ -153,7 +167,8 @@ class EvaluatorTest(unittest.TestCase): input_str = "{0}({1})".format(fname, arg) result = calc.evaluator({}, {}, input_str) fail_msg = "Failed on function {0}: '{1}' was not {2}".format( - fname, input_str, val) + fname, input_str, val + ) self.assertAlmostEqual(val, result, delta=tolerance, msg=fail_msg) def test_trig_functions(self): @@ -177,17 +192,16 @@ class EvaluatorTest(unittest.TestCase): arcsin_inputs = ['-0.707', '0', '0.5', '0.588', '1.298 + 0.635*j'] arcsin_angles = [-0.785, 0, 0.524, 0.629, 1 + 1j] self.assert_function_values('arcsin', arcsin_inputs, arcsin_angles) - # Rather than throwing an exception, numpy.arcsin gives nan - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)'))) - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)'))) - # Disabled for now because they are giving a runtime warning... :-/ + # Rather than a complex number, numpy.arcsin gives nan + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)'))) + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)'))) # Include those where the real part is between 0 and pi arccos_inputs = ['1', '0.866', '0.809', '0.834-0.989*j'] arccos_angles = [0, 0.524, 0.628, 1 + 1j] self.assert_function_values('arccos', arccos_inputs, arccos_angles) - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)'))) - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)'))) + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)'))) + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)'))) # Has the same range as arcsin arctan_inputs = ['-1', '0', '0.577', '0.727', '0.272 + 1.084*j'] @@ -303,21 +317,29 @@ class EvaluatorTest(unittest.TestCase): """ # Test sqrt - self.assert_function_values('sqrt', - [0, 1, 2, 1024], # -1 - [0, 1, 1.414, 32]) # 1j + self.assert_function_values( + 'sqrt', + [0, 1, 2, 1024], # -1 + [0, 1, 1.414, 32] # 1j + ) # sqrt(-1) is NAN not j (!!). # Test logs - self.assert_function_values('log10', - [0.1, 1, 3.162, 1000000, '1+j'], - [-1, 0, 0.5, 6, 0.151 + 0.341j]) - self.assert_function_values('log2', - [0.5, 1, 1.414, 1024, '1+j'], - [-1, 0, 0.5, 10, 0.5 + 1.133j]) - self.assert_function_values('ln', - [0.368, 1, 1.649, 2.718, 42, '1+j'], - [-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j]) + self.assert_function_values( + 'log10', + [0.1, 1, 3.162, 1000000, '1+j'], + [-1, 0, 0.5, 6, 0.151 + 0.341j] + ) + self.assert_function_values( + 'log2', + [0.5, 1, 1.414, 1024, '1+j'], + [-1, 0, 0.5, 10, 0.5 + 1.133j] + ) + self.assert_function_values( + 'ln', + [0.368, 1, 1.649, 2.718, 42, '1+j'], + [-1, 0, 0.5, 1, 3.738, 0.347 + 0.785j] + ) # Test abs self.assert_function_values('abs', [-1, 0, 1, 'j'], [1, 0, 1, 1]) @@ -341,26 +363,28 @@ class EvaluatorTest(unittest.TestCase): """ # Of the form ('expr', python value, tolerance (or None for exact)) - default_variables = [('j', 1j, None), - ('e', 2.7183, 1e-3), - ('pi', 3.1416, 1e-3), - # c = speed of light - ('c', 2.998e8, 1e5), - # 0 deg C = T Kelvin - ('T', 298.15, 0.01), - # Note k = scipy.constants.k = 1.3806488e-23 - ('k', 1.3806488e-23, 1e-26), - # Note q = scipy.constants.e = 1.602176565e-19 - ('q', 1.602176565e-19, 1e-22)] + default_variables = [ + ('i', 1j, None), + ('j', 1j, None), + ('e', 2.7183, 1e-4), + ('pi', 3.1416, 1e-4), + ('k', 1.3806488e-23, 1e-26), # Boltzmann constant (Joules/Kelvin) + ('c', 2.998e8, 1e5), # Light Speed in (m/s) + ('T', 298.15, 0.01), # 0 deg C = T Kelvin + ('q', 1.602176565e-19, 1e-22) # Fund. Charge (Coulombs) + ] for (variable, value, tolerance) in default_variables: fail_msg = "Failed on constant '{0}', not within bounds".format( - variable) + variable + ) result = calc.evaluator({}, {}, variable) if tolerance is None: self.assertEqual(value, result, msg=fail_msg) else: - self.assertAlmostEqual(value, result, - delta=tolerance, msg=fail_msg) + self.assertAlmostEqual( + value, result, + delta=tolerance, msg=fail_msg + ) def test_complex_expression(self): """ @@ -370,21 +394,51 @@ class EvaluatorTest(unittest.TestCase): self.assertAlmostEqual( calc.evaluator({}, {}, "(2^2+1.0)/sqrt(5e0)*5-1"), 10.180, - delta=1e-3) - + delta=1e-3 + ) self.assertAlmostEqual( calc.evaluator({}, {}, "1+1/(1+1/(1+1/(1+1)))"), 1.6, - delta=1e-3) + delta=1e-3 + ) self.assertAlmostEqual( calc.evaluator({}, {}, "10||sin(7+5)"), - -0.567, delta=0.01) - self.assertAlmostEqual(calc.evaluator({}, {}, "sin(e)"), - 0.41, delta=0.01) - self.assertAlmostEqual(calc.evaluator({}, {}, "k*T/q"), - 0.025, delta=1e-3) - self.assertAlmostEqual(calc.evaluator({}, {}, "e^(j*pi)"), - -1, delta=1e-5) + -0.567, delta=0.01 + ) + self.assertAlmostEqual( + calc.evaluator({}, {}, "sin(e)"), + 0.41, delta=0.01 + ) + self.assertAlmostEqual( + calc.evaluator({}, {}, "k*T/q"), + 0.025, delta=1e-3 + ) + self.assertAlmostEqual( + calc.evaluator({}, {}, "e^(j*pi)"), + -1, delta=1e-5 + ) + + def test_explicit_sci_notation(self): + """ + Expressions like 1.6*10^-3 (not 1.6e-3) it should evaluate. + """ + self.assertEqual( + calc.evaluator({}, {}, "-1.6*10^-3"), + -0.0016 + ) + self.assertEqual( + calc.evaluator({}, {}, "-1.6*10^(-3)"), + -0.0016 + ) + + self.assertEqual( + calc.evaluator({}, {}, "-1.6*10^3"), + -1600 + ) + self.assertEqual( + calc.evaluator({}, {}, "-1.6*10^(3)"), + -1600 + ) def test_simple_vars(self): """ @@ -404,19 +458,24 @@ class EvaluatorTest(unittest.TestCase): self.assertEqual(calc.evaluator(variables, {}, 'loooooong'), 6.4) # Test a simple equation - self.assertAlmostEqual(calc.evaluator(variables, {}, '3*x-y'), - 21.25, delta=0.01) # = 3 * 9.72 - 7.91 - self.assertAlmostEqual(calc.evaluator(variables, {}, 'x*y'), - 76.89, delta=0.01) + self.assertAlmostEqual( + calc.evaluator(variables, {}, '3*x-y'), + 21.25, delta=0.01 # = 3 * 9.72 - 7.91 + ) + self.assertAlmostEqual( + calc.evaluator(variables, {}, 'x*y'), + 76.89, delta=0.01 + ) self.assertEqual(calc.evaluator({'x': 9.72, 'y': 7.91}, {}, "13"), 13) self.assertEqual(calc.evaluator(variables, {}, "13"), 13) self.assertEqual( - calc.evaluator({ - 'a': 2.2997471478310274, 'k': 9, 'm': 8, - 'x': 0.66009498411213041}, - {}, "5"), - 5) + calc.evaluator( + {'a': 2.2997471478310274, 'k': 9, 'm': 8, 'x': 0.6600949841121}, + {}, "5" + ), + 5 + ) def test_variable_case_sensitivity(self): """ @@ -424,15 +483,21 @@ class EvaluatorTest(unittest.TestCase): """ self.assertEqual( calc.evaluator({'R1': 2.0, 'R3': 4.0}, {}, "r1*r3"), - 8.0) + 8.0 + ) variables = {'t': 1.0} self.assertEqual(calc.evaluator(variables, {}, "t"), 1.0) self.assertEqual(calc.evaluator(variables, {}, "T"), 1.0) - self.assertEqual(calc.evaluator(variables, {}, "t", cs=True), 1.0) + self.assertEqual( + calc.evaluator(variables, {}, "t", case_sensitive=True), + 1.0 + ) # Recall 'T' is a default constant, with value 298.15 - self.assertAlmostEqual(calc.evaluator(variables, {}, "T", cs=True), - 298, delta=0.2) + self.assertAlmostEqual( + calc.evaluator(variables, {}, "T", case_sensitive=True), + 298, delta=0.2 + ) def test_simple_funcs(self): """ @@ -445,22 +510,41 @@ class EvaluatorTest(unittest.TestCase): self.assertEqual(calc.evaluator(variables, functions, 'id(x)'), 4.712) functions.update({'f': numpy.sin}) - self.assertAlmostEqual(calc.evaluator(variables, functions, 'f(x)'), - -1, delta=1e-3) + self.assertAlmostEqual( + calc.evaluator(variables, functions, 'f(x)'), + -1, delta=1e-3 + ) - def test_function_case_sensitivity(self): + def test_function_case_insensitive(self): """ - Test the case sensitivity of functions + Test case insensitive evaluation + + Normal functions with some capitals should be fine """ - functions = {'f': lambda x: x, - 'F': lambda x: x + 1} - # Test case insensitive evaluation - # Both evaulations should call the same function - self.assertEqual(calc.evaluator({}, functions, 'f(6)'), - calc.evaluator({}, functions, 'F(6)')) - # Test case sensitive evaluation - self.assertNotEqual(calc.evaluator({}, functions, 'f(6)', cs=True), - calc.evaluator({}, functions, 'F(6)', cs=True)) + self.assertAlmostEqual( + -0.28, + calc.evaluator({}, {}, 'SiN(6)', case_sensitive=False), + delta=1e-3 + ) + + def test_function_case_sensitive(self): + """ + Test case sensitive evaluation + + Incorrectly capitilized should fail + Also, it should pick the correct version of a function. + """ + with self.assertRaisesRegexp(calc.UndefinedVariable, 'SiN'): + calc.evaluator({}, {}, 'SiN(6)', case_sensitive=True) + + # With case sensitive turned on, it should pick the right function + functions = {'f': lambda x: x, 'F': lambda x: x + 1} + self.assertEqual( + 6, calc.evaluator({}, functions, 'f(6)', case_sensitive=True) + ) + self.assertEqual( + 7, calc.evaluator({}, functions, 'F(6)', case_sensitive=True) + ) def test_undefined_vars(self): """ @@ -468,9 +552,9 @@ class EvaluatorTest(unittest.TestCase): """ variables = {'R1': 2.0, 'R3': 4.0} - self.assertRaises(calc.UndefinedVariable, calc.evaluator, - {}, {}, "5+7 QWSEKO") - self.assertRaises(calc.UndefinedVariable, calc.evaluator, - {'r1': 5}, {}, "r1+r2") - self.assertRaises(calc.UndefinedVariable, calc.evaluator, - variables, {}, "r1*r3", cs=True) + with self.assertRaisesRegexp(calc.UndefinedVariable, 'QWSEKO'): + calc.evaluator({}, {}, "5+7*QWSEKO") + with self.assertRaisesRegexp(calc.UndefinedVariable, 'r2'): + calc.evaluator({'r1': 5}, {}, "r1+r2") + with self.assertRaisesRegexp(calc.UndefinedVariable, 'r1 r3'): + calc.evaluator(variables, {}, "r1*r3", case_sensitive=True) diff --git a/common/lib/calc/tests/test_preview.py b/common/lib/calc/tests/test_preview.py new file mode 100644 index 0000000000..0008cdda47 --- /dev/null +++ b/common/lib/calc/tests/test_preview.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for preview.py +""" + +import unittest +import preview +import pyparsing + + +class LatexRenderedTest(unittest.TestCase): + """ + Test the initializing code for LatexRendered. + + Specifically that it stores the correct data and handles parens well. + """ + def test_simple(self): + """ + Test that the data values are stored without changing. + """ + math = 'x^2' + obj = preview.LatexRendered(math, tall=True) + self.assertEquals(obj.latex, math) + self.assertEquals(obj.sans_parens, math) + self.assertEquals(obj.tall, True) + + def _each_parens(self, with_parens, math, parens, tall=False): + """ + Helper method to test the way parens are wrapped. + """ + obj = preview.LatexRendered(math, parens=parens, tall=tall) + self.assertEquals(obj.latex, with_parens) + self.assertEquals(obj.sans_parens, math) + self.assertEquals(obj.tall, tall) + + def test_parens(self): + """ Test curvy parens. """ + self._each_parens('(x+y)', 'x+y', '(') + + def test_brackets(self): + """ Test brackets. """ + self._each_parens('[x+y]', 'x+y', '[') + + def test_squiggles(self): + """ Test curly braces. """ + self._each_parens(r'\{x+y\}', 'x+y', '{') + + def test_parens_tall(self): + """ Test curvy parens with the tall parameter. """ + self._each_parens(r'\left(x^y\right)', 'x^y', '(', tall=True) + + def test_brackets_tall(self): + """ Test brackets, also tall. """ + self._each_parens(r'\left[x^y\right]', 'x^y', '[', tall=True) + + def test_squiggles_tall(self): + """ Test tall curly braces. """ + self._each_parens(r'\left\{x^y\right\}', 'x^y', '{', tall=True) + + def test_bad_parens(self): + """ Check that we get an error with invalid parens. """ + with self.assertRaisesRegexp(Exception, 'Unknown parenthesis'): + preview.LatexRendered('x^2', parens='not parens') + + +class LatexPreviewTest(unittest.TestCase): + """ + Run integrative tests for `latex_preview`. + + All functionality was tested `RenderMethodsTest`, but see if it combines + all together correctly. + """ + def test_no_input(self): + """ + With no input (including just whitespace), see that no error is thrown. + """ + self.assertEquals('', preview.latex_preview('')) + self.assertEquals('', preview.latex_preview(' ')) + self.assertEquals('', preview.latex_preview(' \t ')) + + def test_number_simple(self): + """ Simple numbers should pass through. """ + self.assertEquals(preview.latex_preview('3.1415'), '3.1415') + + def test_number_suffix(self): + """ Suffixes should be escaped. """ + self.assertEquals(preview.latex_preview('1.618k'), r'1.618\text{k}') + + def test_number_sci_notation(self): + """ Numbers with scientific notation should display nicely """ + self.assertEquals( + preview.latex_preview('6.0221413E+23'), + r'6.0221413\!\times\!10^{+23}' + ) + self.assertEquals( + preview.latex_preview('-6.0221413E+23'), + r'-6.0221413\!\times\!10^{+23}' + ) + + def test_number_sci_notation_suffix(self): + """ Test numbers with both of these. """ + self.assertEquals( + preview.latex_preview('6.0221413E+23k'), + r'6.0221413\!\times\!10^{+23}\text{k}' + ) + self.assertEquals( + preview.latex_preview('-6.0221413E+23k'), + r'-6.0221413\!\times\!10^{+23}\text{k}' + ) + + def test_variable_simple(self): + """ Simple valid variables should pass through. """ + self.assertEquals(preview.latex_preview('x', variables=['x']), 'x') + + def test_greek(self): + """ Variable names that are greek should be formatted accordingly. """ + self.assertEquals(preview.latex_preview('pi'), r'\pi') + + def test_variable_subscript(self): + """ Things like 'epsilon_max' should display nicely """ + self.assertEquals( + preview.latex_preview('epsilon_max', variables=['epsilon_max']), + r'\epsilon_{max}' + ) + + def test_function_simple(self): + """ Valid function names should be escaped. """ + self.assertEquals( + preview.latex_preview('f(3)', functions=['f']), + r'\text{f}(3)' + ) + + def test_function_tall(self): + r""" Functions surrounding a tall element should have \left, \right """ + self.assertEquals( + preview.latex_preview('f(3^2)', functions=['f']), + r'\text{f}\left(3^{2}\right)' + ) + + def test_function_sqrt(self): + """ Sqrt function should be handled specially. """ + self.assertEquals(preview.latex_preview('sqrt(3)'), r'\sqrt{3}') + + def test_function_log10(self): + """ log10 function should be handled specially. """ + self.assertEquals(preview.latex_preview('log10(3)'), r'\log_{10}(3)') + + def test_function_log2(self): + """ log2 function should be handled specially. """ + self.assertEquals(preview.latex_preview('log2(3)'), r'\log_2(3)') + + def test_power_simple(self): + """ Powers should wrap the elements with braces correctly. """ + self.assertEquals(preview.latex_preview('2^3^4'), '2^{3^{4}}') + + def test_power_parens(self): + """ Powers should ignore the parenthesis of the last math. """ + self.assertEquals(preview.latex_preview('2^3^(4+5)'), '2^{3^{4+5}}') + + def test_parallel(self): + r""" Parallel items should combine with '\|'. """ + self.assertEquals(preview.latex_preview('2||3'), r'2\|3') + + def test_product_mult_only(self): + r""" Simple products should combine with a '\cdot'. """ + self.assertEquals(preview.latex_preview('2*3'), r'2\cdot 3') + + def test_product_big_frac(self): + """ Division should combine with '\frac'. """ + self.assertEquals( + preview.latex_preview('2*3/4/5'), + r'\frac{2\cdot 3}{4\cdot 5}' + ) + + def test_product_single_frac(self): + """ Division should ignore parens if they are extraneous. """ + self.assertEquals( + preview.latex_preview('(2+3)/(4+5)'), + r'\frac{2+3}{4+5}' + ) + + def test_product_keep_going(self): + """ + Complex products/quotients should split into many '\frac's when needed. + """ + self.assertEquals( + preview.latex_preview('2/3*4/5*6'), + r'\frac{2}{3}\cdot \frac{4}{5}\cdot 6' + ) + + def test_sum(self): + """ Sums should combine its elements. """ + # Use 'x' as the first term (instead of, say, '1'), so it can't be + # interpreted as a negative number. + self.assertEquals( + preview.latex_preview('-x+2-3+4', variables=['x']), + '-x+2-3+4' + ) + + def test_sum_tall(self): + """ A complicated expression should not hide the tallness. """ + self.assertEquals( + preview.latex_preview('(2+3^2)'), + r'\left(2+3^{2}\right)' + ) + + def test_complicated(self): + """ + Given complicated input, ensure that exactly the correct string is made. + """ + self.assertEquals( + preview.latex_preview('11*f(x)+x^2*(3||4)/sqrt(pi)'), + r'11\cdot \text{f}(x)+\frac{x^{2}\cdot (3\|4)}{\sqrt{\pi}}' + ) + + self.assertEquals( + preview.latex_preview('log10(1+3/4/Cos(x^2)*(x+1))', + case_sensitive=True), + (r'\log_{10}\left(1+\frac{3}{4\cdot \text{Cos}\left(x^{2}\right)}' + r'\cdot (x+1)\right)') + ) + + def test_syntax_errors(self): + """ + Test a lot of math strings that give syntax errors + + Rather than have a lot of self.assertRaises, make a loop and keep track + of those that do not throw a `ParseException`, and assert at the end. + """ + bad_math_list = [ + '11+', + '11*', + 'f((x)', + 'sqrt(x^)', + '3f(x)', # Not 3*f(x) + '3|4', + '3|||4' + ] + bad_exceptions = {} + for math in bad_math_list: + try: + preview.latex_preview(math) + except pyparsing.ParseException: + pass # This is what we were expecting. (not excepting :P) + except Exception as error: # pragma: no cover + bad_exceptions[math] = error + else: # pragma: no cover + # If there is no exception thrown, this is a problem + bad_exceptions[math] = None + + self.assertEquals({}, bad_exceptions) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 29800a211b..9defd2c5e6 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -16,6 +16,8 @@ Module containing the problem elements which render into input objects - crystallography - vsepr_input - drag_and_drop +- formulaequationinput +- chemicalequationinput These are matched by *.html files templates/*.html which are mako templates with the actual html. @@ -47,6 +49,7 @@ import pyparsing from .registry import TagRegistry from chem import chemcalc +from preview import latex_preview import xqueue_interface from datetime import datetime @@ -531,7 +534,7 @@ class TextLine(InputTypeBase): is used e.g. for embedding simulations turned into questions. Example: - + This example will render out a text line with a math preview and the text 'm/s' after the end of the text line. @@ -1037,15 +1040,16 @@ class ChemicalEquationInput(InputTypeBase): result = {'preview': '', 'error': ''} - formula = data['formula'] - if formula is None: + try: + formula = data['formula'] + except KeyError: result['error'] = "No formula specified." return result try: result['preview'] = chemcalc.render_to_html(formula) except pyparsing.ParseException as p: - result['error'] = "Couldn't parse formula: {0}".format(p) + result['error'] = u"Couldn't parse formula: {0}".format(p.msg) except Exception: # this is unexpected, so log log.warning( @@ -1056,6 +1060,98 @@ class ChemicalEquationInput(InputTypeBase): registry.register(ChemicalEquationInput) +#------------------------------------------------------------------------- + + +class FormulaEquationInput(InputTypeBase): + """ + An input type for entering formula equations. Supports live preview. + + Example: + + + + options: size -- width of the textbox. + """ + + template = "formulaequationinput.html" + tags = ['formulaequationinput'] + + @classmethod + def get_attributes(cls): + """ + Can set size of text field. + """ + return [Attribute('size', '20'), ] + + def _extra_context(self): + """ + TODO (vshnayder): Get rid of 'previewer' once we have a standard way of requiring js to be loaded. + """ + # `reported_status` is basically `status`, except we say 'unanswered' + reported_status = '' + if self.status == 'unsubmitted': + reported_status = 'unanswered' + elif self.status in ('correct', 'incorrect', 'incomplete'): + reported_status = self.status + + return { + 'previewer': '/static/js/capa/src/formula_equation_preview.js', + 'reported_status': reported_status + } + + def handle_ajax(self, dispatch, get): + ''' + Since we only have formcalc preview this input, check to see if it + matches the corresponding dispatch and send it through if it does + ''' + if dispatch == 'preview_formcalc': + return self.preview_formcalc(get) + return {} + + def preview_formcalc(self, get): + """ + Render an preview of a formula or equation. `get` should + contain a key 'formula' with a math expression. + + Returns a json dictionary: + { + 'preview' : '' or '' + 'error' : 'the-error' or '' + 'request_start' :