diff --git a/.gitignore b/.gitignore index b3f7473dc0773c39d0312bb925c9a2b4e46eea16..1a23b36b2ad2ada29fdf2672031f864ca711cdf9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ cms/envs/private.py /nbproject .idea/ .redcar/ +codekit-config.json ### OS X artifacts *.DS_Store @@ -48,14 +49,18 @@ reports/ .prereqs_cache .vagrant/ node_modules +.bundle/ +bin/ ### Static assets pipeline artifacts *.scssc +lms/static/css/ lms/static/sass/*.css lms/static/sass/application.scss lms/static/sass/application-extend1.scss lms/static/sass/application-extend2.scss lms/static/sass/course.scss +cms/static/css/ cms/static/sass/*.css ### Logging artifacts diff --git a/AUTHORS b/AUTHORS index 9326b6781a270c9b7acf0e3441dcea8410346cf0..60ac912d49ede0e5bd1a46ce55a2290a13cb1b81 100644 --- a/AUTHORS +++ b/AUTHORS @@ -97,3 +97,4 @@ Iain Dunning <idunning@mit.edu> Olivier Marquez <oliviermarquez@gmail.com> Florian Dufour <neurolit@gmail.com> Manuel Freire <manuel.freire@fdi.ucm.es> +Daniel Cebrián Robles <danielcebrianr@gmail.com> diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b0239dc86b61f2c4131d563a1ee8200797c87798..9680759f8bf1ad76a9591e352051c21bf74432ff 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,9 +13,36 @@ Blades: Added grading support for LTI module. LTI providers can now grade student's work and send edX scores. OAuth1 based authentication implemented. BLD-384. -LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid - across all runs with the same course name. Old group membership will still work - across runs, but new beta-testers will only be added to a single course run. +LMS: Beta-tester status is now set on a per-course-run basis, rather than being + valid across all runs with the same course name. Old group membership will + still work across runs, but new beta-testers will only be added to a single + course run. + +Blades: Enabled several Video Jasmine tests. BLD-463. + +Studio: Continued modification of Studio pages to follow a RESTful framework. +includes Settings pages, edit page for Subsection and Unit, and interfaces +for updating xblocks (xmodules) and getting their editing HTML. + +Blades: Put 2nd "Hide output" button at top of test box & increase text size for +code response questions. BLD-126. + +Blades: Update the calculator hints tooltip with full information. BLD-400. + +Blades: Fix transcripts 500 error in studio (BLD-530) + +LMS: Add error recovery when a user loads or switches pages in an +inline discussion. + +Blades: Allow multiple strings as the correct answer to a string response +question. BLD-474. + +Blades: a11y - Videos will alert screenreaders when the video is over. + +LMS: Trap focus on the loading element when a user loads more threads +in the forum sidebar to improve accessibility. + +LMS: Add error recovery when a user loads more threads in the forum sidebar. LMS: Add a user-visible alert modal when a forums AJAX request fails. @@ -36,7 +63,8 @@ text like with bold or italics. (BLD-449) LMS: Beta instructor dashboard will only count actively enrolled students for course enrollment numbers. -Blades: Fix speed menu that is not rendered correctly when YouTube is unavailable. (BLD-457). +Blades: Fix speed menu that is not rendered correctly when YouTube is +unavailable. (BLD-457). LMS: Users with is_staff=True no longer have the STAFF label appear on their forum posts. @@ -54,6 +82,9 @@ key in course settings. (BLD-426) Blades: Fix bug when the speed can only be changed when the video is playing. +LMS: The dialogs on the wiki "changes" page are now accessible to screen +readers. Now all wiki pages have been made accessible. (LMS-1337) + LMS: Change bulk email implementation to use less memory, and to better handle duplicate tasks in celery. @@ -70,8 +101,8 @@ client error are correctly passed through to the client. LMS: Improve performance of page load and thread list load for discussion tab -LMS: The wiki markup cheatsheet dialog is now accessible to people with -disabilites. (LMS-1303) +LMS: The wiki markup cheatsheet dialog is now accessible to screen readers. +(LMS-1303) Common: Add skip links for accessibility to CMS and LMS. (LMS-1311) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 5473438571e1fa38f57e747ffaa5471501be8dab..d3293c474e72047a60790b832c8917d7e022b94d 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611 from terrain.steps import reload_the_page -def _is_expected_element_count(css, expected_number): - """ - Returns whether the number of elements found on the page by css locator - the same number that you expected. - """ - return len(world.css_find(css)) == expected_number - - @world.absorb def create_component_instance(step, category, component_type=None, is_advanced=False): """ @@ -47,8 +39,11 @@ def create_component_instance(step, category, component_type=None, is_advanced=F world.wait_for_invisible(component_button_css) click_component_from_menu(category, component_type, is_advanced) - world.wait_for(lambda _: _is_expected_element_count(module_css, - module_count_before + 1)) + expected_count = module_count_before + 1 + world.wait_for( + lambda _: len(world.css_find(module_css)) == expected_count, + timeout=20 + ) @world.absorb diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature index 6f24fba68c6dea32c71355fd34aa75cc145fe332..152da9c3499f40bd9be37862a76f91b88cf324e2 100644 --- a/cms/djangoapps/contentstore/features/course-updates.feature +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -76,3 +76,17 @@ Feature: CMS.Course updates Then I see the handout "/c4x/MITx/999/asset/modified.jpg" And when I reload the page Then I see the handout "/c4x/MITx/999/asset/modified.jpg" + + Scenario: Users cannot save handouts with bad html until edit or update it properly + Given I have opened a new course in Studio + And I go to the course updates page + When I modify the handout to "<p><a href=>[LINK TEXT]</a></p>" + Then I see the handout error text + And I see handout save button disabled + When I edit the handout to "<p><a href='https://www.google.com.pk/'>home</a></p>" + Then I see handout save button re-enabled + When I save handout edit + # Can only do partial text matches because of the quotes with in quotes (and regexp step matching). + Then I see the handout "https://www.google.com.pk/" + And when I reload the page + Then I see the handout "https://www.google.com.pk/" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index da74f5aa4bb3787ea25429e7c9eeefcd032c7d39..b41578c907c85211711355188805fb217bdd535a 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -90,6 +90,35 @@ def check_handout(_step, handout): assert handout in world.css_html(handout_css) +@step(u'I see the handout error text') +def check_handout_error(_step): + handout_error_css = 'div#handout_error' + assert world.css_has_class(handout_error_css, 'is-shown') + + +@step(u'I see handout save button disabled') +def check_handout_error(_step): + handout_save_button = 'form.edit-handouts-form a.save-button' + assert world.css_has_class(handout_save_button, 'is-disabled') + + +@step(u'I edit the handout to "([^"]*)"$') +def edit_handouts(_step, text): + type_in_codemirror(0, text) + + +@step(u'I see handout save button re-enabled') +def check_handout_error(_step): + handout_save_button = 'form.edit-handouts-form a.save-button' + assert not world.css_has_class(handout_save_button, 'is-disabled') + + +@step(u'I save handout edit') +def check_handout_error(_step): + save_css = 'a.save-button' + world.css_click(save_css) + + def change_text(text): type_in_codemirror(0, text) save_css = 'a.save-button' diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature index 54d23d985de2561927ca9b1028d7a28191b12c5f..39399fb20725c9d1c4ca0a900959764523947106 100644 --- a/cms/djangoapps/contentstore/features/static-pages.feature +++ b/cms/djangoapps/contentstore/features/static-pages.feature @@ -9,10 +9,8 @@ Feature: CMS.Static Pages Then I should see a static page named "Empty" Scenario: Users can delete static pages - Given I have opened a new course in Studio - And I go to the static pages page - And I add a new page - And I "delete" the static page + Given I have created a static page + When I "delete" the static page Then I am shown a prompt When I confirm the prompt Then I should not see any static pages @@ -20,9 +18,16 @@ Feature: CMS.Static Pages # Safari won't update the name properly @skip_safari Scenario: Users can edit static pages - Given I have opened a new course in Studio - And I go to the static pages page - And I add a new page + Given I have created a static page When I "edit" the static page And I change the name to "New" Then I should see a static page named "New" + + # Safari won't update the name properly + @skip_safari + Scenario: Users can reorder static pages + Given I have created two different static pages + When I reorder the tabs + Then the tabs are in the reverse order + And I reload the page + Then the tabs are in the reverse order diff --git a/cms/djangoapps/contentstore/features/static-pages.py b/cms/djangoapps/contentstore/features/static-pages.py index 58932ad8e2bcc2f790fb6c94a3063fce459eb4e6..0adb4b1e54fa63ecd33ebefbff083e8e9eb87228 100644 --- a/cms/djangoapps/contentstore/features/static-pages.py +++ b/cms/djangoapps/contentstore/features/static-pages.py @@ -48,3 +48,47 @@ def change_name(step, new_name): world.trigger_event(input_css) save_button = 'a.save-button' world.css_click(save_button) + + +@step(u'I reorder the tabs') +def reorder_tabs(_step): + # For some reason, the drag_and_drop method did not work in this case. + draggables = world.css_find('.drag-handle') + source = draggables.first + target = draggables.last + source.action_chains.click_and_hold(source._element).perform() + source.action_chains.move_to_element_with_offset(target._element, 0, 50).perform() + source.action_chains.release().perform() + + +@step(u'I have created a static page') +def create_static_page(step): + step.given('I have opened a new course in Studio') + step.given('I go to the static pages page') + step.given('I add a new page') + + +@step(u'I have created two different static pages') +def create_two_pages(step): + step.given('I have created a static page') + step.given('I "edit" the static page') + step.given('I change the name to "First"') + step.given('I add a new page') + # Verify order of tabs + _verify_tab_names('First', 'Empty') + + +@step(u'the tabs are in the reverse order') +def tabs_in_reverse_order(step): + _verify_tab_names('Empty', 'First') + + +def _verify_tab_names(first, second): + world.wait_for( + func=lambda _: len(world.css_find('.xmodule_StaticTabModule')) == 2, + timeout=200, + timeout_msg="Timed out waiting for two tabs to be present" + ) + tabs = world.css_find('.xmodule_StaticTabModule') + assert tabs[0].text == first + assert tabs[1].text == second diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index dad9cbb49e01e26c0cab3be81eb393f1a2a67822..159c8a3c5abc96669079a6be06c990869fe4fefa 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -641,6 +641,7 @@ Feature: Video Component Editor And I save changes Then when I view the video it does show the captions + And I see "好 å„ä½åŒå¦" text in the captions And I edit the component And I open tab "Advanced" diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py index 5cbb65dc9d163f4c038a787dee7e14265ce2e2fe..4fac5e5b9340c743df8c8f46cfb4417f9c514271 100644 --- a/cms/djangoapps/contentstore/features/transcripts.py +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -116,6 +116,7 @@ def i_see_status_message(_step, status): world.wait(DELAY) world.wait_for_ajax_complete() + assert not world.css_visible(SELECTORS['error_bar']) assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()]) diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 06bf36747e31629e3b9ee43b593bcceca1103b58..9f08e98f0d24efbf10f8da5bc764cc5954e8fc14 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -53,30 +53,33 @@ Feature: CMS.Video Component Then Captions become "invisible" # 8 - Scenario: Open captions never become invisible - Given I have created a Video component with subtitles - And Make sure captions are open - Then Captions are "visible" - And I hover over button "CC" - Then Captions are "visible" - And I hover over button "volume" - Then Captions are "visible" + # Disabled 11/26 due to flakiness in master + #Scenario: Open captions never become invisible + # Given I have created a Video component with subtitles + # And Make sure captions are open + # Then Captions are "visible" + # And I hover over button "CC" + # Then Captions are "visible" + # And I hover over button "volume" + # Then Captions are "visible" # 9 - Scenario: Closed captions are invisible when mouse doesn't hover on CC button - Given I have created a Video component with subtitles - And Make sure captions are closed - Then Captions become "invisible" - And I hover over button "volume" - Then Captions are "invisible" + # Disabled 11/26 due to flakiness in master + #Scenario: Closed captions are invisible when mouse doesn't hover on CC button + # Given I have created a Video component with subtitles + # And Make sure captions are closed + # Then Captions become "invisible" + # And I hover over button "volume" + # Then Captions are "invisible" # 10 - Scenario: When enter key is pressed on a caption shows an outline around it - Given I have created a Video component with subtitles - And Make sure captions are opened - Then I focus on caption line with data-index "0" - Then I press "enter" button on caption line with data-index "0" - And I see caption line with data-index "0" has class "focused" + # Disabled 11/26 due to flakiness in master + #Scenario: When enter key is pressed on a caption shows an outline around it + # Given I have created a Video component with subtitles + # And Make sure captions are opened + # Then I focus on caption line with data-index "0" + # Then I press "enter" button on caption line with data-index "0" + # And I see caption line with data-index "0" has class "focused" # 11 Scenario: When start end end times are specified, a range on slider is shown diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 5408c482908c3cfb55740c6a623318080c1cc0c8..c97dba10b9295cedd4c1a7c420093065ab53c8b1 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -181,7 +181,7 @@ def click_on_the_caption(_step, index): @step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$') def caption_line_has_class(_step, index, className): SELECTOR = ".subtitles > li[data-index='{index}']".format(index=int(index.strip())) - world.css_has_class(SELECTOR, className.strip()) + assert world.css_has_class(SELECTOR, className.strip()) @step('I see a range on slider$') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 58cf3be70b0f9ebbedfdc5b535309b67903fae29..0aaf2dfb299244f6d06b53a59dc4565790f7ad05 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,6 +1,5 @@ #pylint: disable=E1101 -import json import shutil import mock @@ -15,6 +14,7 @@ from fs.osfs import OSFS import copy from json import loads from datetime import timedelta +from django.test import TestCase from django.contrib.auth.models import User from django.dispatch import Signal @@ -42,6 +42,7 @@ from xmodule.capa_module import CapaDescriptor from xmodule.course_module import CourseDescriptor from xmodule.seq_module import SequenceDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.locator import BlockUsageLocator from contentstore.views.component import ADVANCED_COMPONENT_TYPES from xmodule.exceptions import NotFoundError @@ -53,6 +54,7 @@ from pytz import UTC from uuid import uuid4 from pymongo import MongoClient from student.models import CourseEnrollment +import re from contentstore.utils import delete_course_and_groups from xmodule.modulestore.django import loc_mapper @@ -132,9 +134,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # just pick one vertical descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] - - resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + locator = loc_mapper().translate_location(course.location.course_id, descriptor.location, False, True) + resp = self.client.get_html(locator.url_reverse('unit')) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when video transcripts no longer require IDs. + # _test_no_locations(self, resp) for expected in expected_types: self.assertIn(expected, resp.content) @@ -152,25 +156,24 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_malformed_edit_unit_request(self): store = modulestore('direct') - import_from_xml(store, 'common/test/data/', ['simple']) + _, course_items = import_from_xml(store, 'common/test/data/', ['simple']) # just pick one vertical descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] location = descriptor.location.replace(name='.' + descriptor.location.name) + locator = loc_mapper().translate_location(course_items[0].location.course_id, location, False, True) - resp = self.client.get_html(reverse('edit_unit', kwargs={'location': location.url()})) + resp = self.client.get_html(locator.url_reverse('unit')) self.assertEqual(resp.status_code, 400) + _test_no_locations(self, resp, status_code=400) def check_edit_unit(self, test_course_name): - import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) + _, course_items = import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) - for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): - print "Checking ", descriptor.location.url() - print descriptor.__class__, descriptor.location - resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) - self.assertEqual(resp.status_code, 200) + items = modulestore().get_items(Location('i4x', 'edX', test_course_name, 'vertical', None, None)) + self._check_verticals(items, course_items[0].location.course_id) - def lockAnAsset(self, content_store, course_location): + def _lock_an_asset(self, content_store, course_location): """ Lock an arbitrary asset in the course :param course_location: @@ -398,24 +401,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(course.tabs, expected_tabs) def test_static_tab_reordering(self): - def get_tab_locator(tab): - tab_location = 'i4x://MITx/999/static_tab/{0}'.format(tab['url_slug']) - return unicode(loc_mapper().translate_location( - course.location.course_id, Location(tab_location), False, True - )) - - module_store = modulestore('direct') - locator = _course_factory_create_course() - course_location = loc_mapper().translate_locator_to_location(locator) - - ItemFactory.create( - parent_location=course_location, - category="static_tab", - display_name="Static_1") - ItemFactory.create( - parent_location=course_location, - category="static_tab", - display_name="Static_2") + module_store, course_location, new_location = self._create_static_tabs() course = module_store.get_item(course_location) @@ -423,9 +409,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): reverse_tabs = [] for tab in course.tabs: if tab['type'] == 'static_tab': - reverse_tabs.insert(0, get_tab_locator(tab)) + reverse_tabs.insert(0, unicode(self._get_tab_locator(course, tab))) - self.client.ajax_post(reverse('reorder_static_tabs'), {'tabs': reverse_tabs}) + self.client.ajax_post(new_location.url_reverse('tabs'), {'tabs': reverse_tabs}) course = module_store.get_item(course_location) @@ -433,10 +419,57 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course_tabs = [] for tab in course.tabs: if tab['type'] == 'static_tab': - course_tabs.append(get_tab_locator(tab)) + course_tabs.append(unicode(self._get_tab_locator(course, tab))) self.assertEqual(reverse_tabs, course_tabs) + def test_static_tab_deletion(self): + module_store, course_location, _ = self._create_static_tabs() + + course = module_store.get_item(course_location) + num_tabs = len(course.tabs) + last_tab = course.tabs[num_tabs - 1] + url_slug = last_tab['url_slug'] + delete_url = self._get_tab_locator(course, last_tab).url_reverse('xblock') + + self.client.delete(delete_url) + + course = module_store.get_item(course_location) + self.assertEqual(num_tabs - 1, len(course.tabs)) + + def tab_matches(tab): + """ Checks if the tab matches the one we deleted """ + return tab['type'] == 'static_tab' and tab['url_slug'] == url_slug + + tab_found = any(tab_matches(tab) for tab in course.tabs) + + self.assertFalse(tab_found, "tab should have been deleted") + + def _get_tab_locator(self, course, tab): + """ Returns the locator for a given tab. """ + tab_location = 'i4x://MITx/999/static_tab/{0}'.format(tab['url_slug']) + return loc_mapper().translate_location( + course.location.course_id, Location(tab_location), False, True + ) + + def _create_static_tabs(self): + """ Creates two static tabs in a dummy course. """ + module_store = modulestore('direct') + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + new_location = loc_mapper().translate_location(course_location.course_id, course_location, False, True) + + ItemFactory.create( + parent_location=course_location, + category="static_tab", + display_name="Static_1") + ItemFactory.create( + parent_location=course_location, + category="static_tab", + display_name="Static_2") + + return module_store, course_location, new_location + def test_import_polls(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['toy']) @@ -454,31 +487,38 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): @override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*']) def test_module_preview_in_whitelist(self): - ''' + """ Tests the ajax callback to render an XModule - ''' - direct_store = modulestore('direct') - import_from_xml(direct_store, 'common/test/data/', ['toy']) - - # also try a custom response which will trigger the 'is this course in whitelist' logic - problem_module_location = Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]) - url = reverse('preview_component', kwargs={'location': problem_module_location.url()}) - resp = self.client.get_html(url) - self.assertEqual(resp.status_code, 200) + """ + resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None])) + # These are the data-ids of the xblocks contained in the vertical. + # Ultimately, these must be converted to new locators. + self.assertContains(resp, 'i4x://edX/toy/video/sample_video') + self.assertContains(resp, 'i4x://edX/toy/video/separate_file_video') + self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time') + self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2') def test_video_module_caption_asset_path(self): - ''' + """ This verifies that a video caption url is as we expect it to be - ''' + """ + resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None])) + self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"') + + def _test_preview(self, location): + """ Preview test case. """ direct_store = modulestore('direct') - import_from_xml(direct_store, 'common/test/data/', ['toy']) + _, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy']) # also try a custom response which will trigger the 'is this course in whitelist' logic - video_module_location = Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None]) - url = reverse('preview_component', kwargs={'location': video_module_location.url()}) - resp = self.client.get_html(url) + locator = loc_mapper().translate_location( + course_items[0].location.course_id, location, False, True + ) + resp = self.client.get_html(locator.url_reverse('xblock')) self.assertEqual(resp.status_code, 200) - self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"') + # TODO: uncomment when preview no longer has locations being returned. + # _test_no_locations(self, resp) + return resp def test_delete(self): direct_store = modulestore('direct') @@ -617,7 +657,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False) self.assertIsNotNone(thumbnail) - def _delete_asset_in_course (self): + def _delete_asset_in_course(self): """ Helper method for: 1) importing course from xml @@ -836,6 +876,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_bad_contentstore_request(self): resp = self.client.get_html('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) + _test_no_locations(self, resp, 400) def test_rewrite_nonportable_links_on_import(self): module_store = modulestore('direct') @@ -955,7 +996,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertIn(private_location_no_draft.url(), sequential.children) - locked_asset = self.lockAnAsset(content_store, location) + locked_asset = self._lock_an_asset(content_store, location) locked_asset_attrs = content_store.get_attrs(locked_asset) # the later import will reupload del locked_asset_attrs['uploadDate'] @@ -1010,7 +1051,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): shutil.rmtree(root_dir) def check_import(self, module_store, root_dir, draft_store, content_store, stub_location, course_location, - locked_asset, locked_asset_attrs): + locked_asset, locked_asset_attrs): # reimport import_from_xml( module_store, root_dir, ['test_export'], draft_store=draft_store, @@ -1018,15 +1059,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): target_location_namespace=course_location ) + # Unit test fails in Jenkins without this. + loc_mapper().translate_location(course_location.course_id, course_location, False, True) + items = module_store.get_items(stub_location.replace(category='vertical', name=None)) - self.assertGreater(len(items), 0) - for descriptor in items: - # don't try to look at private verticals. Right now we're running - # the service in non-draft aware - if getattr(descriptor, 'is_draft', False): - print "Checking {0}....".format(descriptor.location.url()) - resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) - self.assertEqual(resp.status_code, 200) + self._check_verticals(items, course_location.course_id) # verify that we have the content in the draft store as well vertical = draft_store.get_item( @@ -1210,7 +1247,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): handouts_locator = loc_mapper().translate_location('edX/toy/2012_Fall', handout_location) # get module info (json) - resp = self.client.get(handouts_locator.url_reverse('/xblock', '')) + resp = self.client.get(handouts_locator.url_reverse('/xblock')) # make sure we got a successful response self.assertEqual(resp.status_code, 200) @@ -1309,6 +1346,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): items = module_store.get_items(stub_location) self.assertEqual(len(items), 1) + def _check_verticals(self, items, course_id): + """ Test getting the editing HTML for each vertical. """ + # Assert is here to make sure that the course being tested actually has verticals (units) to check. + self.assertGreater(len(items), 0) + for descriptor in items: + unit_locator = loc_mapper().translate_location(course_id, descriptor.location, False, True) + resp = self.client.get_html(unit_locator.url_reverse('unit')) + self.assertEqual(resp.status_code, 200) + # TODO: uncomment when video transcripts no longer require IDs. + # _test_no_locations(self, resp) + @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE) class ContentStoreTest(ModuleStoreTestCase): @@ -1387,7 +1435,7 @@ class ContentStoreTest(ModuleStoreTestCase): second_course_data = self.assert_created_course(number_suffix=uuid4().hex) # unseed the forums for the first course - course_id =_get_course_id(test_course_data) + course_id = _get_course_id(test_course_data) delete_course_and_groups(course_id, commit=True) self.assertFalse(are_permissions_roles_seeded(course_id)) @@ -1503,6 +1551,7 @@ class ContentStoreTest(ModuleStoreTestCase): status_code=200, html=True ) + _test_no_locations(self, resp) def test_course_factory(self): """Test that the course factory works correctly.""" @@ -1525,6 +1574,7 @@ class ContentStoreTest(ModuleStoreTestCase): status_code=200, html=True ) + _test_no_locations(self, resp) def test_course_overview_view_with_course(self): """Test viewing the course overview page with an existing course""" @@ -1550,12 +1600,13 @@ class ContentStoreTest(ModuleStoreTestCase): } resp = self.client.ajax_post('/xblock', section_data) + _test_no_locations(self, resp, html=False) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertRegexpMatches( - data['id'], - r"^i4x://MITx/999/chapter/([0-9]|[a-f]){32}$" + data['locator'], + r"^MITx.999.Robot_Super_Course/branch/draft/block/chapter([0-9]|[a-f]){3}$" ) def test_capa_module(self): @@ -1571,7 +1622,7 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) payload = parse_json(resp) - problem_loc = Location(payload['id']) + problem_loc = loc_mapper().translate_locator_to_location(BlockUsageLocator(payload['locator'])) problem = get_modulestore(problem_loc).get_item(problem_loc) # should be a CapaDescriptor self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") @@ -1584,6 +1635,13 @@ class ContentStoreTest(ModuleStoreTestCase): Import and walk through some common URL endpoints. This just verifies non-500 and no other correct behavior, so it is not a deep test """ + def test_get_html(page): + # Helper function for getting HTML for a page in Studio and + # checking that it does not error. + resp = self.client.get_html(new_location.url_reverse(page)) + self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]) new_location = loc_mapper().translate_location(loc.course_id, loc, False, True) @@ -1593,55 +1651,39 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertContains(resp, 'Chapter 2') # go to various pages - - # import page - resp = self.client.get_html(new_location.url_reverse('import/', '')) - self.assertEqual(resp.status_code, 200) - - # export page - resp = self.client.get_html(new_location.url_reverse('export/', '')) - self.assertEqual(resp.status_code, 200) - - # course team - url = new_location.url_reverse('course_team/', '') - resp = self.client.get_html(url) - self.assertEqual(resp.status_code, 200) - - # course info - resp = self.client.get(new_location.url_reverse('course_info')) - self.assertEqual(resp.status_code, 200) - - # settings_details - resp = self.client.get(reverse('settings_details', - kwargs={'org': loc.org, - 'course': loc.course, - 'name': loc.name})) - self.assertEqual(resp.status_code, 200) - - # settings_details - resp = self.client.get(reverse('settings_grading', - kwargs={'org': loc.org, - 'course': loc.course, - 'name': loc.name})) - self.assertEqual(resp.status_code, 200) - - # assets_handler (HTML for full page content) - url = new_location.url_reverse('assets/', '') - resp = self.client.get_html(url) + test_get_html('import') + test_get_html('export') + test_get_html('course_team') + test_get_html('course_info') + test_get_html('checklists') + test_get_html('assets') + test_get_html('tabs') + test_get_html('settings/details') + test_get_html('settings/grading') + test_get_html('settings/advanced') + + # textbook index + resp = self.client.get_html(reverse('textbook_index', + kwargs={'org': loc.org, + 'course': loc.course, + 'name': loc.name})) self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) # go look at a subsection page subsection_location = loc.replace(category='sequential', name='test_sequence') - resp = self.client.get_html( - reverse('edit_subsection', kwargs={'location': subsection_location.url()}) - ) + subsection_locator = loc_mapper().translate_location(loc.course_id, subsection_location, False, True) + resp = self.client.get_html(subsection_locator.url_reverse('subsection')) self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) # go look at the Edit page unit_location = loc.replace(category='vertical', name='test_vertical') - resp = self.client.get_html( - reverse('edit_unit', kwargs={'location': unit_location.url()})) + unit_locator = loc_mapper().translate_location(loc.course_id, unit_location, False, True) + resp = self.client.get_html(unit_locator.url_reverse('unit')) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when video transcripts no longer require IDs. + # _test_no_locations(self, resp) def delete_item(category, name): """ Helper method for testing the deletion of an xblock item. """ @@ -1649,6 +1691,7 @@ class ContentStoreTest(ModuleStoreTestCase): del_location = loc_mapper().translate_location(loc.course_id, del_loc, False, True) resp = self.client.delete(del_location.url_reverse('xblock')) self.assertEqual(resp.status_code, 204) + _test_no_locations(self, resp, status_code=204, html=False) # delete a component delete_item(category='html', name='test_html') @@ -1848,7 +1891,9 @@ class ContentStoreTest(ModuleStoreTestCase): Show the course overview page. """ new_location = loc_mapper().translate_location(location.course_id, location, False, True) - return self.client.get_html(new_location.url_reverse('course/', '')) + resp = self.client.get_html(new_location.url_reverse('course/', '')) + _test_no_locations(self, resp) + return resp @override_settings(MODULESTORE=TEST_MODULESTORE) @@ -1915,6 +1960,32 @@ class MetadataSaveTestCase(ModuleStoreTestCase): pass +class EntryPageTestCase(TestCase): + """ + Tests entry pages that aren't specific to a course. + """ + def setUp(self): + self.client = AjaxEnabledTestClient() + + def _test_page(self, page, status_code=200): + resp = self.client.get_html(page) + self.assertEqual(resp.status_code, status_code) + _test_no_locations(self, resp, status_code) + + def test_how_it_works(self): + self._test_page("/howitworks") + + def test_signup(self): + self._test_page("/signup") + + def test_login(self): + self._test_page("/signin") + + def test_logout(self): + # Logout redirects. + self._test_page("/logout", 302) + + def _create_course(test, course_data): """ Creates a course via an AJAX request and verifies the URL returned in the response. @@ -1926,7 +1997,7 @@ def _create_course(test, course_data): test.assertEqual(response.status_code, 200) data = parse_json(response) test.assertNotIn('ErrMsg', data) - test.assertEqual(data['url'], new_location.url_reverse("course/", "")) + test.assertEqual(data['url'], new_location.url_reverse("course")) def _course_factory_create_course(): @@ -1940,3 +2011,19 @@ def _course_factory_create_course(): def _get_course_id(test_course_data): """Returns the course ID (org/number/run).""" return "{org}/{number}/{run}".format(**test_course_data) + + +def _test_no_locations(test, resp, status_code=200, html=True): + """ + Verifies that "i4x", which appears in old locations, but not + new locators, does not appear in the HTML response output. + Used to verify that database refactoring is complete. + """ + test.assertNotContains(resp, 'i4x', status_code=status_code, html=html) + if html: + # For HTML pages, it is nice to call the method with html=True because + # it checks that the HTML properly parses. However, it won't find i4x usages + # in JavaScript blocks. + content = resp.content + hits = len(re.findall(r"(?<!jump_to/)i4x://", content)) + test.assertEqual(hits, 0, "i4x found outside of LMS jump-to links") diff --git a/cms/djangoapps/contentstore/tests/test_course_index.py b/cms/djangoapps/contentstore/tests/test_course_index.py index 1fcdb6f040b92b7b942f50dec6fec149227e042a..4c4c736fb7191db40063891edc9d736369074bf2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/tests/test_course_index.py @@ -3,7 +3,6 @@ Unit tests for getting the list of courses and the course outline. """ import json import lxml -from django.core.urlresolvers import reverse from contentstore.tests.utils import CourseTestCase from xmodule.modulestore.django import loc_mapper @@ -60,8 +59,7 @@ class TestCourseIndex(CourseTestCase): """ Test the error conditions for the access """ - locator = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True) - outline_url = locator.url_reverse('course/', '') + outline_url = self.course_locator.url_reverse('course/', '') # register a non-staff member and try to delete the course branch non_staff_client, _ = self.createNonStaffAuthedUserClient() response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json') diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index d1cbcde4d0d9d9e6c84819e8448bfdb78654ee65..792b28fe4da66e9f3861be1e53775b3e73de57d0 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -6,14 +6,12 @@ import json import copy import mock -from django.core.urlresolvers import reverse from django.utils.timezone import UTC from django.test.utils import override_settings -from xmodule.modulestore import Location from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) from models.settings.course_grading import CourseGradingModel -from contentstore.utils import get_modulestore +from contentstore.utils import get_modulestore, EXTRA_TAB_PANELS from xmodule.modulestore.tests.factories import CourseFactory @@ -21,6 +19,8 @@ from models.settings.course_metadata import CourseMetadata from xmodule.fields import Date from .utils import CourseTestCase +from xmodule.modulestore.django import loc_mapper, modulestore +from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY class CourseDetailsTestCase(CourseTestCase): @@ -28,8 +28,10 @@ class CourseDetailsTestCase(CourseTestCase): Tests the first course settings page (course dates, overview, etc.). """ def test_virgin_fetch(self): - details = CourseDetails.fetch(self.course.location) - self.assertEqual(details.course_location, self.course.location, "Location not copied into") + details = CourseDetails.fetch(self.course_locator) + self.assertEqual(details.org, self.course.location.org, "Org not copied into") + self.assertEqual(details.course_id, self.course.location.course, "Course_id not copied into") + self.assertEqual(details.run, self.course.location.name, "Course name not copied into") self.assertEqual(details.course_image_name, self.course.course_image) self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) @@ -40,10 +42,9 @@ class CourseDetailsTestCase(CourseTestCase): self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) def test_encoder(self): - details = CourseDetails.fetch(self.course.location) + details = CourseDetails.fetch(self.course_locator) jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) - self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=") self.assertEqual(jsondetails['course_image_name'], self.course.course_image) self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") @@ -57,7 +58,6 @@ class CourseDetailsTestCase(CourseTestCase): Test the encoder out of its original constrained purpose to see if it functions for general use """ details = { - 'location': Location(['tag', 'org', 'course', 'category', 'name']), 'number': 1, 'string': 'string', 'datetime': datetime.datetime.now(UTC()) @@ -65,59 +65,49 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) - self.assertIn('location', jsondetails) - self.assertIn('org', jsondetails['location']) - self.assertEquals('org', jsondetails['location'][1]) self.assertEquals(1, jsondetails['number']) self.assertEqual(jsondetails['string'], 'string') def test_update_and_fetch(self): - jsondetails = CourseDetails.fetch(self.course.location) + jsondetails = CourseDetails.fetch(self.course_locator) jsondetails.syllabus = "<a href='foo'>bar</a>" # encode - decode to convert date fields and other data which changes form self.assertEqual( - CourseDetails.update_from_json(jsondetails.__dict__).syllabus, + CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).syllabus, jsondetails.syllabus, "After set syllabus" ) jsondetails.overview = "Overview" self.assertEqual( - CourseDetails.update_from_json(jsondetails.__dict__).overview, + CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).overview, jsondetails.overview, "After set overview" ) jsondetails.intro_video = "intro_video" self.assertEqual( - CourseDetails.update_from_json(jsondetails.__dict__).intro_video, + CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).intro_video, jsondetails.intro_video, "After set intro_video" ) jsondetails.effort = "effort" self.assertEqual( - CourseDetails.update_from_json(jsondetails.__dict__).effort, + CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).effort, jsondetails.effort, "After set effort" ) jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC()) self.assertEqual( - CourseDetails.update_from_json(jsondetails.__dict__).start_date, + CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).start_date, jsondetails.start_date ) jsondetails.course_image_name = "an_image.jpg" self.assertEqual( - CourseDetails.update_from_json(jsondetails.__dict__).course_image_name, + CourseDetails.update_from_json(self.course_locator, jsondetails.__dict__).course_image_name, jsondetails.course_image_name ) @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) def test_marketing_site_fetch(self): - settings_details_url = reverse( - 'settings_details', - kwargs={ - 'org': self.course.location.org, - 'name': self.course.location.name, - 'course': self.course.location.course - } - ) + settings_details_url = self.course_locator.url_reverse('settings/details/') with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): - response = self.client.get(settings_details_url) + response = self.client.get_html(settings_details_url) self.assertNotContains(response, "Course Summary Page") self.assertNotContains(response, "Send a note to students via email") self.assertContains(response, "course summary page will not be viewable") @@ -135,17 +125,10 @@ class CourseDetailsTestCase(CourseTestCase): self.assertNotContains(response, "Requirements") def test_regular_site_fetch(self): - settings_details_url = reverse( - 'settings_details', - kwargs={ - 'org': self.course.location.org, - 'name': self.course.location.name, - 'course': self.course.location.course - } - ) + settings_details_url = self.course_locator.url_reverse('settings/details/') with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): - response = self.client.get(settings_details_url) + response = self.client.get_html(settings_details_url) self.assertContains(response, "Course Summary Page") self.assertContains(response, "Send a note to students via email") self.assertNotContains(response, "course summary page will not be viewable") @@ -168,10 +151,12 @@ class CourseDetailsViewTest(CourseTestCase): Tests for modifying content on the first course settings page (course dates, overview, etc.). """ def alter_field(self, url, details, field, val): + """ + Change the one field to the given value and then invoke the update post to see if it worked. + """ setattr(details, field, val) # Need to partially serialize payload b/c the mock doesn't handle it correctly payload = copy.copy(details.__dict__) - payload['course_location'] = details.course_location.url() payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date) payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date) payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start) @@ -181,16 +166,17 @@ class CourseDetailsViewTest(CourseTestCase): @staticmethod def convert_datetime_to_iso(datetime_obj): + """ + Use the xblock serializer to convert the datetime + """ return Date().to_json(datetime_obj) def test_update_and_fetch(self): - loc = self.course.location - details = CourseDetails.fetch(loc) + details = CourseDetails.fetch(self.course_locator) # resp s/b json from here on - url = reverse('course_settings', kwargs={'org': loc.org, 'course': loc.course, - 'name': loc.name, 'section': 'details'}) - resp = self.client.get(url) + url = self.course_locator.url_reverse('settings/details/') + resp = self.client.get_json(url) self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get") utc = UTC() @@ -206,6 +192,9 @@ class CourseDetailsViewTest(CourseTestCase): self.alter_field(url, details, 'course_image_name', "course_image_name") def compare_details_with_encoding(self, encoded, details, context): + """ + compare all of the fields of the before and after dicts + """ self.compare_date_fields(details, encoded, context, 'start_date') self.compare_date_fields(details, encoded, context, 'end_date') self.compare_date_fields(details, encoded, context, 'enrollment_start') @@ -216,6 +205,9 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==") def compare_date_fields(self, details, encoded, context, field): + """ + Compare the given date fields between the before and after doing json deserialization + """ if details[field] is not None: date = Date() if field in encoded and encoded[field] is not None: @@ -234,142 +226,191 @@ class CourseGradingTest(CourseTestCase): Tests for the course settings grading page. """ def test_initial_grader(self): - descriptor = get_modulestore(self.course.location).get_item(self.course.location) - test_grader = CourseGradingModel(descriptor) - # ??? How much should this test bake in expectations about defaults and thus fail if defaults change? - self.assertEqual(self.course.location, test_grader.course_location, "Course locations") - self.assertIsNotNone(test_grader.graders, "No graders") - self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") + test_grader = CourseGradingModel(self.course) + self.assertIsNotNone(test_grader.graders) + self.assertIsNotNone(test_grader.grade_cutoffs) def test_fetch_grader(self): - test_grader = CourseGradingModel.fetch(self.course.location.url()) - self.assertEqual(self.course.location, test_grader.course_location, "Course locations") - self.assertIsNotNone(test_grader.graders, "No graders") - self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") - - test_grader = CourseGradingModel.fetch(self.course.location) - self.assertEqual(self.course.location, test_grader.course_location, "Course locations") + test_grader = CourseGradingModel.fetch(self.course_locator) self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") for i, grader in enumerate(test_grader.graders): - subgrader = CourseGradingModel.fetch_grader(self.course.location, i) + subgrader = CourseGradingModel.fetch_grader(self.course_locator, i) self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal") - subgrader = CourseGradingModel.fetch_grader(self.course.location.list(), 0) - self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list") - - def test_fetch_cutoffs(self): - test_grader = CourseGradingModel.fetch_cutoffs(self.course.location) - # ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think) - self.assertIsNotNone(test_grader, "No cutoffs via fetch") - - test_grader = CourseGradingModel.fetch_cutoffs(self.course.location.url()) - self.assertIsNotNone(test_grader, "No cutoffs via fetch with url") - - def test_fetch_grace(self): - test_grader = CourseGradingModel.fetch_grace_period(self.course.location) - # almost a worthless test - self.assertIn('grace_period', test_grader, "No grace via fetch") - - test_grader = CourseGradingModel.fetch_grace_period(self.course.location.url()) - self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url") - def test_update_from_json(self): - test_grader = CourseGradingModel.fetch(self.course.location) - altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) + test_grader = CourseGradingModel.fetch(self.course_locator) + altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update") test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2 - altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) + altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2") test_grader.grade_cutoffs['D'] = 0.3 - altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) + altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0} - altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) + altered_grader = CourseGradingModel.update_from_json(self.course_locator, test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") def test_update_grader_from_json(self): - test_grader = CourseGradingModel.fetch(self.course.location) - altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) + test_grader = CourseGradingModel.fetch(self.course_locator) + altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update") test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2 - altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) + altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2") test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1 - altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) + altered_grader = CourseGradingModel.update_grader_from_json(self.course_locator, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") def test_update_cutoffs_from_json(self): - test_grader = CourseGradingModel.fetch(self.course.location) - CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + test_grader = CourseGradingModel.fetch(self.course_locator) + CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs) # Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json # simply returns the cutoffs you send into it, rather than returning the db contents. - altered_grader = CourseGradingModel.fetch(self.course.location) + altered_grader = CourseGradingModel.fetch(self.course_locator) self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update") test_grader.grade_cutoffs['D'] = 0.3 - CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) - altered_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs) + altered_grader = CourseGradingModel.fetch(self.course_locator) self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D") test_grader.grade_cutoffs['Pass'] = 0.75 - CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) - altered_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_cutoffs_from_json(self.course_locator, test_grader.grade_cutoffs) + altered_grader = CourseGradingModel.fetch(self.course_locator) self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'") def test_delete_grace_period(self): - test_grader = CourseGradingModel.fetch(self.course.location) - CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period) + test_grader = CourseGradingModel.fetch(self.course_locator) + CourseGradingModel.update_grace_period_from_json(self.course_locator, test_grader.grace_period) # update_grace_period_from_json doesn't return anything, so query the db for its contents. - altered_grader = CourseGradingModel.fetch(self.course.location) + altered_grader = CourseGradingModel.fetch(self.course_locator) self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update") test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30} - CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period) - altered_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_grace_period_from_json(self.course_locator, test_grader.grace_period) + altered_grader = CourseGradingModel.fetch(self.course_locator) self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period") test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0} # Now delete the grace period - CourseGradingModel.delete_grace_period(test_grader.course_location) + CourseGradingModel.delete_grace_period(self.course_locator) # update_grace_period_from_json doesn't return anything, so query the db for its contents. - altered_grader = CourseGradingModel.fetch(self.course.location) + altered_grader = CourseGradingModel.fetch(self.course_locator) # Once deleted, the grace period should simply be None self.assertEqual(None, altered_grader.grace_period, "Delete grace period") def test_update_section_grader_type(self): # Get the descriptor and the section_grader_type and assert they are the default values descriptor = get_modulestore(self.course.location).get_item(self.course.location) - section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator) self.assertEqual('Not Graded', section_grader_type['graderType']) self.assertEqual(None, descriptor.format) self.assertEqual(False, descriptor.graded) # Change the default grader type to Homework, which should also mark the section as graded - CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'}) + CourseGradingModel.update_section_grader_type(self.course, 'Homework') descriptor = get_modulestore(self.course.location).get_item(self.course.location) - section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator) self.assertEqual('Homework', section_grader_type['graderType']) self.assertEqual('Homework', descriptor.format) self.assertEqual(True, descriptor.graded) # Change the grader type back to Not Graded, which should also unmark the section as graded - CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'}) + CourseGradingModel.update_section_grader_type(self.course, 'Not Graded') descriptor = get_modulestore(self.course.location).get_item(self.course.location) - section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course_locator) self.assertEqual('Not Graded', section_grader_type['graderType']) self.assertEqual(None, descriptor.format) self.assertEqual(False, descriptor.graded) + def test_get_set_grader_types_ajax(self): + """ + Test configuring the graders via ajax calls + """ + grader_type_url_base = self.course_locator.url_reverse('settings/grading') + # test get whole + response = self.client.get_json(grader_type_url_base) + whole_model = json.loads(response.content) + self.assertIn('graders', whole_model) + self.assertIn('grade_cutoffs', whole_model) + self.assertIn('grace_period', whole_model) + # test post/update whole + whole_model['grace_period'] = {'hours': 1, 'minutes': 30, 'seconds': 0} + response = self.client.ajax_post(grader_type_url_base, whole_model) + self.assertEqual(200, response.status_code) + response = self.client.get_json(grader_type_url_base) + whole_model = json.loads(response.content) + self.assertEqual(whole_model['grace_period'], {'hours': 1, 'minutes': 30, 'seconds': 0}) + # test get one grader + self.assertGreater(len(whole_model['graders']), 1) # ensure test will make sense + response = self.client.get_json(grader_type_url_base + '/1') + grader_sample = json.loads(response.content) + self.assertEqual(grader_sample, whole_model['graders'][1]) + # test add grader + new_grader = { + "type": "Extra Credit", + "min_count": 1, + "drop_count": 2, + "short_label": None, + "weight": 15, + } + response = self.client.ajax_post( + '{}/{}'.format(grader_type_url_base, len(whole_model['graders'])), + new_grader + ) + self.assertEqual(200, response.status_code) + grader_sample = json.loads(response.content) + new_grader['id'] = len(whole_model['graders']) + self.assertEqual(new_grader, grader_sample) + # test delete grader + response = self.client.delete(grader_type_url_base + '/1', HTTP_ACCEPT="application/json") + self.assertEqual(204, response.status_code) + response = self.client.get_json(grader_type_url_base) + updated_model = json.loads(response.content) + new_grader['id'] -= 1 # one fewer and the id mutates + self.assertIn(new_grader, updated_model['graders']) + self.assertNotIn(whole_model['graders'][1], updated_model['graders']) + + def setup_test_set_get_section_grader_ajax(self): + """ + Populate the course, grab a section, get the url for the assignment type access + """ + self.populateCourse() + sections = get_modulestore(self.course_location).get_items( + self.course_location.replace(category="sequential", name=None) + ) + # see if test makes sense + self.assertGreater(len(sections), 0, "No sections found") + section = sections[0] # just take the first one + section_locator = loc_mapper().translate_location(self.course_location.course_id, section.location, False, True) + return section_locator.url_reverse('xblock') + + def test_set_get_section_grader_ajax(self): + """ + Test setting and getting section grades via the grade as url + """ + grade_type_url = self.setup_test_set_get_section_grader_ajax() + response = self.client.ajax_post(grade_type_url, {'graderType': u'Homework'}) + self.assertEqual(200, response.status_code) + response = self.client.get_json(grade_type_url + '?fields=graderType') + self.assertEqual(json.loads(response.content).get('graderType'), u'Homework') + # and unset + response = self.client.ajax_post(grade_type_url, {'graderType': u'Not Graded'}) + self.assertEqual(200, response.status_code) + response = self.client.get_json(grade_type_url + '?fields=graderType') + self.assertEqual(json.loads(response.content).get('graderType'), u'Not Graded') + class CourseMetadataEditingTest(CourseTestCase): """ @@ -377,15 +418,19 @@ class CourseMetadataEditingTest(CourseTestCase): """ def setUp(self): CourseTestCase.setUp(self) - CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') - self.fullcourse_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + self.fullcourse = CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + self.course_setting_url = self.course_locator.url_reverse('settings/advanced') + self.fullcourse_setting_url = loc_mapper().translate_location( + self.fullcourse.location.course_id, + self.fullcourse.location, False, True + ).url_reverse('settings/advanced') def test_fetch_initial_fields(self): - test_model = CourseMetadata.fetch(self.course.location) + test_model = CourseMetadata.fetch(self.course) self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") - test_model = CourseMetadata.fetch(self.fullcourse_location) + test_model = CourseMetadata.fetch(self.fullcourse) self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') self.assertIn('display_name', test_model, 'full missing editable metadata field') self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") @@ -394,17 +439,17 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertIn('xqa_key', test_model, 'xqa_key field ') def test_update_from_json(self): - test_model = CourseMetadata.update_from_json(self.course.location, { + test_model = CourseMetadata.update_from_json(self.course, { "advertised_start": "start A", - "testcenter_info": {"c": "test"}, "days_early_for_beta": 2 }) self.update_check(test_model) # try fresh fetch to ensure persistence - test_model = CourseMetadata.fetch(self.course.location) + fresh = modulestore().get_item(self.course_location) + test_model = CourseMetadata.fetch(fresh) self.update_check(test_model) # now change some of the existing metadata - test_model = CourseMetadata.update_from_json(self.course.location, { + test_model = CourseMetadata.update_from_json(fresh, { "advertised_start": "start B", "display_name": "jolly roger"} ) @@ -418,13 +463,15 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value") - self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field') - self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value") self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value") def test_delete_key(self): - test_model = CourseMetadata.delete_key(self.fullcourse_location, {'deleteKeys': ['doesnt_exist', 'showanswer', 'xqa_key']}) + test_model = CourseMetadata.update_from_json( + self.fullcourse, { + "unsetKeys": ['showanswer', 'xqa_key'] + } + ) # ensure no harm self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') self.assertIn('display_name', test_model, 'full missing editable metadata field') @@ -434,27 +481,113 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in') self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') + def test_http_fetch_initial_fields(self): + response = self.client.get_json(self.course_setting_url) + test_model = json.loads(response.content) + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") + + response = self.client.get_json(self.fullcourse_setting_url) + test_model = json.loads(response.content) + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') + self.assertIn('display_name', test_model, 'full missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') + self.assertIn('showanswer', test_model, 'showanswer field ') + self.assertIn('xqa_key', test_model, 'xqa_key field ') + + def test_http_update_from_json(self): + response = self.client.ajax_post(self.course_setting_url, { + "advertised_start": "start A", + "testcenter_info": {"c": "test"}, + "days_early_for_beta": 2, + "unsetKeys": ['showanswer', 'xqa_key'], + }) + test_model = json.loads(response.content) + self.update_check(test_model) + self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in') + self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') + + response = self.client.get_json(self.course_setting_url) + test_model = json.loads(response.content) + self.update_check(test_model) + # now change some of the existing metadata + response = self.client.ajax_post(self.course_setting_url, { + "advertised_start": "start B", + "display_name": "jolly roger" + }) + test_model = json.loads(response.content) + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value") + self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') + self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value") + + def test_advanced_components_munge_tabs(self): + """ + Test that adding and removing specific advanced components adds and removes tabs. + """ + self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), self.course.tabs) + self.assertNotIn(EXTRA_TAB_PANELS.get("notes"), self.course.tabs) + self.client.ajax_post(self.course_setting_url, { + ADVANCED_COMPONENT_POLICY_KEY: ["combinedopenended"] + }) + course = modulestore().get_item(self.course_location) + self.assertIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs) + self.assertNotIn(EXTRA_TAB_PANELS.get("notes"), course.tabs) + self.client.ajax_post(self.course_setting_url, { + ADVANCED_COMPONENT_POLICY_KEY: [] + }) + course = modulestore().get_item(self.course_location) + self.assertNotIn(EXTRA_TAB_PANELS.get("open_ended"), course.tabs) + class CourseGraderUpdatesTest(CourseTestCase): + """ + Test getting, deleting, adding, & updating graders + """ def setUp(self): + """Compute the url to use in tests""" super(CourseGraderUpdatesTest, self).setUp() - self.url = reverse("course_settings", kwargs={ - 'org': self.course.location.org, - 'course': self.course.location.course, - 'name': self.course.location.name, - 'grader_index': 0, - }) + self.url = self.course_locator.url_reverse('settings/grading') + self.starting_graders = CourseGradingModel(self.course).graders def test_get(self): - resp = self.client.get(self.url) + """Test getting a specific grading type record.""" + resp = self.client.get_json(self.url + '/0') self.assertEqual(resp.status_code, 200) obj = json.loads(resp.content) + self.assertEqual(self.starting_graders[0], obj) def test_delete(self): - resp = self.client.delete(self.url) + """Test deleting a specific grading type record.""" + resp = self.client.delete(self.url + '/0', HTTP_ACCEPT="application/json") self.assertEqual(resp.status_code, 204) + current_graders = CourseGradingModel.fetch(self.course_locator).graders + self.assertNotIn(self.starting_graders[0], current_graders) + self.assertEqual(len(self.starting_graders) - 1, len(current_graders)) - def test_post(self): + def test_update(self): + """Test updating a specific grading type record.""" + grader = { + "id": 0, + "type": "manual", + "min_count": 5, + "drop_count": 10, + "short_label": "yo momma", + "weight": 17.3, + } + resp = self.client.ajax_post(self.url + '/0', grader) + self.assertEqual(resp.status_code, 200) + obj = json.loads(resp.content) + self.assertEqual(obj, grader) + current_graders = CourseGradingModel.fetch(self.course_locator).graders + self.assertEqual(len(self.starting_graders), len(current_graders)) + + def test_add(self): + """Test adding a grading type record.""" + # the same url works for changing the whole grading model (graceperiod, cutoffs, and grading types) when + # the grading_index is None; thus, using None to imply adding a grading_type doesn't work; so, it uses an + # index out of bounds to imply create item. grader = { "type": "manual", "min_count": 5, @@ -462,6 +595,11 @@ class CourseGraderUpdatesTest(CourseTestCase): "short_label": "yo momma", "weight": 17.3, } - resp = self.client.ajax_post(self.url, grader) + resp = self.client.ajax_post('{}/{}'.format(self.url, len(self.starting_graders) + 1), grader) self.assertEqual(resp.status_code, 200) obj = json.loads(resp.content) + self.assertEqual(obj['id'], len(self.starting_graders)) + del obj['id'] + self.assertEqual(obj, grader) + current_graders = CourseGradingModel.fetch(self.course_locator).graders + self.assertEqual(len(self.starting_graders) + 1, len(current_graders)) diff --git a/cms/djangoapps/contentstore/tests/test_import_export.py b/cms/djangoapps/contentstore/tests/test_import_export.py index 20957a3508098afc7827ded6a15a3230816100a5..85df894cd414a38b4353ac4824847a371311a768 100644 --- a/cms/djangoapps/contentstore/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/tests/test_import_export.py @@ -263,7 +263,7 @@ class ExportTestCase(CourseTestCase): parent_location=vertical.location, category='aawefawef' ) - self._verify_export_failure('/edit/i4x://MITx/999/vertical/foo') + self._verify_export_failure(u'/unit/MITx.999.Robot_Super_Course/branch/draft/block/foo') def _verify_export_failure(self, expectedText): """ Export failure helper method. """ diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index b34dab0a6d29a0a6cfe98b91ceb764cc5ed006e3..4922b4888a32bf460ee7d14b1afa70f094459918 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -9,6 +9,7 @@ from xmodule.capa_module import CapaDescriptor from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import loc_mapper from xmodule.modulestore.locator import BlockUsageLocator +from xmodule.modulestore.exceptions import ItemNotFoundError class ItemTest(CourseTestCase): @@ -30,7 +31,7 @@ class ItemTest(CourseTestCase): """ Get the item referenced by the locator from the modulestore """ - store = modulestore('draft') if draft else modulestore() + store = modulestore('draft') if draft else modulestore('direct') return store.get_item(self.get_old_id(locator)) def response_locator(self, response): @@ -251,3 +252,105 @@ class TestEditItem(ItemTest): self.assertEqual(self.get_old_id(self.problem_locator).url(), children[0]) self.assertEqual(self.get_old_id(unit1_locator).url(), children[2]) self.assertEqual(self.get_old_id(unit2_locator).url(), children[1]) + + def test_make_public(self): + """ Test making a private problem public (publishing it). """ + # When the problem is first created, it is only in draft (because of its category). + with self.assertRaises(ItemNotFoundError): + self.get_item_from_modulestore(self.problem_locator, False) + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'make_public'} + ) + self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False)) + + def test_make_private(self): + """ Test making a public problem private (un-publishing it). """ + # Make problem public. + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'make_public'} + ) + self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False)) + # Now make it private + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'make_private'} + ) + with self.assertRaises(ItemNotFoundError): + self.get_item_from_modulestore(self.problem_locator, False) + + def test_make_draft(self): + """ Test creating a draft version of a public problem. """ + # Make problem public. + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'make_public'} + ) + self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False)) + # Now make it draft, which means both versions will exist. + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'create_draft'} + ) + # Update the draft version and check that published is different. + self.client.ajax_post( + self.problem_update_url, + data={'metadata': {'due': '2077-10-10T04:00Z'}} + ) + published = self.get_item_from_modulestore(self.problem_locator, False) + self.assertIsNone(published.due) + draft = self.get_item_from_modulestore(self.problem_locator, True) + self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) + + def test_make_public_with_update(self): + """ Update a problem and make it public at the same time. """ + self.client.ajax_post( + self.problem_update_url, + data={ + 'metadata': {'due': '2077-10-10T04:00Z'}, + 'publish': 'make_public' + } + ) + published = self.get_item_from_modulestore(self.problem_locator, False) + self.assertEqual(published.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) + + def test_make_private_with_update(self): + """ Make a problem private and update it at the same time. """ + # Make problem public. + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'make_public'} + ) + self.client.ajax_post( + self.problem_update_url, + data={ + 'metadata': {'due': '2077-10-10T04:00Z'}, + 'publish': 'make_private' + } + ) + with self.assertRaises(ItemNotFoundError): + self.get_item_from_modulestore(self.problem_locator, False) + draft = self.get_item_from_modulestore(self.problem_locator, True) + self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) + + def test_create_draft_with_update(self): + """ Create a draft and update it at the same time. """ + # Make problem public. + self.client.ajax_post( + self.problem_update_url, + data={'publish': 'make_public'} + ) + self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False)) + # Now make it draft, which means both versions will exist. + self.client.ajax_post( + self.problem_update_url, + data={ + 'metadata': {'due': '2077-10-10T04:00Z'}, + 'publish': 'create_draft' + } + ) + published = self.get_item_from_modulestore(self.problem_locator, False) + self.assertIsNone(published.due) + draft = self.get_item_from_modulestore(self.problem_locator, True) + self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) diff --git a/cms/djangoapps/contentstore/tests/test_transcripts.py b/cms/djangoapps/contentstore/tests/test_transcripts.py index 695fdcd09f9916500cf6653601aabde0bb2b79a9..4c481383ab5f7fa5f1c19874759e3c6283746403 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts.py @@ -20,6 +20,7 @@ from xmodule.contentstore.django import contentstore, _CONTENTSTORE from xmodule.contentstore.content import StaticContent from xmodule.exceptions import NotFoundError from xmodule.modulestore.django import loc_mapper +from xmodule.modulestore.locator import BlockUsageLocator from contentstore.tests.modulestore_config import TEST_MODULESTORE TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) @@ -59,7 +60,7 @@ class Basetranscripts(CourseTestCase): 'type': 'video' } resp = self.client.ajax_post('/xblock', data) - self.item_location = json.loads(resp.content).get('id') + self.item_location = self._get_location(resp) self.assertEqual(resp.status_code, 200) # hI10vDNYz4M - valid Youtube ID with transcripts. @@ -72,6 +73,11 @@ class Basetranscripts(CourseTestCase): # Remove all transcripts for current module. self.clear_subs_content() + def _get_location(self, resp): + """ Returns the location (as a string) from the response returned by a create operation. """ + locator = json.loads(resp.content).get('locator') + return loc_mapper().translate_locator_to_location(BlockUsageLocator(locator)).url() + def get_youtube_ids(self): """Return youtube speeds and ids.""" item = modulestore().get_item(self.item_location) @@ -205,7 +211,7 @@ class TestUploadtranscripts(Basetranscripts): 'type': 'non_video' } resp = self.client.ajax_post('/xblock', data) - item_location = json.loads(resp.content).get('id') + item_location = self._get_location(resp) data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />' modulestore().update_item(item_location, data) @@ -416,7 +422,7 @@ class TestDownloadtranscripts(Basetranscripts): 'type': 'videoalpha' } resp = self.client.ajax_post('/xblock', data) - item_location = json.loads(resp.content).get('id') + item_location = self._get_location(resp) subs_id = str(uuid4()) data = textwrap.dedent(""" <videoalpha youtube="" sub="{}"> @@ -666,7 +672,7 @@ class TestChecktranscripts(Basetranscripts): 'type': 'not_video' } resp = self.client.ajax_post('/xblock', data) - item_location = json.loads(resp.content).get('id') + item_location = self._get_location(resp) subs_id = str(uuid4()) data = textwrap.dedent(""" <not_video youtube="" sub="{}"> diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index b59f21405476185091d7369a3b5a353f1946c62d..0e716cc878bb96087f8f667c320a4afbbf0dd8e6 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -10,8 +10,9 @@ from django.test.client import Client from django.test.utils import override_settings from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from contentstore.tests.modulestore_config import TEST_MODULESTORE +from xmodule.modulestore.django import loc_mapper def parse_json(response): @@ -41,6 +42,7 @@ class AjaxEnabledTestClient(Client): if not isinstance(data, basestring): data = json.dumps(data or {}) kwargs.setdefault("HTTP_X_REQUESTED_WITH", "XMLHttpRequest") + kwargs.setdefault("HTTP_ACCEPT", "application/json") return self.post(path=path, data=data, content_type=content_type, **kwargs) def get_html(self, path, data=None, follow=False, **extra): @@ -88,6 +90,9 @@ class CourseTestCase(ModuleStoreTestCase): display_name='Robot Super Course', ) self.course_location = self.course.location + self.course_locator = loc_mapper().translate_location( + self.course.location.course_id, self.course.location, False, True + ) def createNonStaffAuthedUserClient(self): """ @@ -106,3 +111,16 @@ class CourseTestCase(ModuleStoreTestCase): client = Client() client.login(username=uname, password=password) return client, nonstaff + + def populateCourse(self): + """ + Add 2 chapters, 4 sections, 8 verticals, 16 problems to self.course (branching 2) + """ + def descend(parent, stack): + xblock_type = stack.pop(0) + for _ in range(2): + child = ItemFactory.create(category=xblock_type, parent_location=parent.location) + if stack: + descend(child, stack) + + descend(self.course, ['chapter', 'sequential', 'vertical', 'problem']) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index 5643e5c044213c08b021abc8054c6f0a94f5a930..61c6c672a73f5ab1750d6b3800f1ee591be1ec84 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -5,7 +5,6 @@ from util.json_request import JsonResponse from django.http import HttpResponseBadRequest from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods -from django.core.urlresolvers import reverse from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response from django.http import HttpResponseNotFound @@ -22,6 +21,8 @@ from xmodule.modulestore.locator import BlockUsageLocator __all__ = ['checklists_handler'] + +# pylint: disable=unused-argument @require_http_methods(("GET", "POST", "PUT")) @login_required @ensure_csrf_cookie @@ -85,8 +86,8 @@ def checklists_handler(request, tag=None, course_id=None, branch=None, version_g return JsonResponse(expanded_checklist) else: return HttpResponseBadRequest( - ( "Could not save checklist state because the checklist index " - "was out of range or unspecified."), + ("Could not save checklist state because the checklist index " + "was out of range or unspecified."), content_type="text/plain" ) else: @@ -113,14 +114,12 @@ def expand_checklist_action_url(course_module, checklist): The method does a copy of the input checklist and does not modify the input argument. """ expanded_checklist = copy.deepcopy(checklist) - oldurlconf_map = { - "SettingsDetails": "settings_details", - "SettingsGrading": "settings_grading" - } urlconf_map = { "ManageUsers": "course_team", - "CourseOutline": "course" + "CourseOutline": "course", + "SettingsDetails": "settings/details", + "SettingsGrading": "settings/grading", } for item in expanded_checklist.get('items'): @@ -130,12 +129,5 @@ def expand_checklist_action_url(course_module, checklist): ctx_loc = course_module.location location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) item['action_url'] = location.url_reverse(url_prefix, '') - elif action_url in oldurlconf_map: - urlconf_name = oldurlconf_map[action_url] - item['action_url'] = reverse(urlconf_name, kwargs={ - 'org': course_module.location.org, - 'course': course_module.location.course, - 'name': course_module.location.name, - }) return expanded_checklist diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 327e75c7f47e0aeb7b96a4911b062c5bdae95dee..3742c7af203e9603559de9cd226f0f96ff85d273 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -2,21 +2,19 @@ import json import logging from collections import defaultdict -from django.http import (HttpResponse, HttpResponseBadRequest, - HttpResponseForbidden) +from django.http import HttpResponseBadRequest from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from django.conf import settings -from xmodule.modulestore.exceptions import (ItemNotFoundError, - InvalidLocationError) +from xmodule.modulestore.exceptions import ItemNotFoundError from mitxmako.shortcuts import render_to_response -from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.util.date_utils import get_default_time_display from xmodule.modulestore.django import loc_mapper +from xmodule.modulestore.locator import BlockUsageLocator from xblock.fields import Scope from util.json_request import expect_json, JsonResponse @@ -25,7 +23,6 @@ from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitSt from models.settings.course_grading import CourseGradingModel -from .helpers import _xmodule_recurse from .access import has_access from xmodule.x_module import XModuleDescriptor from xblock.plugin import PluginMissingError @@ -33,17 +30,13 @@ from xblock.runtime import Mixologist __all__ = ['OPEN_ENDED_COMPONENT_TYPES', 'ADVANCED_COMPONENT_POLICY_KEY', - 'edit_subsection', - 'edit_unit', - 'assignment_type_update', - 'create_draft', - 'publish_draft', - 'unpublish_unit', + 'subsection_handler', + 'unit_handler' ] log = logging.getLogger(__name__) -# NOTE: edit_unit assumes this list is disjoint from ADVANCED_COMPONENT_TYPES +# NOTE: unit_handler assumes this list is disjoint from ADVANCED_COMPONENT_TYPES COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video'] OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"] @@ -58,93 +51,87 @@ ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' +@require_http_methods(["GET"]) @login_required -def edit_subsection(request, location): - "Edit the subsection of a course" - # check that we have permissions to edit this item - try: - course = get_course_for_item(location) - except InvalidLocationError: - return HttpResponseBadRequest() - - if not has_access(request.user, course.location): - raise PermissionDenied() +def subsection_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None): + """ + The restful handler for subsection-specific requests. - try: - item = modulestore().get_item(location, depth=1) - except ItemNotFoundError: - return HttpResponseBadRequest() + GET + html: return html page for editing a subsection + json: not currently supported + """ + if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + try: + old_location, course, item, lms_link = _get_item_in_course(request, locator) + except ItemNotFoundError: + return HttpResponseBadRequest() - lms_link = get_lms_link_for_item( - location, course_id=course.location.course_id - ) - preview_link = get_lms_link_for_item( - location, course_id=course.location.course_id, preview=True - ) + preview_link = get_lms_link_for_item(old_location, course_id=course.location.course_id, preview=True) - # make sure that location references a 'sequential', otherwise return - # BadRequest - if item.location.category != 'sequential': - return HttpResponseBadRequest() + # make sure that location references a 'sequential', otherwise return + # BadRequest + if item.location.category != 'sequential': + return HttpResponseBadRequest() - parent_locs = modulestore().get_parent_locations(location, None) + parent_locs = modulestore().get_parent_locations(old_location, None) - # we're for now assuming a single parent - if len(parent_locs) != 1: - logging.error( + # we're for now assuming a single parent + if len(parent_locs) != 1: + logging.error( 'Multiple (or none) parents have been found for %s', - location + unicode(locator) + ) + + # this should blow up if we don't find any parents, which would be erroneous + parent = modulestore().get_item(parent_locs[0]) + + # remove all metadata from the generic dictionary that is presented in a + # more normalized UI. We only want to display the XBlocks fields, not + # the fields from any mixins that have been added + fields = getattr(item, 'unmixed_class', item.__class__).fields + + policy_metadata = dict( + (field.name, field.read_from(item)) + for field + in fields.values() + if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings + ) + + can_view_live = False + subsection_units = item.get_children() + for unit in subsection_units: + state = compute_unit_state(unit) + if state == UnitState.public or state == UnitState.draft: + can_view_live = True + break + + course_locator = loc_mapper().translate_location( + course.location.course_id, course.location, False, True + ) + + return render_to_response( + 'edit_subsection.html', + { + 'subsection': item, + 'context_course': course, + 'new_unit_category': 'vertical', + 'lms_link': lms_link, + 'preview_link': preview_link, + 'course_graders': json.dumps(CourseGradingModel.fetch(course_locator).graders), + 'parent_item': parent, + 'locator': locator, + 'policy_metadata': policy_metadata, + 'subsection_units': subsection_units, + 'can_view_live': can_view_live + } ) + else: + return HttpResponseBadRequest("Only supports html requests") + - # this should blow up if we don't find any parents, which would be erroneous - parent = modulestore().get_item(parent_locs[0]) - - # remove all metadata from the generic dictionary that is presented in a - # more normalized UI. We only want to display the XBlocks fields, not - # the fields from any mixins that have been added - fields = getattr(item, 'unmixed_class', item.__class__).fields - - policy_metadata = dict( - (field.name, field.read_from(item)) - for field - in fields.values() - if field.name not in ['display_name', 'start', 'due', 'format'] - and field.scope == Scope.settings - ) - - can_view_live = False - subsection_units = item.get_children() - for unit in subsection_units: - state = compute_unit_state(unit) - if state == UnitState.public or state == UnitState.draft: - can_view_live = True - break - - locator = loc_mapper().translate_location( - course.location.course_id, item.location, False, True - ) - - return render_to_response( - 'edit_subsection.html', - { - 'subsection': item, - 'context_course': course, - 'new_unit_category': 'vertical', - 'lms_link': lms_link, - 'preview_link': preview_link, - 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), - # For grader, which is not yet converted - 'parent_location': course.location, - 'parent_item': parent, - 'locator': locator, - 'policy_metadata': policy_metadata, - 'subsection_units': subsection_units, - 'can_view_live': can_view_live - } - ) - - -def load_mixed_class(category): +def _load_mixed_class(category): """ Load an XBlock by category name, and apply all defined mixins """ @@ -153,139 +140,125 @@ def load_mixed_class(category): return mixologist.mix(component_class) +@require_http_methods(["GET"]) @login_required -def edit_unit(request, location): +def unit_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None): """ - Display an editing page for the specified module. - - Expects a GET request with the parameter `id`. + The restful handler for unit-specific requests. - id: A Location URL + GET + html: return html page for editing a unit + json: not currently supported """ - try: - course = get_course_for_item(location) - except InvalidLocationError: - return HttpResponseBadRequest() - - if not has_access(request.user, course.location): - raise PermissionDenied() - - try: - item = modulestore().get_item(location, depth=1) - except ItemNotFoundError: - return HttpResponseBadRequest() - lms_link = get_lms_link_for_item( - item.location, - course_id=course.location.course_id - ) - - # Note that the unit_state (draft, public, private) does not match up with the published value - # passed to translate_location. The two concepts are different at this point. - unit_locator = loc_mapper().translate_location( - course.location.course_id, Location(location), False, True - ) - - component_templates = defaultdict(list) - for category in COMPONENT_TYPES: - component_class = load_mixed_class(category) - # add the default template - # TODO: Once mixins are defined per-application, rather than per-runtime, - # this should use a cms mixed-in class. (cpennington) - if hasattr(component_class, 'display_name'): - display_name = component_class.display_name.default or 'Blank' - else: - display_name = 'Blank' - component_templates[category].append(( - display_name, - category, - False, # No defaults have markdown (hardcoded current default) - None # no boilerplate for overrides - )) - # add boilerplates - if hasattr(component_class, 'templates'): - for template in component_class.templates(): - filter_templates = getattr(component_class, 'filter_templates', None) - if not filter_templates or filter_templates(template, course): - component_templates[category].append(( - template['metadata'].get('display_name'), - category, - template['metadata'].get('markdown') is not None, - template.get('template_id') - )) - - # Check if there are any advanced modules specified in the course policy. - # These modules should be specified as a list of strings, where the strings - # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be - # enabled for the course. - course_advanced_keys = course.advanced_modules - - # Set component types according to course policy file - if isinstance(course_advanced_keys, list): - for category in course_advanced_keys: - if category in ADVANCED_COMPONENT_TYPES: - # Do I need to allow for boilerplates or just defaults on the - # class? i.e., can an advanced have more than one entry in the - # menu? one for default and others for prefilled boilerplates? - try: - component_class = load_mixed_class(category) - - component_templates['advanced'].append(( - component_class.display_name.default or category, - category, - False, - None # don't override default data + if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + try: + old_location, course, item, lms_link = _get_item_in_course(request, locator) + except ItemNotFoundError: + return HttpResponseBadRequest() + + component_templates = defaultdict(list) + for category in COMPONENT_TYPES: + component_class = _load_mixed_class(category) + # add the default template + # TODO: Once mixins are defined per-application, rather than per-runtime, + # this should use a cms mixed-in class. (cpennington) + if hasattr(component_class, 'display_name'): + display_name = component_class.display_name.default or 'Blank' + else: + display_name = 'Blank' + component_templates[category].append(( + display_name, + category, + False, # No defaults have markdown (hardcoded current default) + None # no boilerplate for overrides + )) + # add boilerplates + if hasattr(component_class, 'templates'): + for template in component_class.templates(): + filter_templates = getattr(component_class, 'filter_templates', None) + if not filter_templates or filter_templates(template, course): + component_templates[category].append(( + template['metadata'].get('display_name'), + category, + template['metadata'].get('markdown') is not None, + template.get('template_id') )) - except PluginMissingError: - # dhm: I got this once but it can happen any time the - # course author configures an advanced component which does - # not exist on the server. This code here merely - # prevents any authors from trying to instantiate the - # non-existent component type by not showing it in the menu - pass - else: - log.error( - "Improper format for course advanced keys! %s", - course_advanced_keys - ) - components = [ - [ - component.location.url(), - loc_mapper().translate_location( - course.location.course_id, component.location, False, True + # Check if there are any advanced modules specified in the course policy. + # These modules should be specified as a list of strings, where the strings + # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be + # enabled for the course. + course_advanced_keys = course.advanced_modules + + # Set component types according to course policy file + if isinstance(course_advanced_keys, list): + for category in course_advanced_keys: + if category in ADVANCED_COMPONENT_TYPES: + # Do I need to allow for boilerplates or just defaults on the + # class? i.e., can an advanced have more than one entry in the + # menu? one for default and others for prefilled boilerplates? + try: + component_class = _load_mixed_class(category) + + component_templates['advanced'].append( + ( + component_class.display_name.default or category, + category, + False, + None # don't override default data + ) + ) + except PluginMissingError: + # dhm: I got this once but it can happen any time the + # course author configures an advanced component which does + # not exist on the server. This code here merely + # prevents any authors from trying to instantiate the + # non-existent component type by not showing it in the menu + pass + else: + log.error( + "Improper format for course advanced keys! %s", + course_advanced_keys ) + + components = [ + [ + # TODO: old location needed for video transcripts. + component.location.url(), + loc_mapper().translate_location( + course.location.course_id, component.location, False, True + ) + ] + for component + in item.get_children() ] - for component - in item.get_children() - ] - - # TODO (cpennington): If we share units between courses, - # this will need to change to check permissions correctly so as - # to pick the correct parent subsection - - containing_subsection_locs = modulestore().get_parent_locations( - location, None - ) - containing_subsection = modulestore().get_item(containing_subsection_locs[0]) - containing_section_locs = modulestore().get_parent_locations( + + # TODO (cpennington): If we share units between courses, + # this will need to change to check permissions correctly so as + # to pick the correct parent subsection + + containing_subsection_locs = modulestore().get_parent_locations(old_location, None) + containing_subsection = modulestore().get_item(containing_subsection_locs[0]) + containing_section_locs = modulestore().get_parent_locations( containing_subsection.location, None - ) - containing_section = modulestore().get_item(containing_section_locs[0]) + ) + containing_section = modulestore().get_item(containing_section_locs[0]) - # cdodge hack. We're having trouble previewing drafts via jump_to redirect - # so let's generate the link url here + # cdodge hack. We're having trouble previewing drafts via jump_to redirect + # so let's generate the link url here - # need to figure out where this item is in the list of children as the - # preview will need this - index = 1 - for child in containing_subsection.get_children(): - if child.location == item.location: - break - index = index + 1 + # need to figure out where this item is in the list of children as the + # preview will need this + index = 1 + for child in containing_subsection.get_children(): + if child.location == item.location: + break + index = index + 1 - preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE') + preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE') - preview_lms_link = ( + preview_lms_link = ( '//{preview_lms_base}/courses/{org}/{course}/' '{course_name}/courseware/{section}/{subsection}/{index}' ).format( @@ -299,102 +272,46 @@ def edit_unit(request, location): index=index ) - return render_to_response('unit.html', { - 'context_course': course, - 'unit': item, - # Still needed for creating a draft. - 'unit_location': location, - 'unit_locator': unit_locator, - 'components': components, - 'component_templates': component_templates, - 'draft_preview_link': preview_lms_link, - 'published_preview_link': lms_link, - 'subsection': containing_subsection, - 'release_date': ( - get_default_time_display(containing_subsection.start) - if containing_subsection.start is not None else None - ), - 'section': containing_section, - 'new_unit_category': 'vertical', - 'unit_state': compute_unit_state(item), - 'published_date': ( - get_default_time_display(item.published_date) - if item.published_date is not None else None - ), - }) - - -@expect_json -@login_required -@require_http_methods(("GET", "POST", "PUT")) -@ensure_csrf_cookie -def assignment_type_update(request, org, course, category, name): - """ - CRUD operations on assignment types for sections and subsections and - anything else gradable. - """ - location = Location(['i4x', org, course, category, name]) - if not has_access(request.user, location): - return HttpResponseForbidden() - - if request.method == 'GET': - rsp = CourseGradingModel.get_section_grader_type(location) - elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. - rsp = CourseGradingModel.update_section_grader_type( - location, request.json - ) - return JsonResponse(rsp) - - -@login_required -@expect_json -def create_draft(request): - "Create a draft" - location = request.json['id'] - - # check permissions for this user within this course - if not has_access(request.user, location): - raise PermissionDenied() - - # This clones the existing item location to a draft location (the draft is - # implicit, because modulestore is a Draft modulestore) - modulestore().convert_to_draft(location) - - return HttpResponse() + return render_to_response('unit.html', { + 'context_course': course, + 'unit': item, + 'unit_locator': locator, + 'components': components, + 'component_templates': component_templates, + 'draft_preview_link': preview_lms_link, + 'published_preview_link': lms_link, + 'subsection': containing_subsection, + 'release_date': ( + get_default_time_display(containing_subsection.start) + if containing_subsection.start is not None else None + ), + 'section': containing_section, + 'new_unit_category': 'vertical', + 'unit_state': compute_unit_state(item), + 'published_date': ( + get_default_time_display(item.published_date) + if item.published_date is not None else None + ), + }) + else: + return HttpResponseBadRequest("Only supports html requests") @login_required -@expect_json -def publish_draft(request): - """ - Publish a draft +def _get_item_in_course(request, locator): """ - location = request.json['id'] - - # check permissions for this user within this course - if not has_access(request.user, location): - raise PermissionDenied() - - item = modulestore().get_item(location) - _xmodule_recurse( - item, - lambda i: modulestore().publish(i.location, request.user.id) - ) + Helper method for getting the old location, containing course, + item, and lms_link for a given locator. - return HttpResponse() - - -@login_required -@expect_json -def unpublish_unit(request): - "Unpublish a unit" - location = request.json['id'] - - # check permissions for this user within this course - if not has_access(request.user, location): + Verifies that the caller has permission to access this item. + """ + if not has_access(request.user, locator): raise PermissionDenied() - item = modulestore().get_item(location) - _xmodule_recurse(item, lambda i: modulestore().unpublish(i.location)) + old_location = loc_mapper().translate_locator_to_location(locator) + course_location = loc_mapper().translate_locator_to_location(locator, True) + course = modulestore().get_item(course_location) + item = modulestore().get_item(old_location, depth=1) + lms_link = get_lms_link_for_item(old_location, course_id=course.location.course_id) - return HttpResponse() + return old_location, course, item, lms_link diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 187ee9343b380f00735d85bae1ad75522dfe8710..044ef79473bf4ac1c1cec2fe410bf47ad6272e62 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -27,13 +27,11 @@ from xmodule.modulestore.exceptions import ( ItemNotFoundError, InvalidLocationError) from xmodule.modulestore import Location -from contentstore.course_info_model import ( - get_course_updates, update_course_updates, delete_course_update) +from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update from contentstore.utils import ( get_lms_link_for_item, add_extra_panel_tab, remove_extra_panel_tab, get_modulestore) -from models.settings.course_details import ( - CourseDetails, CourseSettingsEncoder) +from models.settings.course_details import CourseDetails, CourseSettingsEncoder from models.settings.course_grading import CourseGradingModel from models.settings.course_metadata import CourseMetadata @@ -53,14 +51,13 @@ from student.models import CourseEnrollment from xmodule.html_module import AboutDescriptor from xmodule.modulestore.locator import BlockUsageLocator from course_creators.views import get_course_creator_status, add_user_with_status_unrequested +from contentstore import utils __all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler', - 'get_course_settings', - 'course_config_graders_page', - 'course_config_advanced_page', - 'course_settings_updates', - 'course_grader_updates', - 'course_advanced_updates', 'textbook_index', 'textbook_by_id', + 'settings_handler', + 'grading_handler', + 'advanced_settings_handler', + 'textbook_index', 'textbook_by_id', 'create_textbook'] @@ -177,7 +174,6 @@ def course_index(request, course_id, branch, version_guid, block): if not has_access(request.user, location): raise PermissionDenied() - old_location = loc_mapper().translate_locator_to_location(location) lms_link = get_lms_link_for_item(old_location) @@ -190,10 +186,8 @@ def course_index(request, course_id, branch, version_guid, block): 'lms_link': lms_link, 'sections': sections, 'course_graders': json.dumps( - CourseGradingModel.fetch(course.location).graders + CourseGradingModel.fetch(location).graders ), - # This is used by course grader, which has not yet been updated. - 'parent_location': course.location, 'parent_locator': location, 'new_section_category': 'chapter', 'new_subsection_category': 'sequential', @@ -232,14 +226,20 @@ def create_new_course(request): pass if existing_course is not None: return JsonResponse({ - 'ErrMsg': _('There is already a course defined with the same ' + 'ErrMsg': _( + 'There is already a course defined with the same ' 'organization, course number, and course run. Please ' 'change either organization or course number to be ' - 'unique.'), - 'OrgErrMsg': _('Please change either the organization or ' - 'course number so that it is unique.'), - 'CourseErrMsg': _('Please change either the organization or ' - 'course number so that it is unique.'), + 'unique.' + ), + 'OrgErrMsg': _( + 'Please change either the organization or ' + 'course number so that it is unique.' + ), + 'CourseErrMsg': _( + 'Please change either the organization or ' + 'course number so that it is unique.' + ), }) # dhm: this query breaks the abstraction, but I'll fix it when I do my suspended refactoring of this @@ -254,12 +254,15 @@ def create_new_course(request): courses = modulestore().collection.find(course_search_location, fields=('_id')) if courses.count() > 0: return JsonResponse({ - 'ErrMsg': _('There is already a course defined with the same ' + 'ErrMsg': _( + 'There is already a course defined with the same ' 'organization and course number. Please ' 'change at least one field to be unique.'), - 'OrgErrMsg': _('Please change either the organization or ' + 'OrgErrMsg': _( + 'Please change either the organization or ' 'course number so that it is unique.'), - 'CourseErrMsg': _('Please change either the organization or ' + 'CourseErrMsg': _( + 'Please change either the organization or ' 'course number so that it is unique.'), }) @@ -347,9 +350,8 @@ def course_info_handler(request, tag=None, course_id=None, branch=None, version_ @ensure_csrf_cookie @require_http_methods(("GET", "POST", "PUT", "DELETE")) @expect_json -def course_info_update_handler( - request, tag=None, course_id=None, branch=None, version_guid=None, block=None, provided_id=None - ): +def course_info_update_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None, + provided_id=None): """ restful CRUD operations on course_info updates. provided_id should be none if it's new (create) and index otherwise. @@ -394,232 +396,206 @@ def course_info_update_handler( @login_required @ensure_csrf_cookie -def get_course_settings(request, org, course, name): - """ - Send models and views as well as html for editing the course settings to - the client. - - org, course, name: Attributes of the Location for the item to edit +@require_http_methods(("GET", "PUT", "POST")) +@expect_json +def settings_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None): """ - location = get_location_and_verify_access(request, org, course, name) - - course_module = modulestore().get_item(location) - - new_loc = loc_mapper().translate_location(location.course_id, location, False, True) - upload_asset_url = new_loc.url_reverse('assets/', '') - - return render_to_response('settings.html', { - 'context_course': course_module, - 'course_location': location, - 'details_url': reverse(course_settings_updates, - kwargs={"org": org, - "course": course, - "name": name, - "section": "details"}), - 'about_page_editable': not settings.MITX_FEATURES.get( - 'ENABLE_MKTG_SITE', False - ), - 'upload_asset_url': upload_asset_url - }) - - -@login_required -@ensure_csrf_cookie -def course_config_graders_page(request, org, course, name): + Course settings for dates and about pages + GET + html: get the page + json: get the CourseDetails model + PUT + json: update the Course and About xblocks through the CourseDetails model """ - Send models and views as well as html for editing the course settings to - the client. + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + if not has_access(request.user, locator): + raise PermissionDenied() - org, course, name: Attributes of the Location for the item to edit - """ - location = get_location_and_verify_access(request, org, course, name) + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + course_old_location = loc_mapper().translate_locator_to_location(locator) + course_module = modulestore().get_item(course_old_location) - course_module = modulestore().get_item(location) - course_details = CourseGradingModel.fetch(location) + upload_asset_url = locator.url_reverse('assets/') - return render_to_response('settings_graders.html', { - 'context_course': course_module, - 'course_location': location, - 'course_details': json.dumps(course_details, cls=CourseSettingsEncoder) - }) + return render_to_response('settings.html', { + 'context_course': course_module, + 'course_locator': locator, + 'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_old_location), + 'course_image_url': utils.course_image_url(course_module), + 'details_url': locator.url_reverse('/settings/details/'), + 'about_page_editable': not settings.MITX_FEATURES.get( + 'ENABLE_MKTG_SITE', False + ), + 'upload_asset_url': upload_asset_url + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + return JsonResponse( + CourseDetails.fetch(locator), + # encoder serializes dates, old locations, and instances + encoder=CourseSettingsEncoder + ) + else: # post or put, doesn't matter. + return JsonResponse( + CourseDetails.update_from_json(locator, request.json), + encoder=CourseSettingsEncoder + ) @login_required @ensure_csrf_cookie -def course_config_advanced_page(request, org, course, name): - """ - Send models and views as well as html for editing the advanced course - settings to the client. - - org, course, name: Attributes of the Location for the item to edit - """ - location = get_location_and_verify_access(request, org, course, name) - - course_module = modulestore().get_item(location) - - return render_to_response('settings_advanced.html', { - 'context_course': course_module, - 'course_location': location, - 'advanced_dict': json.dumps(CourseMetadata.fetch(location)), - }) - - +@require_http_methods(("GET", "POST", "PUT", "DELETE")) @expect_json -@login_required -@ensure_csrf_cookie -def course_settings_updates(request, org, course, name, section): +def grading_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None, grader_index=None): """ - Restful CRUD operations on course settings. This differs from - get_course_settings by communicating purely through json (not rendering any - html) and handles section level operations rather than whole page. - - org, course: Attributes of the Location for the item to edit - section: one of details, faculty, grading, problems, discussions + Course Grading policy configuration + GET + html: get the page + json no grader_index: get the CourseGrading model (graceperiod, cutoffs, and graders) + json w/ grader_index: get the specific grader + PUT + json no grader_index: update the Course through the CourseGrading model + json w/ grader_index: create or update the specific grader (create if index out of range) """ - get_location_and_verify_access(request, org, course, name) - - if section == 'details': - manager = CourseDetails - elif section == 'grading': - manager = CourseGradingModel - else: - return - - if request.method == 'GET': - # Cannot just do a get w/o knowing the course name :-( - return JsonResponse( - manager.fetch(Location(['i4x', org, course, 'course', name])), - encoder=CourseSettingsEncoder - ) - elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. - return JsonResponse( - manager.update_from_json(request.json), - encoder=CourseSettingsEncoder - ) + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + if not has_access(request.user, locator): + raise PermissionDenied() + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + course_old_location = loc_mapper().translate_locator_to_location(locator) + course_module = modulestore().get_item(course_old_location) + course_details = CourseGradingModel.fetch(locator) -@expect_json -@require_http_methods(("GET", "POST", "PUT", "DELETE")) -@login_required -@ensure_csrf_cookie -def course_grader_updates(request, org, course, name, grader_index=None): - """ - Restful CRUD operations on course_info updates. This differs from - get_course_settings by communicating purely through json (not rendering any - html) and handles section level operations rather than whole page. + return render_to_response('settings_graders.html', { + 'context_course': course_module, + 'course_locator': locator, + 'course_details': json.dumps(course_details, cls=CourseSettingsEncoder), + 'grading_url': locator.url_reverse('/settings/grading/'), + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + if grader_index is None: + return JsonResponse( + CourseGradingModel.fetch(locator), + # encoder serializes dates, old locations, and instances + encoder=CourseSettingsEncoder + ) + else: + return JsonResponse(CourseGradingModel.fetch_grader(locator, grader_index)) + elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. + # None implies update the whole model (cutoffs, graceperiod, and graders) not a specific grader + if grader_index is None: + return JsonResponse( + CourseGradingModel.update_from_json(locator, request.json), + encoder=CourseSettingsEncoder + ) + else: + return JsonResponse( + CourseGradingModel.update_grader_from_json(locator, request.json) + ) + elif request.method == "DELETE" and grader_index is not None: + CourseGradingModel.delete_grader(locator, grader_index) + return JsonResponse() + + +# pylint: disable=invalid-name +def _config_course_advanced_components(request, course_module): + """ + Check to see if the user instantiated any advanced components. This + is a hack that does the following : + 1) adds/removes the open ended panel tab to a course automatically + if the user has indicated that they want to edit the + combinedopendended or peergrading module + 2) adds/removes the notes panel tab to a course automatically if + the user has indicated that they want the notes module enabled in + their course + """ + # TODO refactor the above into distinct advanced policy settings + filter_tabs = True # Exceptional conditions will pull this to False + if ADVANCED_COMPONENT_POLICY_KEY in request.json: # Maps tab types to components + tab_component_map = { + 'open_ended':OPEN_ENDED_COMPONENT_TYPES, + 'notes':NOTE_COMPONENT_TYPES, + } + # Check to see if the user instantiated any notes or open ended + # components + for tab_type in tab_component_map.keys(): + component_types = tab_component_map.get(tab_type) + found_ac_type = False + for ac_type in component_types: + if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]: + # Add tab to the course if needed + changed, new_tabs = add_extra_panel_tab(tab_type, course_module) + # If a tab has been added to the course, then send the + # metadata along to CourseMetadata.update_from_json + if changed: + course_module.tabs = new_tabs + request.json.update({'tabs': new_tabs}) + # Indicate that tabs should not be filtered out of + # the metadata + filter_tabs = False # Set this flag to avoid the tab removal code below. + found_ac_type = True #break - org, course: Attributes of the Location for the item to edit - """ + # If we did not find a module type in the advanced settings, + # we may need to remove the tab from the course. + if not found_ac_type: # Remove tab from the course if needed + changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) + if changed: + course_module.tabs = new_tabs + request.json.update({'tabs':new_tabs}) + # Indicate that tabs should *not* be filtered out of + # the metadata + filter_tabs = False - location = get_location_and_verify_access(request, org, course, name) + return filter_tabs - if request.method == 'GET': - # Cannot just do a get w/o knowing the course name :-( - return JsonResponse(CourseGradingModel.fetch_grader( - Location(location), grader_index - )) - elif request.method == "DELETE": - # ??? Should this return anything? Perhaps success fail? - CourseGradingModel.delete_grader(Location(location), grader_index) - return JsonResponse() - else: # post or put, doesn't matter. - return JsonResponse(CourseGradingModel.update_grader_from_json( - Location(location), - request.json - )) - -@require_http_methods(("GET", "POST", "PUT", "DELETE")) @login_required @ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) @expect_json -def course_advanced_updates(request, org, course, name): +def advanced_settings_handler(request, course_id=None, branch=None, version_guid=None, block=None, tag=None): """ - Restful CRUD operations on metadata. The payload is a json rep of the - metadata dicts. For delete, otoh, the payload is either a key or a list of - keys to delete. + Course settings configuration + GET + html: get the page + json: get the model + PUT, POST + json: update the Course's settings. The payload is a json rep of the + metadata dicts. The dict can include a "unsetKeys" entry which is a list + of keys whose values to unset: i.e., revert to default + """ + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + if not has_access(request.user, locator): + raise PermissionDenied() - org, course: Attributes of the Location for the item to edit - """ - location = get_location_and_verify_access(request, org, course, name) + course_old_location = loc_mapper().translate_locator_to_location(locator) + course_module = modulestore().get_item(course_old_location) - if request.method == 'GET': - return JsonResponse(CourseMetadata.fetch(location)) - elif request.method == 'DELETE': - return JsonResponse(CourseMetadata.delete_key( - location, - json.loads(request.body) - )) - else: - # Whether or not to filter the tabs key out of the settings metadata - filter_tabs = True - - # Check to see if the user instantiated any advanced components. This - # is a hack that does the following : - # 1) adds/removes the open ended panel tab to a course automatically - # if the user has indicated that they want to edit the - # combinedopendended or peergrading module - # 2) adds/removes the notes panel tab to a course automatically if - # the user has indicated that they want the notes module enabled in - # their course - # TODO refactor the above into distinct advanced policy settings - if ADVANCED_COMPONENT_POLICY_KEY in request.json: - # Get the course so that we can scrape current tabs - course_module = modulestore().get_item(location) - - # Maps tab types to components - tab_component_map = { - 'open_ended': OPEN_ENDED_COMPONENT_TYPES, - 'notes': NOTE_COMPONENT_TYPES, - } + if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': - # Check to see if the user instantiated any notes or open ended - # components - for tab_type in tab_component_map.keys(): - component_types = tab_component_map.get(tab_type) - found_ac_type = False - for ac_type in component_types: - if ac_type in request.json[ADVANCED_COMPONENT_POLICY_KEY]: - # Add tab to the course if needed - changed, new_tabs = add_extra_panel_tab( - tab_type, - course_module - ) - # If a tab has been added to the course, then send the - # metadata along to CourseMetadata.update_from_json - if changed: - course_module.tabs = new_tabs - request.json.update({'tabs': new_tabs}) - # Indicate that tabs should not be filtered out of - # the metadata - filter_tabs = False - # Set this flag to avoid the tab removal code below. - found_ac_type = True - break - # If we did not find a module type in the advanced settings, - # we may need to remove the tab from the course. - if not found_ac_type: - # Remove tab from the course if needed - changed, new_tabs = remove_extra_panel_tab( - tab_type, course_module - ) - if changed: - course_module.tabs = new_tabs - request.json.update({'tabs': new_tabs}) - # Indicate that tabs should *not* be filtered out of - # the metadata - filter_tabs = False - try: - return JsonResponse(CourseMetadata.update_from_json( - location, - request.json, - filter_tabs=filter_tabs - )) - except (TypeError, ValueError) as err: - return HttpResponseBadRequest( - "Incorrect setting format. " + str(err), - content_type="text/plain" - ) + return render_to_response('settings_advanced.html', { + 'context_course': course_module, + 'advanced_dict': json.dumps(CourseMetadata.fetch(course_module)), + 'advanced_settings_url': locator.url_reverse('settings/advanced') + }) + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + if request.method == 'GET': + return JsonResponse(CourseMetadata.fetch(course_module)) + else: + # Whether or not to filter the tabs key out of the settings metadata + filter_tabs = _config_course_advanced_components(request, course_module) + try: + return JsonResponse(CourseMetadata.update_from_json( + course_module, + request.json, + filter_tabs=filter_tabs + )) + except (TypeError, ValueError) as err: + return HttpResponseBadRequest( + "Incorrect setting format. {}".format(err), + content_type="text/plain" + ) class TextbookValidationError(Exception): diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 5d1b26ec3dfbe4002beb53f155be3102a55565f0..f740d10707e550b54e12295aed609d7af5bc8507 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -14,7 +14,6 @@ from django.conf import settings from django.http import HttpResponse from django.contrib.auth.decorators import login_required from django_future.csrf import ensure_csrf_cookie -from django.core.urlresolvers import reverse from django.core.servers.basehttp import FileWrapper from django.core.files.temp import NamedTemporaryFile from django.core.exceptions import SuspiciousOperation, PermissionDenied @@ -140,7 +139,7 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid= "size": size, "deleteUrl": "", "deleteType": "", - "url": location.url_reverse('import/', ''), + "url": location.url_reverse('import'), "thumbnailUrl": "" }] }) @@ -252,8 +251,8 @@ def import_handler(request, tag=None, course_id=None, branch=None, version_guid= course_module = modulestore().get_item(old_location) return render_to_response('import.html', { 'context_course': course_module, - 'successful_import_redirect_url': location.url_reverse("course/", ""), - 'import_status_url': location.url_reverse("import_status/", "fillerName"), + 'successful_import_redirect_url': location.url_reverse("course"), + 'import_status_url': location.url_reverse("import_status", "fillerName"), }) else: return HttpResponseNotFound() @@ -313,7 +312,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= # an _accept URL parameter will be preferred over HTTP_ACCEPT in the header. requested_format = request.REQUEST.get('_accept', request.META.get('HTTP_ACCEPT', 'text/html')) - export_url = location.url_reverse('export/', '') + '?_accept=application/x-tgz' + export_url = location.url_reverse('export') + '?_accept=application/x-tgz' if 'application/x-tgz' in requested_format: name = old_location.name export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") @@ -339,16 +338,16 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= # if we have a nested exception, then we'll show the more generic error message pass + unit_locator = loc_mapper().translate_location(old_location.course_id, parent.location, False, True) + return render_to_response('export.html', { 'context_course': course_module, 'in_err': True, 'raw_err_msg': str(e), 'failed_module': failed_item, 'unit': unit, - 'edit_unit_url': reverse('edit_unit', kwargs={ - 'location': parent.location - }) if parent else '', - 'course_home_url': location.url_reverse("course/", ""), + 'edit_unit_url': unit_locator.url_reverse("unit") if parent else "", + 'course_home_url': location.url_reverse("course"), 'export_url': export_url }) @@ -359,7 +358,7 @@ def export_handler(request, tag=None, course_id=None, branch=None, version_guid= 'in_err': True, 'unit': None, 'raw_err_msg': str(e), - 'course_home_url': location.url_reverse("course/", ""), + 'course_home_url': location.url_reverse("course"), 'export_url': export_url }) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 97bfde3b828530ca659af2d84a8dee4744cd495a..220da038a7813dd363ec9c9c03a094a66be662b6 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -3,7 +3,9 @@ import logging from uuid import uuid4 +from functools import partial from static_replace import replace_static_urls +from xmodule_modifiers import wrap_xblock from django.core.exceptions import PermissionDenied from django.contrib.auth.decorators import login_required @@ -27,6 +29,9 @@ from xmodule.modulestore.locator import BlockUsageLocator from student.models import CourseEnrollment from django.http import HttpResponseBadRequest from xblock.fields import Scope +from preview import handler_prefix, get_preview_html +from mitxmako.shortcuts import render_to_response, render_to_string +from models.settings.course_grading import CourseGradingModel __all__ = ['orphan_handler', 'xblock_handler'] @@ -51,17 +56,21 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= all children and "all_versions" to delete from all (mongo) versions. GET json: returns representation of the xblock (locator id, data, and metadata). + if ?fields=graderType, it returns the graderType for the unit instead of the above. + html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view) PUT or POST - json: if xblock location is specified, update the xblock instance. The json payload can contain + json: if xblock locator is specified, update the xblock instance. The json payload can contain these fields, all optional: :data: the new value for the data. :children: the locator ids of children for this xblock. :metadata: new values for the metadata fields. Any whose values are None will be deleted not set to None! Absent ones will be left alone. :nullout: which metadata fields to set to None + :graderType: change how this unit is graded + :publish: can be one of three values, 'make_public, 'make_private', or 'create_draft' The JSON representation on the updated xblock (minus children) is returned. - if xblock location is not specified, create a new xblock instance. The json playload can contain + if xblock locator is not specified, create a new xblock instance. The json playload can contain these fields: :parent_locator: parent for new xblock, required :category: type of xblock, required @@ -70,14 +79,38 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= The locator (and old-style id) for the created xblock (minus children) is returned. """ if course_id is not None: - location = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) - if not has_access(request.user, location): + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + if not has_access(request.user, locator): raise PermissionDenied() - old_location = loc_mapper().translate_locator_to_location(location) + old_location = loc_mapper().translate_locator_to_location(locator) if request.method == 'GET': - rsp = _get_module_info(location) - return JsonResponse(rsp) + if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + fields = request.REQUEST.get('fields', '').split(',') + if 'graderType' in fields: + # right now can't combine output of this w/ output of _get_module_info, but worthy goal + return JsonResponse(CourseGradingModel.get_section_grader_type(locator)) + # TODO: pass fields to _get_module_info and only return those + rsp = _get_module_info(locator) + return JsonResponse(rsp) + else: + component = modulestore().get_item(old_location) + # Wrap the generated fragment in the xmodule_editor div so that the javascript + # can bind to it correctly + component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix)) + + try: + content = component.render('studio_view').content + # catch exceptions indiscriminately, since after this point they escape the + # dungeon and surface as uneditable, unsaveable, and undeletable + # component-goblins. + except Exception as exc: # pylint: disable=W0703 + content = render_to_string('html_error.html', {'message': str(exc)}) + + return render_to_response('component.html', { + 'preview': get_preview_html(request, component), + 'editor': content + }) elif request.method == 'DELETE': delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False')) @@ -85,12 +118,15 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= return _delete_item_at_location(old_location, delete_children, delete_all_versions) else: # Since we have a course_id, we are updating an existing xblock. return _save_item( - location, + request, + locator, old_location, data=request.json.get('data'), children=request.json.get('children'), metadata=request.json.get('metadata'), - nullout=request.json.get('nullout') + nullout=request.json.get('nullout'), + grader_type=request.json.get('graderType'), + publish=request.json.get('publish'), ) elif request.method in ('PUT', 'POST'): return _create_item(request) @@ -101,11 +137,14 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= ) -def _save_item(usage_loc, item_location, data=None, children=None, metadata=None, nullout=None): +def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None, + grader_type=None, publish=None): """ - Saves certain properties (data, children, metadata, nullout) for a given xblock item. + Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata. + nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert + to default). - The item_location is still the old-style location. + The item_location is still the old-style location whereas usage_loc is a BlockUsageLocator """ store = get_modulestore(item_location) @@ -123,6 +162,14 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None log.error("Can't find item by location.") return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404) + if publish: + if publish == 'make_private': + _xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location)) + elif publish == 'create_draft': + # This clones the existing item location to a draft location (the draft is + # implicit, because modulestore is a Draft modulestore) + modulestore().convert_to_draft(item_location) + if data: store.update_item(item_location, data) else: @@ -170,12 +217,25 @@ def _save_item(usage_loc, item_location, data=None, children=None, metadata=None if existing_item.category == 'video': manage_video_subtitles_save(existing_item, existing_item) - # Note that children aren't being returned until we have a use case. - return JsonResponse({ + result = { 'id': unicode(usage_loc), 'data': data, 'metadata': own_metadata(existing_item) - }) + } + + if grader_type is not None: + result.update(CourseGradingModel.update_section_grader_type(existing_item, grader_type)) + + # Make public after updating the xblock, in case the caller asked + # for both an update and a publish. + if publish and publish == 'make_public': + _xmodule_recurse( + existing_item, + lambda i: modulestore().publish(i.location, request.user.id) + ) + + # Note that children aren't being returned until we have a use case. + return JsonResponse(result) @login_required @@ -192,10 +252,7 @@ def _create_item(request): raise PermissionDenied() parent = get_modulestore(category).get_item(parent_location) - # Necessary to set revision=None or else metadata inheritance does not work - # (the ID with @draft will be used as the key in the inherited metadata map, - # and that is not expected by the code that later references it). - dest_location = parent_location.replace(category=category, name=uuid4().hex, revision=None) + dest_location = parent_location.replace(category=category, name=uuid4().hex) # get the metadata, display_name, and definition from the request metadata = {} @@ -224,7 +281,7 @@ def _create_item(request): course_location = loc_mapper().translate_locator_to_location(parent_locator, get_course=True) locator = loc_mapper().translate_location(course_location.course_id, dest_location, False, True) - return JsonResponse({'id': dest_location.url(), "locator": unicode(locator)}) + return JsonResponse({"locator": unicode(locator)}) def _delete_item_at_location(item_location, delete_children=False, delete_all_versions=False): diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index ab1375554d60ba001f6646e2094ef56a0b89344b..123d7fbadbdd2d9a4bfbfc5f63ae4c65748281d3 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -3,7 +3,7 @@ from functools import partial from django.conf import settings from django.core.urlresolvers import reverse -from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden +from django.http import Http404, HttpResponseBadRequest from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response, render_to_string @@ -24,10 +24,9 @@ from util.sandboxing import can_execute_unsafe_code import static_replace from .session_kv_store import SessionKeyValueStore from .helpers import render_from_lms -from .access import has_access from ..utils import get_course_for_item -__all__ = ['preview_handler', 'preview_component'] +__all__ = ['preview_handler'] log = logging.getLogger(__name__) @@ -53,13 +52,13 @@ def preview_handler(request, usage_id, handler, suffix=''): usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes` handler: The handler to execute - suffix: The remaineder of the url to be passed to the handler + suffix: The remainder of the url to be passed to the handler """ location = unquote_slashes(usage_id) descriptor = modulestore().get_item(location) - instance = load_preview_module(request, descriptor) + instance = _load_preview_module(request, descriptor) # Let the module handle the AJAX req = django_to_webob_request(request) try: @@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''): return webob_to_django_response(resp) -@login_required -def preview_component(request, location): - "Return the HTML preview of a component" - # TODO (vshnayder): change name from id to location in coffee+html as well. - if not has_access(request.user, location): - return HttpResponseForbidden() - - component = modulestore().get_item(location) - # Wrap the generated fragment in the xmodule_editor div so that the javascript - # can bind to it correctly - component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix)) - - try: - content = component.render('studio_view').content - # catch exceptions indiscriminately, since after this point they escape the - # dungeon and surface as uneditable, unsaveable, and undeletable - # component-goblins. - except Exception as exc: # pylint: disable=W0703 - content = render_to_string('html_error.html', {'message': str(exc)}) - - return render_to_response('component.html', { - 'preview': get_preview_html(request, component), - 'editor': content - }) - - class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method """ An XModule ModuleSystem for use in Studio previews @@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method return handler_prefix(block, handler_name, suffix) + '?' + query -def preview_module_system(request, descriptor): +def _preview_module_system(request, descriptor): """ Returns a ModuleSystem for the specified descriptor that is specialized for rendering module previews. @@ -135,7 +108,7 @@ def preview_module_system(request, descriptor): # TODO (cpennington): Do we want to track how instructors are using the preview problems? track_function=lambda event_type, event: None, filestore=descriptor.runtime.resources_fs, - get_module=partial(load_preview_module, request), + get_module=partial(_load_preview_module, request), render_template=render_from_lms, debug=True, replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id), @@ -162,7 +135,7 @@ def preview_module_system(request, descriptor): ) -def load_preview_module(request, descriptor): +def _load_preview_module(request, descriptor): """ Return a preview XModule instantiated from the supplied descriptor. @@ -171,7 +144,7 @@ def load_preview_module(request, descriptor): """ student_data = DbModel(SessionKeyValueStore(request)) descriptor.bind_for_student( - preview_module_system(request, descriptor), + _preview_module_system(request, descriptor), LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access ) return descriptor @@ -182,7 +155,7 @@ def get_preview_html(request, descriptor): Returns the HTML returned by the XModule's student_view, specified by the descriptor and idx. """ - module = load_preview_module(request, descriptor) + module = _load_preview_module(request, descriptor) try: content = module.render("student_view").content except Exception as exc: # pylint: disable=W0703 diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index de0a1899b3acd4057bde4d98ae47fa0d918f4784..9ab03a409331ba31a297b144fac74e46ba07dd7d 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -10,7 +10,7 @@ from mitxmako.shortcuts import render_to_response from external_auth.views import ssl_login_shortcut -__all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] +__all__ = ['signup', 'login_page', 'howitworks'] @ensure_csrf_cookie @@ -22,13 +22,6 @@ def signup(request): return render_to_response('signup.html', {'csrf': csrf_token}) -def old_login_redirect(request): - ''' - Redirect to the active login url. - ''' - return redirect('login', permanent=True) - - @ssl_login_shortcut @ensure_csrf_cookie def login_page(request): diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index 277445e3b929447c2612aa0ff9696ef9c7ec11b2..46791ddc26f3c2881761e633769d54fd7e86cd53 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -2,12 +2,13 @@ Views related to course tabs """ from access import has_access -from util.json_request import expect_json +from util.json_request import expect_json, JsonResponse -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponseNotFound from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_http_methods from mitxmako.shortcuts import render_to_response from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata @@ -19,7 +20,7 @@ from ..utils import get_modulestore from django.utils.translation import ugettext as _ -__all__ = ['edit_tabs', 'reorder_static_tabs'] +__all__ = ['tabs_handler'] def initialize_course_tabs(course): @@ -43,107 +44,113 @@ def initialize_course_tabs(course): modulestore('direct').update_metadata(course.location.url(), own_metadata(course)) - -@login_required @expect_json -def reorder_static_tabs(request): - "Order the static tabs in the requested order" - def get_location_for_tab(tab): - tab_locator = BlockUsageLocator(tab) - return loc_mapper().translate_locator_to_location(tab_locator) - - tabs = request.json['tabs'] - course_location = loc_mapper().translate_locator_to_location(BlockUsageLocator(tabs[0]), get_course=True) - - if not has_access(request.user, course_location): - raise PermissionDenied() - - course = get_modulestore(course_location).get_item(course_location) - - # get list of existing static tabs in course - # make sure they are the same lengths (i.e. the number of passed in tabs equals the number - # that we know about) otherwise we can drop some! - - existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab'] - if len(existing_static_tabs) != len(tabs): - return HttpResponseBadRequest() - - # load all reference tabs, return BadRequest if we can't find any of them - tab_items = [] - for tab in tabs: - item = modulestore('direct').get_item(get_location_for_tab(tab)) - if item is None: - return HttpResponseBadRequest() - - tab_items.append(item) - - # now just go through the existing course_tabs and re-order the static tabs - reordered_tabs = [] - static_tab_idx = 0 - for tab in course.tabs: - if tab['type'] == 'static_tab': - reordered_tabs.append({'type': 'static_tab', - 'name': tab_items[static_tab_idx].display_name, - 'url_slug': tab_items[static_tab_idx].location.name}) - static_tab_idx += 1 - else: - reordered_tabs.append(tab) - - # OK, re-assemble the static tabs in the new order - course.tabs = reordered_tabs - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - course.save() - modulestore('direct').update_metadata(course.location, own_metadata(course)) - # TODO: above two lines are used for the primitive-save case. Maybe factor them out? - return HttpResponse() - - @login_required @ensure_csrf_cookie -def edit_tabs(request, org, course, coursename): - "Edit tabs" - location = ['i4x', org, course, 'course', coursename] - store = get_modulestore(location) - course_item = store.get_item(location) - - # check that logged in user has permissions to this item - if not has_access(request.user, location): - raise PermissionDenied() +@require_http_methods(("GET", "POST", "PUT")) +def tabs_handler(request, tag=None, course_id=None, branch=None, version_guid=None, block=None): + """ + The restful handler for static tabs. - # see tabs have been uninitialized (e.g. supporing courses created before tab support in studio) - if course_item.tabs is None or len(course_item.tabs) == 0: - initialize_course_tabs(course_item) + GET + html: return page for editing static tabs + json: not supported + PUT or POST + json: update the tab order. It is expected that the request body contains a JSON-encoded dict with entry "tabs". + The value for "tabs" is an array of tab locators, indicating the desired order of the tabs. - # first get all static tabs from the tabs list - # we do this because this is also the order in which items are displayed in the LMS - static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab'] + Creating a tab, deleting a tab, or changing its contents is not supported through this method. + Instead use the general xblock URL (see item.xblock_handler). + """ + locator = BlockUsageLocator(course_id=course_id, branch=branch, version_guid=version_guid, usage_id=block) + if not has_access(request.user, locator): + raise PermissionDenied() - static_tabs = [] - for static_tab_ref in static_tabs_refs: - static_tab_loc = Location(location)._replace(category='static_tab', name=static_tab_ref['url_slug']) - static_tabs.append(modulestore('direct').get_item(static_tab_loc)) + old_location = loc_mapper().translate_locator_to_location(locator) + store = get_modulestore(old_location) + course_item = store.get_item(old_location) - components = [ - [ - static_tab.location.url(), + if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + if request.method == 'GET': + raise NotImplementedError('coming soon') + else: + if 'tabs' in request.json: + def get_location_for_tab(tab): + """ Returns the location (old-style) for a tab. """ + return loc_mapper().translate_locator_to_location(BlockUsageLocator(tab)) + + tabs = request.json['tabs'] + + # get list of existing static tabs in course + # make sure they are the same lengths (i.e. the number of passed in tabs equals the number + # that we know about) otherwise we will inadvertently drop some! + existing_static_tabs = [t for t in course_item.tabs if t['type'] == 'static_tab'] + if len(existing_static_tabs) != len(tabs): + return JsonResponse( + {"error": "number of tabs must be {}".format(len(existing_static_tabs))}, status=400 + ) + + # load all reference tabs, return BadRequest if we can't find any of them + tab_items = [] + for tab in tabs: + item = modulestore('direct').get_item(get_location_for_tab(tab)) + if item is None: + return JsonResponse( + {"error": "no tab for found location {}".format(tab)}, status=400 + ) + + tab_items.append(item) + + # now just go through the existing course_tabs and re-order the static tabs + reordered_tabs = [] + static_tab_idx = 0 + for tab in course_item.tabs: + if tab['type'] == 'static_tab': + reordered_tabs.append( + {'type': 'static_tab', + 'name': tab_items[static_tab_idx].display_name, + 'url_slug': tab_items[static_tab_idx].location.name, + } + ) + static_tab_idx += 1 + else: + reordered_tabs.append(tab) + + # OK, re-assemble the static tabs in the new order + course_item.tabs = reordered_tabs + modulestore('direct').update_metadata(course_item.location, own_metadata(course_item)) + return JsonResponse() + else: + raise NotImplementedError('Creating or changing tab content is not supported.') + elif request.method == 'GET': # assume html + # see tabs have been uninitialized (e.g. supporting courses created before tab support in studio) + if course_item.tabs is None or len(course_item.tabs) == 0: + initialize_course_tabs(course_item) + + # first get all static tabs from the tabs list + # we do this because this is also the order in which items are displayed in the LMS + static_tabs_refs = [t for t in course_item.tabs if t['type'] == 'static_tab'] + + static_tabs = [] + for static_tab_ref in static_tabs_refs: + static_tab_loc = old_location.replace(category='static_tab', name=static_tab_ref['url_slug']) + static_tabs.append(modulestore('direct').get_item(static_tab_loc)) + + components = [ loc_mapper().translate_location( course_item.location.course_id, static_tab.location, False, True ) + for static_tab + in static_tabs ] - for static_tab - in static_tabs - ] - course_locator = loc_mapper().translate_location( - course_item.location.course_id, course_item.location, False, True - ) - - return render_to_response('edit-tabs.html', { - 'context_course': course_item, - 'components': components, - 'locator': course_locator - }) + return render_to_response('edit-tabs.html', { + 'context_course': course_item, + 'components': components, + 'course_locator': locator + }) + else: + return HttpResponseNotFound() # "primitive" tab edit functions driven by the command line. @@ -167,7 +174,7 @@ def primitive_delete(course, num): # Note for future implementations: if you delete a static_tab, then Chris Dodge # points out that there's other stuff to delete beyond this element. # This code happens to not delete static_tab so it doesn't come up. - primitive_save(course) + modulestore('direct').update_metadata(course.location, own_metadata(course)) def primitive_insert(course, num, tab_type, name): @@ -176,11 +183,5 @@ def primitive_insert(course, num, tab_type, name): new_tab = {u'type': unicode(tab_type), u'name': unicode(name)} tabs = course.tabs tabs.insert(num, new_tab) - primitive_save(course) - - -def primitive_save(course): - "Saves the course back to modulestore." - # This code copied from reorder_static_tabs above - course.save() modulestore('direct').update_metadata(course.location, own_metadata(course)) + diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 99ce00b891cf1fdd3f79fd3ce8f4a214e5b9942d..dd8582ba76485b5fd6ce471ac5ca12bb658af9e4 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -1,20 +1,25 @@ +import re +import logging +import datetime +import json +from json.encoder import JSONEncoder + from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata -import json -from json.encoder import JSONEncoder from contentstore.utils import get_modulestore, course_image_url from models.settings import course_grading from contentstore.utils import update_item from xmodule.fields import Date -import re -import logging -import datetime +from xmodule.modulestore.django import loc_mapper class CourseDetails(object): - def __init__(self, location): - self.course_location = location # a Location obj + def __init__(self, org, course_id, run): + # still need these for now b/c the client's screen shows these 3 fields + self.org = org + self.course_id = course_id + self.run = run self.start_date = None # 'start' self.end_date = None # 'end' self.enrollment_start = None @@ -27,16 +32,13 @@ class CourseDetails(object): self.course_image_asset_path = "" # URL of the course image @classmethod - def fetch(cls, course_location): + def fetch(cls, course_locator): """ Fetch the course details for the given course from persistence and return a CourseDetails model. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - course = cls(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_locator) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) + course = cls(course_old_location.org, course_old_location.course, course_old_location.name) course.start_date = descriptor.start course.end_date = descriptor.end @@ -45,7 +47,7 @@ class CourseDetails(object): course.course_image_name = descriptor.course_image course.course_image_asset_path = course_image_url(descriptor) - temploc = course_location.replace(category='about', name='syllabus') + temploc = course_old_location.replace(category='about', name='syllabus') try: course.syllabus = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: @@ -73,14 +75,12 @@ class CourseDetails(object): return course @classmethod - def update_from_json(cls, jsondict): + def update_from_json(cls, course_locator, jsondict): """ Decode the json into CourseDetails and save any changed attrs to the db """ - # TODO make it an error for this to be undefined & for it to not be retrievable from modulestore - course_location = Location(jsondict['course_location']) - # Will probably want to cache the inflight courses because every blur generates an update - descriptor = get_modulestore(course_location).get_item(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_locator) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) dirty = False @@ -134,11 +134,11 @@ class CourseDetails(object): # MongoKeyValueStore before we update the mongo datastore. descriptor.save() - get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) + get_modulestore(course_old_location).update_metadata(course_old_location, own_metadata(descriptor)) # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. - temploc = Location(course_location).replace(category='about', name='syllabus') + temploc = Location(course_old_location).replace(category='about', name='syllabus') update_item(temploc, jsondict['syllabus']) temploc = temploc.replace(name='overview') @@ -151,9 +151,9 @@ class CourseDetails(object): recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) update_item(temploc, recomposed_video_tag) - # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm + # Could just return jsondict w/o doing any db reads, but I put the reads in as a means to confirm # it persisted correctly - return CourseDetails.fetch(course_location) + return CourseDetails.fetch(course_locator) @staticmethod def parse_video_tag(raw_video): @@ -188,6 +188,9 @@ class CourseDetails(object): # TODO move to a more general util? class CourseSettingsEncoder(json.JSONEncoder): + """ + Serialize CourseDetails, CourseGradingModel, datetime, and old Locations + """ def default(self, obj): if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)): return obj.__dict__ diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 578961fad6cde1e0bae72f1ee496c5386ff71d15..fbbb37450cb13c76ef8b66e97eaef005ba19849f 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -1,6 +1,7 @@ -from xmodule.modulestore import Location -from contentstore.utils import get_modulestore from datetime import timedelta +from contentstore.utils import get_modulestore +from xmodule.modulestore.django import loc_mapper +from xblock.fields import Scope class CourseGradingModel(object): @@ -9,22 +10,20 @@ class CourseGradingModel(object): """ # Within this class, allow access to protected members of client classes. # This comes up when accessing kvs data and caches during kvs saves and modulestore writes. - # pylint: disable=W0212 def __init__(self, course_descriptor): - self.course_location = course_descriptor.location - self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100] + self.graders = [ + CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader) + ] # weights transformed to ints [0..100] self.grade_cutoffs = course_descriptor.grade_cutoffs self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor) @classmethod - def fetch(cls, course_location): + def fetch(cls, course_locator): """ - Fetch the course details for the given course from persistence and return a CourseDetails model. + Fetch the course grading policy for the given course from persistence and return a CourseGradingModel. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_locator) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) model = cls(descriptor) return model @@ -35,12 +34,8 @@ class CourseGradingModel(object): Fetch the course's nth grader Returns an empty dict if there's no such grader. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) - # # ??? it would be good if these had the course_location in them so that they stand alone sufficiently - # # but that would require not using CourseDescriptor's field directly. Opinions? + course_old_location = loc_mapper().translate_locator_to_location(course_location) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) index = int(index) if len(descriptor.raw_grader) > index: @@ -57,48 +52,26 @@ class CourseGradingModel(object): } @staticmethod - def fetch_cutoffs(course_location): - """ - Fetch the course's grade cutoffs. - """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) - return descriptor.grade_cutoffs - - @staticmethod - def fetch_grace_period(course_location): - """ - Fetch the course's default grace period. - """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) - return {'grace_period': CourseGradingModel.convert_set_grace_period(descriptor)} - - @staticmethod - def update_from_json(jsondict): + def update_from_json(course_locator, jsondict): """ Decode the json into CourseGradingModel and save any changes. Returns the modified model. Probably not the usual path for updates as it's too coarse grained. """ - course_location = Location(jsondict['course_location']) - descriptor = get_modulestore(course_location).get_item(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_locator) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) + graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']] descriptor.raw_grader = graders_parsed descriptor.grade_cutoffs = jsondict['grade_cutoffs'] - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data) + get_modulestore(course_old_location).update_item( + course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content) + ) - CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period']) + CourseGradingModel.update_grace_period_from_json(course_locator, jsondict['grace_period']) - return CourseGradingModel.fetch(course_location) + return CourseGradingModel.fetch(course_locator) @staticmethod def update_grader_from_json(course_location, grader): @@ -106,12 +79,8 @@ class CourseGradingModel(object): Create or update the grader of the given type (string key) for the given course. Returns the modified grader which is a full model on the client but not on the server (just a dict) """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) - # # ??? it would be good if these had the course_location in them so that they stand alone sufficiently - # # but that would require not using CourseDescriptor's field directly. Opinions? + course_old_location = loc_mapper().translate_locator_to_location(course_location) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) # parse removes the id; so, grab it before parse index = int(grader.get('id', len(descriptor.raw_grader))) @@ -122,10 +91,9 @@ class CourseGradingModel(object): else: descriptor.raw_grader.append(grader) - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data) + get_modulestore(course_old_location).update_item( + course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content) + ) return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) @@ -135,16 +103,13 @@ class CourseGradingModel(object): Create or update the grade cutoffs for the given course. Returns sent in cutoffs (ie., no extra db fetch). """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - descriptor = get_modulestore(course_location).get_item(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_location) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) descriptor.grade_cutoffs = cutoffs - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data) + get_modulestore(course_old_location).update_item( + course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content) + ) return cutoffs @@ -155,8 +120,8 @@ class CourseGradingModel(object): grace_period entry in an enclosing dict. It is also safe to call this method with a value of None for graceperiodjson. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_location) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) # Before a graceperiod has ever been created, it will be None (once it has been # created, it cannot be set back to None). @@ -164,81 +129,67 @@ class CourseGradingModel(object): if 'grace_period' in graceperiodjson: graceperiodjson = graceperiodjson['grace_period'] - # lms requires these to be in a fixed order grace_timedelta = timedelta(**graceperiodjson) - - descriptor = get_modulestore(course_location).get_item(course_location) descriptor.graceperiod = grace_timedelta - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata) + get_modulestore(course_old_location).update_metadata( + course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings) + ) @staticmethod def delete_grader(course_location, index): """ Delete the grader of the given type from the given course. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_location) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) - descriptor = get_modulestore(course_location).get_item(course_location) index = int(index) if index < len(descriptor.raw_grader): del descriptor.raw_grader[index] # force propagation to definition descriptor.raw_grader = descriptor.raw_grader - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data) + get_modulestore(course_old_location).update_item( + course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.content) + ) @staticmethod def delete_grace_period(course_location): """ - Delete the course's default grace period. + Delete the course's grace period. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) + course_old_location = loc_mapper().translate_locator_to_location(course_location) + descriptor = get_modulestore(course_old_location).get_item(course_old_location) - descriptor = get_modulestore(course_location).get_item(course_location) del descriptor.graceperiod - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata) + get_modulestore(course_old_location).update_metadata( + course_old_location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings) + ) @staticmethod def get_section_grader_type(location): - if not isinstance(location, Location): - location = Location(location) - - descriptor = get_modulestore(location).get_item(location) - return {"graderType": descriptor.format if descriptor.format is not None else 'Not Graded', - "location": location, - "id": 99 # just an arbitrary value to - } + old_location = loc_mapper().translate_locator_to_location(location) + descriptor = get_modulestore(old_location).get_item(old_location) + return { + "graderType": descriptor.format if descriptor.format is not None else 'Not Graded', + "location": unicode(location), + } @staticmethod - def update_section_grader_type(location, jsondict): - if not isinstance(location, Location): - location = Location(location) - - descriptor = get_modulestore(location).get_item(location) - if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded": - descriptor.format = jsondict.get('graderType') + def update_section_grader_type(descriptor, grader_type): + if grader_type is not None and grader_type != u"Not Graded": + descriptor.format = grader_type descriptor.graded = True else: del descriptor.format del descriptor.graded - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(location).update_metadata(location, descriptor._field_data._kvs._metadata) + get_modulestore(descriptor.location).update_metadata( + descriptor.location, descriptor.get_explicitly_set_fields_by_scope(Scope.settings) + ) + return {'graderType': grader_type} @staticmethod def convert_set_grace_period(descriptor): diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 603865b8846b91e3f45b607caf34431f7f4f0fb7..ddb4814511b99c2d125115c9c0786780adfd08c9 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -1,7 +1,7 @@ -from xmodule.modulestore import Location +from xblock.fields import Scope + from contentstore.utils import get_modulestore from xmodule.modulestore.inheritance import own_metadata -from xblock.fields import Scope from cms.xmodule_namespace import CmsBlockMixin @@ -20,21 +20,18 @@ class CourseMetadata(object): 'tabs', 'graceperiod', 'checklists', - 'show_timezone' + 'show_timezone', + 'format', + 'graded', ] @classmethod - def fetch(cls, course_location): + def fetch(cls, descriptor): """ Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model. """ - if not isinstance(course_location, Location): - course_location = Location(course_location) - - course = {} - - descriptor = get_modulestore(course_location).get_item(course_location) + result = {} for field in descriptor.fields.values(): if field.name in CmsBlockMixin.fields: @@ -46,19 +43,17 @@ class CourseMetadata(object): if field.name in cls.FILTERED_LIST: continue - course[field.name] = field.read_json(descriptor) + result[field.name] = field.read_json(descriptor) - return course + return result @classmethod - def update_from_json(cls, course_location, jsondict, filter_tabs=True): + def update_from_json(cls, descriptor, jsondict, filter_tabs=True): """ Decode the json into CourseMetadata and save any changed attrs to the db. Ensures none of the fields are in the blacklist. """ - descriptor = get_modulestore(course_location).get_item(course_location) - dirty = False # Copy the filtered list to avoid permanently changing the class attribute. @@ -72,39 +67,17 @@ class CourseMetadata(object): if key in filtered_list: continue + if key == "unsetKeys": + dirty = True + for unset in val: + descriptor.fields[unset].delete_from(descriptor) + if hasattr(descriptor, key) and getattr(descriptor, key) != val: dirty = True value = descriptor.fields[key].from_json(val) setattr(descriptor, key, value) if dirty: - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - get_modulestore(course_location).update_metadata(course_location, - own_metadata(descriptor)) - - # Could just generate and return a course obj w/o doing any db reads, - # but I put the reads in as a means to confirm it persisted correctly - return cls.fetch(course_location) - - @classmethod - def delete_key(cls, course_location, payload): - ''' - Remove the given metadata key(s) from the course. payload can be a - single key or [key..] - ''' - descriptor = get_modulestore(course_location).get_item(course_location) - - for key in payload['deleteKeys']: - if hasattr(descriptor, key): - delattr(descriptor, key) - - # Save the data that we've just changed to the underlying - # MongoKeyValueStore before we update the mongo datastore. - descriptor.save() - - get_modulestore(course_location).update_metadata(course_location, - own_metadata(descriptor)) + get_modulestore(descriptor.location).update_metadata(descriptor.location, own_metadata(descriptor)) - return cls.fetch(course_location) + return cls.fetch(descriptor) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 592415f61ae76ab58aac7df80c741fb3da7de57f..1b0c0ef6482fd5f40e1223169524432ca2aa0896 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -166,9 +166,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') if SEGMENT_IO_KEY: MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) - AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] +if AWS_ACCESS_KEY_ID == "": + AWS_ACCESS_KEY_ID = None + AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] +if AWS_SECRET_ACCESS_KEY == "": + AWS_SECRET_ACCESS_KEY = None + DATABASES = AUTH_TOKENS['DATABASES'] MODULESTORE = AUTH_TOKENS['MODULESTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 4c69172170be0c19ea1ae3ec9a2bcebe09e51769..e25f092c9a84786df2c849d4197e743782c32136 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -23,7 +23,8 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' ################################# LMS INTEGRATION ############################# -MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000" +LMS_BASE = "localhost:8000" +MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE ################################# CELERY ###################################### diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 2ee8d1797958c98db4f2b58f087d28dadb9e84a9..c84b60be61a8bab3c4b904eddf64bdde0436fb14 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -197,7 +197,8 @@ define([ "js/spec/transcripts/videolist_spec", "js/spec/transcripts/message_manager_spec", "js/spec/transcripts/file_uploader_spec", - "js/spec/utils/module_spec" + "js/spec/utils/module_spec", + "js/spec/models/explicit_url_spec" # these tests are run separate in the cms-squire suite, due to process # isolation issues with Squire.js diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee index 3c388fa5936dceee2a5ef88074f289fa6fcd05bc..1e843d59fbb0a103b000df490c4b03e6c9134643 100644 --- a/cms/static/coffee/spec/views/course_info_spec.coffee +++ b/cms/static/coffee/spec/views/course_info_spec.coffee @@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model @handoutsEdit.$el.find('.edit-button').click() expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg') + it "can open course handouts with bad html on edit", -> + # Enter some bad html in handouts section, verifying that the + # model/handoutform opens when "Edit" is clicked + + @model = new ModuleInfo({ + id: 'handouts-id', + data: '<p><a href="[URL OF FILE]>[LINK TEXT]</a></p>' + }) + @handoutsEdit = new CourseInfoHandoutsView({ + el: $('#course-handouts-view'), + model: @model, + base_asset_url: 'base-asset-url/' + }); + @handoutsEdit.render() + + expect($('.edit-handouts-form').is(':hidden')).toEqual(true) + @handoutsEdit.$el.find('.edit-button').click() + expect(@handoutsEdit.$codeMirror.getValue()).toEqual('<p><a href="[URL OF FILE]>[LINK TEXT]</a></p>') + expect($('.edit-handouts-form').is(':hidden')).toEqual(false) \ No newline at end of file diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 22d1052fa3a0f5d9afe6c9d56ceb19166bc82de4..36716668d34c99f83f02c2b315de32bd4c2fc1d7 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -1,12 +1,9 @@ -define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> +define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) -> describe "ModuleEdit", -> beforeEach -> - @stubModule = jasmine.createSpy("Module") - @stubModule.id = 'stub-id' - @stubModule.get = (param)-> - if param == 'old_id' - return 'stub-old-id' + @stubModule = new ModuleModel + id: "stub-id" setFixtures """ <li class="component" id="stub-id"> @@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> @moduleEdit.render() it "loads the module preview and editor via ajax on the view element", -> - expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.get('old_id')}", jasmine.any(Function)) + expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/xblock/#{@moduleEdit.model.id}", jasmine.any(Function)) @moduleEdit.$el.load.mostRecentCall.args[1]() expect(@moduleEdit.loadDisplay).toHaveBeenCalled() expect(@moduleEdit.delegateEvents).toHaveBeenCalled() diff --git a/cms/static/coffee/spec/views/overview_spec.coffee b/cms/static/coffee/spec/views/overview_spec.coffee index 9ece1b0059e42ff907a4e961b5e1a345d66d5f91..cbc08212137a251d4ecb7b8b1af183174979832f 100644 --- a/cms/static/coffee/spec/views/overview_spec.coffee +++ b/cms/static/coffee/spec/views/overview_spec.coffee @@ -36,7 +36,7 @@ define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base appendSetFixtures """ <section class="courseware-section branch" data-locator="a-location-goes-here"> - <li class="branch collapsed id-holder" data-id="an-id-goes-here" data-locator="an-id-goes-here"> + <li class="branch collapsed id-holder" data-locator="an-id-goes-here"> <a href="#" class="delete-section-button"></a> </li> </section> diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index a13e572887c8e775c6904c97f61e91718513052f..729a17615e182387b55e2d28d55b715cf8e29c9b 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", payload (data) => @model.set(id: data.locator) - @model.set(old_id: data.id) - @$el.data('id', data.id) @$el.data('locator', data.locator) @render() ) render: -> - if @model.get('old_id') - @$el.load("/preview_component/#{@model.get('old_id')}", => + if @model.id + @$el.load(@model.url(), => @loadDisplay() @delegateEvents() ) diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 0f72e8bddbf0dd39a53ebd05daad1b4bf5761f38..83ca7dc2fedc464a993f9e4c09a4feb6d6c98c39 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views initialize: => @$('.component').each((idx, element) => model = new ModuleModel({ - id: $(element).data('locator'), - old_id:$(element).data('id') + id: $(element).data('locator') }) new ModuleEditView( @@ -38,14 +37,17 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views analytics.track "Reordered Static Pages", course: course_location_analytics + saving = new NotificationView.Mini({title: gettext("Saving…")}) + saving.show() + $.ajax({ type:'POST', - url: '/reorder_static_tabs', + url: @model.url(), data: JSON.stringify({ tabs : tabs }), contentType: 'application/json' - }) + }).success(=> saving.hide()) addNewTab: (event) => event.preventDefault() diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 075b56d1b0a049bf78032fa8b42fd0d828574177..c4ff25c309cab3ba356ede96a3559574e44150d8 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone", @$('.component').each (idx, element) => model = new ModuleModel id: $(element).data('locator') - old_id: $(element).data('id') new ModuleEditView el: element, onDelete: @deleteComponent, @@ -167,7 +166,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", @wait(true) $.ajax({ type: 'DELETE', - url: @model.urlRoot + "/" + @$el.data('locator') + "?" + $.param({recurse: true}) + url: @model.url() + "?" + $.param({recurse: true}) }).success(=> analytics.track "Deleted Draft", @@ -180,8 +179,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", createDraft: (event) -> @wait(true) - $.postJSON('/create_draft', { - id: @$el.data('id') + $.postJSON(@model.url(), { + publish: 'create_draft' }, => analytics.track "Created Draft", course: course_location_analytics @@ -194,8 +193,8 @@ define ["jquery", "jquery.ui", "gettext", "backbone", @wait(true) @saveDraft() - $.postJSON('/publish_draft', { - id: @$el.data('id') + $.postJSON(@model.url(), { + publish: 'make_public' }, => analytics.track "Published Draft", course: course_location_analytics @@ -206,16 +205,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone", setVisibility: (event) -> if @$('.visibility-select').val() == 'private' - target_url = '/unpublish_unit' + action = 'make_private' visibility = "private" else - target_url = '/publish_draft' + action = 'make_public' visibility = "public" @wait(true) - $.postJSON(target_url, { - id: @$el.data('id') + $.postJSON(@model.url(), { + publish: action }, => analytics.track "Set Unit Visibility", course: course_location_analytics diff --git a/cms/static/js/base.js b/cms/static/js/base.js index bc7260cf64019675ffe6c6a06821f463177bef07..174ab10c89cca5a540e8f659e0e2d0c9ab47abe9 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -237,7 +237,7 @@ function createNewUnit(e) { function(data) { // redirect to the edit page - window.location = "/edit/" + data['id']; + window.location = "/unit/" + data['locator']; }); } diff --git a/cms/static/js/collections/course_grader.js b/cms/static/js/collections/course_grader.js index c4adf64e1f3f11ddf95f345d1f40f0f588565b75..7dde698cb27d098c83104f8bd5c4d300c9e5b3b3 100644 --- a/cms/static/js/collections/course_grader.js +++ b/cms/static/js/collections/course_grader.js @@ -2,10 +2,6 @@ define(["backbone", "js/models/settings/course_grader"], function(Backbone, Cour var CourseGraderCollection = Backbone.Collection.extend({ model : CourseGrader, - course_location : null, // must be set to a Location object - url : function() { - return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/settings-grading/' + this.course_location.get('name') + '/'; - }, sumWeights : function() { return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0); } diff --git a/cms/static/js/models/assignment_grade.js b/cms/static/js/models/assignment_grade.js index 4c3d54b976bbf0e468b9f8f710bce31ccb48533b..83f00a7d106c616422e1093243592ddbfd15c77e 100644 --- a/cms/static/js/models/assignment_grade.js +++ b/cms/static/js/models/assignment_grade.js @@ -1,26 +1,14 @@ -define(["backbone", "underscore", "js/models/location"], function(Backbone, _, Location) { +define(["backbone", "underscore"], function(Backbone, _) { var AssignmentGrade = Backbone.Model.extend({ defaults : { - graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral - location : null // A location object + graderType : null, // the type label (string). May be "Not Graded" which implies None. + locator : null // locator for the block }, - initialize : function(attrs) { - if (attrs['assignmentUrl']) { - this.set('location', new Location(attrs['assignmentUrl'], {parse: true})); - } - }, - parse : function(attrs) { - if (attrs && attrs['location']) { - attrs.location = new Location(attrs['location'], {parse: true}); - } - }, - urlRoot : function() { - if (this.has('location')) { - var location = this.get('location'); - return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/' - + location.get('name') + '/gradeas/'; - } - else return ""; + idAttribute: 'locator', + urlRoot : '/xblock/', + url: function() { + // add ?fields=graderType to the request url (only needed for fetch, but innocuous for others) + return Backbone.Model.prototype.url.apply(this) + '?' + $.param({fields: 'graderType'}); } }); return AssignmentGrade; diff --git a/cms/static/js/models/course_info.js b/cms/static/js/models/course_info.js index e5a6114dff0802d79fd1983f6c6dccd8f1c865b1..e4c816ccf3e1edc5efbc2d11bbf4c33fe757b22e 100644 --- a/cms/static/js/models/course_info.js +++ b/cms/static/js/models/course_info.js @@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) { url: '', defaults: { - "courseId": "", // the location url "updates" : null, // UpdateCollection "handouts": null // HandoutCollection - }, - - idAttribute : "courseId" + } }); return CourseInfo; }); diff --git a/cms/static/js/models/explicit_url.js b/cms/static/js/models/explicit_url.js new file mode 100644 index 0000000000000000000000000000000000000000..aae69608af85d4d90b7414559e82feedf975a5de --- /dev/null +++ b/cms/static/js/models/explicit_url.js @@ -0,0 +1,14 @@ +/** + * A model that simply allows the update URL to be passed + * in as an argument. + */ +define(["backbone"], function(Backbone){ + return Backbone.Model.extend({ + defaults: { + "explicit_url": "" + }, + url: function() { + return this.get("explicit_url"); + } + }); +}); diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 13cc4ce692ff5f68248d4d685b7d03030f4c6607..058cacadd70eeb401bdaf42d8706b8283198a0d2 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -1,8 +1,10 @@ -define(["backbone", "underscore", "gettext", "js/models/location"], function(Backbone, _, gettext, Location) { +define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) { var CourseDetails = Backbone.Model.extend({ defaults: { - location : null, // the course's Location model, required + org : '', + course_id: '', + run: '', start_date: null, // maps to 'start' end_date: null, // maps to 'end' enrollment_start: null, @@ -17,9 +19,6 @@ var CourseDetails = Backbone.Model.extend({ // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) parse: function(attributes) { - if (attributes['course_location']) { - attributes.location = new Location(attributes.course_location, {parse:true}); - } if (attributes['start_date']) { attributes.start_date = new Date(attributes.start_date); } diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js index 1e23a4ecf45edc67a459d8231c58649af89a0837..d034aa2cef14c04ca4a2b1031907582af4f5ee89 100644 --- a/cms/static/js/models/settings/course_grading_policy.js +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -3,15 +3,11 @@ define(["backbone", "js/models/location", "js/collections/course_grader"], var CourseGradingPolicy = Backbone.Model.extend({ defaults : { - course_location : null, graders : null, // CourseGraderCollection grade_cutoffs : null, // CourseGradeCutoff model grace_period : null // either null or { hours: n, minutes: m, ...} }, parse: function(attributes) { - if (attributes['course_location']) { - attributes.course_location = new Location(attributes.course_location, {parse:true}); - } if (attributes['graders']) { var graderCollection; // interesting race condition: if {parse:true} when newing, then parse called before .attributes created @@ -21,7 +17,6 @@ var CourseGradingPolicy = Backbone.Model.extend({ } else { graderCollection = new CourseGraderCollection(attributes.graders, {parse:true}); - graderCollection.course_location = attributes['course_location'] || this.get('course_location'); } attributes.graders = graderCollection; } @@ -35,10 +30,6 @@ var CourseGradingPolicy = Backbone.Model.extend({ } return attributes; }, - url : function() { - var location = this.get('course_location'); - return '/' + location.get('org') + "/" + location.get('course') + '/settings-details/' + location.get('name') + '/section/grading'; - }, gracePeriodToDate : function() { var newDate = new Date(); if (this.has('grace_period') && this.get('grace_period')['hours']) diff --git a/cms/static/js/spec/models/explicit_url_spec.js b/cms/static/js/spec/models/explicit_url_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..df70d47b63ffbe51b9236e68ac56c859564a2470 --- /dev/null +++ b/cms/static/js/spec/models/explicit_url_spec.js @@ -0,0 +1,12 @@ +define(['js/models/explicit_url'], + function (Model) { + describe('Model ', function () { + it('allows url to be passed in constructor', function () { + expect(new Model({'explicit_url': '/fancy/url'}).url()).toBe('/fancy/url'); + }); + it('returns empty string if url not set', function () { + expect(new Model().url()).toBe(''); + }); + }); + } +); diff --git a/cms/static/js/views/course_info_handout.js b/cms/static/js/views/course_info_handout.js index f9804d03a445f21b86c0bb79b031d2ccbb5b0686..9309deda1b47be515502796ab8ed3a275fe80218 100644 --- a/cms/static/js/views/course_info_handout.js +++ b/cms/static/js/views/course_info_handout.js @@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" model: this.model })) ); + $('.handouts-content').html(this.model.get('data')); this.$preview = this.$el.find('.handouts-content'); this.$form = this.$el.find(".edit-handouts-form"); this.$editor = this.$form.find('.handouts-content-editor'); @@ -50,32 +51,43 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" }, onSave: function(event) { - this.model.set('data', this.$codeMirror.getValue()); - var saving = new NotificationView.Mini({ - title: gettext('Saving…') - }); - saving.show(); - this.model.save({}, { - success: function() { - saving.hide(); - } - }); - this.render(); - this.$form.hide(); - this.closeEditor(); - - analytics.track('Saved Course Handouts', { - 'course': course_location_analytics - }); + $('#handout_error').removeClass('is-shown'); + $('.save-button').removeClass('is-disabled'); + if ($('.CodeMirror-lines').find('.cm-error').length == 0){ + this.model.set('data', this.$codeMirror.getValue()); + var saving = new NotificationView.Mini({ + title: gettext('Saving…') + }); + saving.show(); + this.model.save({}, { + success: function() { + saving.hide(); + } + }); + this.render(); + this.$form.hide(); + this.closeEditor(); + analytics.track('Saved Course Handouts', { + 'course': course_location_analytics + }); + }else{ + $('#handout_error').addClass('is-shown'); + $('.save-button').addClass('is-disabled'); + event.preventDefault(); + } }, onCancel: function(event) { + $('#handout_error').removeClass('is-shown'); + $('.save-button').removeClass('is-disabled'); this.$form.hide(); this.closeEditor(); }, closeEditor: function() { + $('#handout_error').removeClass('is-shown'); + $('.save-button').removeClass('is-disabled'); this.$form.hide(); ModalUtils.hideModalCover(); this.$form.find('.CodeMirror').remove(); diff --git a/cms/static/js/views/course_info_helper.js b/cms/static/js/views/course_info_helper.js index ec4a6ba550753861a8cc4041b3c3c312a4c246fb..fb3474cdb0cab4cfb0cbb91479662947415e1c02 100644 --- a/cms/static/js/views/course_info_helper.js +++ b/cms/static/js/views/course_info_helper.js @@ -6,7 +6,10 @@ define(["codemirror", "utility"], var $codeMirror = CodeMirror.fromTextArea(textArea, { mode: "text/html", lineNumbers: true, - lineWrapping: true + lineWrapping: true, + onChange: function () { + $('.save-button').removeClass('is-disabled'); + } }); $codeMirror.setValue(content); $codeMirror.clearHistory(); diff --git a/cms/static/js/views/overview_assignment_grader.js b/cms/static/js/views/overview_assignment_grader.js index 40e93496930c105614e62b94629ab282ac2ca693..b7b501f572ebbff1d9660b62c6c69133f1dc4a0e 100644 --- a/cms/static/js/views/overview_assignment_grader.js +++ b/cms/static/js/views/overview_assignment_grader.js @@ -21,7 +21,7 @@ define(["backbone", "underscore", "gettext", "js/models/assignment_grade", "js/v '<li><a class="gradable-status-notgraded" href="#">Not Graded</a></li>' + '</ul>'); this.assignmentGrade = new AssignmentGrade({ - assignmentUrl : this.$el.closest('.id-holder').data('id'), + locator : this.$el.closest('.id-holder').data('locator'), graderType : this.$el.data('initial-status')}); // TODO throw exception if graders is null this.graders = this.options['graders']; diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index ded2781f666683df4a0ca0602e3a1f3e88ecb45b..63776829c3bd1bcc3d273fe6412ecc7a7fdc667e 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -21,9 +21,9 @@ var DetailsView = ValidatingView.extend({ initialize : function() { this.fileAnchorTemplate = _.template('<a href="<%= fullpath %>"> <i class="icon-file"></i><%= filename %></a>'); // fill in fields - this.$el.find("#course-name").val(this.model.get('location').get('name')); - this.$el.find("#course-organization").val(this.model.get('location').get('org')); - this.$el.find("#course-number").val(this.model.get('location').get('course')); + this.$el.find("#course-organization").val(this.model.get('org')); + this.$el.find("#course-number").val(this.model.get('course_id')); + this.$el.find("#course-name").val(this.model.get('run')); this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); // Avoid showing broken image on mistyped/nonexistent image diff --git a/cms/static/sass/contexts/_ie.scss b/cms/static/sass/contexts/_ie.scss index 4599ec3e6897e520f270cfa2e15151bf564f1d0e..ed9484f010bd28ad1ad25c6a9f06d931787425b1 100644 --- a/cms/static/sass/contexts/_ie.scss +++ b/cms/static/sass/contexts/_ie.scss @@ -10,6 +10,10 @@ &.is-shown { bottom: 0; } + + &.is-hiding { + bottom: -($ui-notification-height); + } } } diff --git a/cms/static/sass/elements/_xmodules.scss b/cms/static/sass/elements/_xmodules.scss index 576fd9549b5506d42bc1c5fad0549ceeab2e0c8a..8ffccfef2da8fa8aeb654942e31104407b7a72b3 100644 --- a/cms/static/sass/elements/_xmodules.scss +++ b/cms/static/sass/elements/_xmodules.scss @@ -1,4 +1,15 @@ -// studio - elements - xmodules +// studio - elements - xmodules & xblocks +// ==================== + +// general - display mode (xblock-student_view or xmodule_display) +.xmodule_display, .xblock-student_view { + + // font styling + i, em { + font-style: italic; + } +} + // ==================== // Video Alpha diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 5576664df7f3cd59ab3af03508c2d1b7c4fe016f..4f6f14a4669c0ca67cb7749279279905e98c67f1 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -187,7 +187,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass <a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr">${_('close')}</span></a> <div class="modal-body"> <h1 class="title">${_("Upload New File")}</h1> - <p class="file-name"></a> + <p class="file-name"> <div class="progress-bar"> <div class="progress-fill"></div> </div> diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html index 401d32ccb21ff9b28b4d056530318e93b49432b7..8cddb8a8c569eabcc052c689cc356da12b39d5de 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -33,7 +33,6 @@ require(["domReady!", "jquery", "js/collections/course_update", "js/models/modul var editor = new CourseInfoEditView({ el: $('.main-wrapper'), model : new CourseInfoModel({ - courseId : '${context_course.location}', updates : course_updates, base_asset_url : '${base_asset_url}', handouts : course_handouts diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index 2dba71c2793fadd45f7226c66364943108bf54c6..70c3e61801dbaf20fdafa4e9dad29b1a3bd2b1f6 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -9,12 +9,15 @@ <%block name="jsextra"> <script type='text/javascript'> -require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView) { +require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel, TabsEditView) { + var model = new TabsModel({ + id: "${course_locator}", + explicit_url: "${course_locator.url_reverse('tabs')}" + }); + new TabsEditView({ el: $('.main-wrapper'), - model: new Backbone.Model({ - id: '${locator}' - }), + model: model, mast: $('.wrapper-mast') }); }); @@ -61,8 +64,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView) <div class="tab-list"> <ol class='components'> - % for id, locator in components: - <li class="component" data-id="${id}" data-locator="${locator}"/> + % for locator in components: + <li class="component" data-locator="${locator}"/> % endfor <li class="new-component-item"> diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index d4676fb9fb9ff49ff493596bb8a73ed7ac31f46f..7947d9dd4347d7af5ddfcd731678790518fea87d 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -31,7 +31,7 @@ </div> <div class="sidebar"> - <div class="unit-settings window id-holder" data-id="${subsection.location}"> + <div class="unit-settings window id-holder" data-locator="${locator}"> <h4 class="header">${_("Subsection Settings")}</h4> <div class="window-contents"> <div class="scheduled-date-input row"> @@ -115,7 +115,6 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm // but we really should change that behavior. if (!window.graderTypes) { window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); - window.graderTypes.course_location = new Location('${parent_location}'); } $(".gradable-status").each(function(index, ele) { diff --git a/cms/templates/js/course_info_handouts.underscore b/cms/templates/js/course_info_handouts.underscore index 7fbbe9bc33e4c73f80ee2ea3db9e5473c3cd800d..6ce8518a32f7ac882e0b21bedf9b6fcd31aa947c 100644 --- a/cms/templates/js/course_info_handouts.underscore +++ b/cms/templates/js/course_info_handouts.underscore @@ -3,12 +3,13 @@ <h2 class="title">Course Handouts</h2> <%if (model.get('data') != null) { %> <div class="handouts-content"> - <%= model.get('data') %> + </div> <% } else {%> <p>${_("You have no handouts defined")}</p> <% } %> <form class="edit-handouts-form" style="display: block;"> + <div class="message message-status error" name="handout_html_error" id="handout_error"><%=gettext("There is invalid code in your content. Please check to make sure it is valid HTML.")%></div> <div class="row"> <textarea class="handouts-content-editor text-editor"></textarea> </div> diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 6ca51e18c8005c87ca0722caca5bb4a9d3f1aace..61b00278a4349f680f50a9808907a357c952bbea 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -27,7 +27,6 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v // but we really should change that behavior. if (!window.graderTypes) { window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); - window.graderTypes.course_location = new Location('${parent_location}'); } $(".gradable-status").each(function(index, ele) { @@ -200,7 +199,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v context_course.location.course_id, subsection.location, False, True ) %> - <li class="courseware-subsection branch collapsed id-holder is-draggable" data-id="${subsection.location}" + <li class="courseware-subsection branch collapsed id-holder is-draggable" data-parent="${section_locator}" data-locator="${subsection_locator}"> <%include file="widgets/_ui-dnd-indicator-before.html" /> @@ -208,7 +207,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v <div class="section-item"> <div class="details"> <a href="#" data-tooltip="${_('Expand/collapse this subsection')}" class="expand-collapse-icon expand"></a> - <a href="${reverse('edit_subsection', args=[subsection.location])}"> + <a href="${subsection_locator.url_reverse('subsection')}"> <span class="folder-icon"></span> <span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span> </a> diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 33a5360da0511cc657e6a2787e76a9cd75fb8c29..744f9cb1127cdb4d56ddc0436d477194104033ad 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -4,10 +4,8 @@ <%namespace name='static' file='static_content.html'/> <%! - from contentstore import utils from django.utils.translation import ugettext as _ from xmodule.modulestore.django import loc_mapper - from django.core.urlresolvers import reverse %> <%block name="jsextra"> @@ -69,17 +67,20 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s <ol class="list-input"> <li class="field text is-not-editable" id="field-course-organization"> <label for="course-organization">${_("Organization")}</label> - <input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-organization" value="[Course Organization]" readonly /> + <input title="${_('This field is disabled: this information cannot be changed.')}" type="text" + class="long" id="course-organization" readonly /> </li> <li class="field text is-not-editable" id="field-course-number"> <label for="course-number">${_("Course Number")}</label> - <input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="short" id="course-number" value="[Course No.]" readonly> + <input title="${_('This field is disabled: this information cannot be changed.')}" type="text" + class="short" id="course-number" readonly> </li> <li class="field text is-not-editable" id="field-course-name"> <label for="course-name">${_("Course Name")}</label> - <input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-name" value="[Course Name]" readonly /> + <input title="${_('This field is disabled: this information cannot be changed.')}" type="text" + class="long" id="course-name" readonly /> </li> </ol> @@ -87,12 +88,14 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s <div class="note note-promotion note-promotion-courseURL has-actions"> <h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3> <div class="copy"> - <p><a class="link-courseURL" rel="external" href="https:${utils.get_lms_link_for_about_page(course_location)}" />https:${utils.get_lms_link_for_about_page(course_location)}</a></p> + <p><a class="link-courseURL" rel="external" href="https:${lms_link_for_about_page}">https:${lms_link_for_about_page}</a></p> </div> <ul class="list-actions"> <li class="action-item"> - <a title="${_('Send a note to students via email')}" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20"${context_course.display_name_with_default}",%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a> + <a title="${_('Send a note to students via email')}" + href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20"${context_course.display_name_with_default}",%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${lms_link_for_about_page}%20to%20enroll." class="action action-primary"> + <i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a> </li> </ul> </div> @@ -199,7 +202,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s <%def name='overview_text()'><% a_link_start = '<a class="link-courseURL" rel="external" href="' a_link_end = '">' + _("your course summary page") + '</a>' - a_link = a_link_start + utils.get_lms_link_for_about_page(course_location) + a_link_end + a_link = a_link_start + lms_link_for_about_page + a_link_end text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link %>${text}</%def> <span class="tip tip-stacked">${overview_text()}</span> @@ -211,15 +214,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s <div class="current current-course-image"> % if context_course.course_image: <span class="wrapper-course-image"> - <img class="course-image" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/> + <img class="course-image" id="course-image" src="${course_image_url}" alt="${_('Course Image')}"/> </span> - <% ctx_loc = context_course.location %> - <span class="msg msg-help">${_("You can manage this image along with all of your other")} <a href='${upload_asset_url}'>${_("files & uploads")}</a></span> + <span class="msg msg-help"> + ${_("You can manage this image along with all of your other <a href='{}'>files & uploads</a>").format(upload_asset_url)} + </span> % else: <span class="wrapper-course-image"> - <img class="course-image placeholder" id="course-image" src="${utils.course_image_url(context_course)}" alt="${_('Course Image')}"/> + <img class="course-image placeholder" id="course-image" src="${course_image_url}" alt="${_('Course Image')}"/> </span> <span class="msg msg-empty">${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")}</span> % endif @@ -286,16 +290,16 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s <div class="bit"> % if context_course: <% - ctx_loc = context_course.location - location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) - course_team_url = location.url_reverse('course_team/', '') + course_team_url = course_locator.url_reverse('course_team/', '') + grading_config_url = course_locator.url_reverse('settings/grading/') + advanced_config_url = course_locator.url_reverse('settings/advanced/') %> <h3 class="title-3">${_("Other Course Settings")}</h3> <nav class="nav-related"> <ul> - <li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li> + <li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> - <li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li> + <li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li> </ul> </nav> % endif diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index 4f70f4dab48c2d0b81e4b7178c7c9304692366a5..0735992a9a1a8d9021605488219fd9ef824b7f68 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -1,11 +1,9 @@ <%inherit file="base.html" /> <%namespace name='static' file='static_content.html'/> <%! - from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from contentstore import utils from xmodule.modulestore.django import loc_mapper - from django.core.urlresolvers import reverse %> <%block name="title">${_("Advanced Settings")}</%block> <%block name="bodyclass">is-signedin course advanced view-settings</%block> @@ -28,7 +26,7 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting // proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern var advancedModel = new AdvancedSettingsModel(${advanced_dict | n}, {parse: true}); - advancedModel.url = "${reverse('course_advanced_settings_updates', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}"; + advancedModel.url = "${advanced_settings_url}"; var editor = new AdvancedSettingsView({ el: $('.settings-advanced'), @@ -91,13 +89,15 @@ require(["domReady!", "jquery", "js/models/settings/advanced", "js/views/setting <% ctx_loc = context_course.location location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) + details_url = location.url_reverse('settings/details/') + grading_url = location.url_reverse('settings/grading/') course_team_url = location.url_reverse('course_team/', '') %> <h3 class="title-3">${_("Other Course Settings")}</h3> <nav class="nav-related"> <ul> - <li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details & Schedule")}</a></li> - <li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li> + <li class="nav-item"><a href="${details_url}">${_("Details & Schedule")}</a></li> + <li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> </ul> </nav> diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index 024809b00c06e659bb34e1c85fc7abf3eb7890ae..a892dca4735d1aa0de5f6f45ef639c9ff352dee3 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -7,7 +7,6 @@ from contentstore import utils from django.utils.translation import ugettext as _ from xmodule.modulestore.django import loc_mapper - from django.core.urlresolvers import reverse %> <%block name="header_extras"> @@ -28,9 +27,11 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings $("label").removeClass("is-focused"); }); + var model = new CourseGradingPolicyModel(${course_details|n},{parse:true}); + model.urlRoot = '${grading_url}'; var editor = new GradingView({ el: $('.settings-grading'), - model : new CourseGradingPolicyModel(${course_details|n},{parse:true}) + model : model }); editor.render(); @@ -137,16 +138,16 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings <div class="bit"> % if context_course: <% - ctx_loc = context_course.location - location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) - course_team_url = location.url_reverse('course_team/', '') + course_team_url = course_locator.url_reverse('course_team/') + advanced_settings_url = course_locator.url_reverse('settings/advanced/') + detailed_settings_url = course_locator.url_reverse('settings/details/') %> <h3 class="title-3">${_("Other Course Settings")}</h3> <nav class="nav-related"> <ul> - <li class="nav-item"><a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Details & Schedule")}</a></li> + <li class="nav-item"><a href="${detailed_settings_url}">${_("Details & Schedule")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> - <li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li> + <li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li> </ul> </nav> % endif diff --git a/cms/templates/unit.html b/cms/templates/unit.html index cc7827c7d3a2e925bcce7b9d3e2a7350f80e09d6..9f3daf0b20c8601edf044aabd67f09e7e6f32591 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -34,7 +34,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" </%block> <%block name="content"> - <div class="main-wrapper edit-state-${unit_state}" data-id="${unit_location}" data-locator="${unit_locator}"> + <div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_locator}"> <div class="inner-wrapper"> <div class="alert editing-draft-alert"> <p class="alert-message"><strong>${_("You are editing a draft.")}</strong> @@ -49,7 +49,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" <p class="unit-name-input"><label>${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" class="unit-display-name-input" /></p> <ol class="components"> % for id, locator in components: - <li class="component" data-id="${id}" data-locator="${locator}"/> + <li class="component" data-locator="${locator}" data-id="${id}" /> % endfor <li class="new-component-item adding"> <div class="new-component"> @@ -135,6 +135,13 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" </article> </div> + <% + ctx_loc = context_course.location + index_url = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True).url_reverse('course') + subsection_url = loc_mapper().translate_location( + ctx_loc.course_id, subsection.location, False, True + ).url_reverse('subsection') + %> <div class="sidebar"> <div class="unit-settings window"> <h4 class="header">${_("Unit Settings")}</h4> @@ -157,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" % endif ${_("with the subsection {link_start}{name}{link_end}").format( name=subsection.display_name_with_default, - link_start='<a href="{url}">'.format(url=reverse('edit_subsection', kwargs={'location': subsection.location})), + link_start='<a href="{url}">'.format(url=subsection_url), link_end='</a>', )} </p> @@ -175,19 +182,15 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" <div class="row wrapper-unit-id"> <p class="unit-id"> <span class="label">${_("Unit Identifier:")}</span> - <input type="text" class="url value" value="${unit.location.name}" disabled /> + <input type="text" class="url value" value="${unit.location.name}" readonly /> </p> </div> <ol> <li> - <% - ctx_loc = context_course.location - index_url = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True).url_reverse('course/', '') - %> <a href="${index_url}" class="section-item">${section.display_name_with_default}</a> <ol> <li> - <a href="${reverse('edit_subsection', args=[subsection.location])}" class="section-item"> + <a href="${subsection_url}" class="section-item"> <span class="folder-icon"></span> <span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span> </a> diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 559304e32a553e9893ee2ccba8a350d4bb913a6b..b5d6c80d9cc3a4d0b3328c1dbe4282c0cf15f821 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -16,13 +16,17 @@ <% ctx_loc = context_course.location location = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) - index_url = location.url_reverse('course/') - checklists_url = location.url_reverse('checklists/') - course_team_url = location.url_reverse('course_team/') - assets_url = location.url_reverse('assets/') - import_url = location.url_reverse('import/') - course_info_url = location.url_reverse('course_info/') - export_url = location.url_reverse('export/', '') + index_url = location.url_reverse('course') + checklists_url = location.url_reverse('checklists') + course_team_url = location.url_reverse('course_team') + assets_url = location.url_reverse('assets') + import_url = location.url_reverse('import') + course_info_url = location.url_reverse('course_info') + export_url = location.url_reverse('export') + settings_url = location.url_reverse('settings/details/') + grading_url = location.url_reverse('settings/grading/') + advanced_settings_url = location.url_reverse('settings/advanced/') + tabs_url = location.url_reverse('tabs') %> <h2 class="info-course"> <span class="sr">${_("Current Course:")}</span> @@ -48,7 +52,7 @@ <a href="${course_info_url}">${_("Updates")}</a> </li> <li class="nav-item nav-course-courseware-pages"> - <a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}">${_("Static Pages")}</a> + <a href="${tabs_url}">${_("Static Pages")}</a> </li> <li class="nav-item nav-course-courseware-uploads"> <a href="${assets_url}">${_("Files & Uploads")}</a> @@ -68,16 +72,16 @@ <div class="nav-sub"> <ul> <li class="nav-item nav-course-settings-schedule"> - <a href="${reverse('contentstore.views.get_course_settings', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}">${_("Schedule & Details")}</a> + <a href="${settings_url}">${_("Schedule & Details")}</a> </li> <li class="nav-item nav-course-settings-grading"> - <a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a> + <a href="${grading_url}">${_("Grading")}</a> </li> <li class="nav-item nav-course-settings-team"> <a href="${course_team_url}">${_("Course Team")}</a> </li> <li class="nav-item nav-course-settings-advanced"> - <a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a> + <a href="${advanced_settings_url}">${_("Advanced Settings")}</a> </li> </ul> </div> diff --git a/cms/templates/widgets/problem-edit.html b/cms/templates/widgets/problem-edit.html index 94c6a578e109a48447a40a386c0e01cf31055a16..2d23a6f441857282e312f41274eebfa943a5b4f6 100644 --- a/cms/templates/widgets/problem-edit.html +++ b/cms/templates/widgets/problem-edit.html @@ -39,7 +39,7 @@ <div class="row"> <h6>${_("Heading 1")}</h6> <div class="col sample heading-1"> - <img src="${static.url("/img/header-example.png")}" /> + <img src="${static.url("img/header-example.png")}" /> </div> <div class="col"> <pre><code>H1 @@ -75,7 +75,9 @@ <img src="${static.url("img/string-example.png")}" /> </div> <div class="col"> - <pre><code>= dog</code></pre> + <pre><code>= dog +or= cat +or= mouse</code></pre> </div> </div> <div class="row"> diff --git a/cms/templates/widgets/segment-io.html b/cms/templates/widgets/segment-io.html index a6a186b2a3c2fafa6e252dd5defa3a16d782e66d..d250e9c23b5d3234768c30070b50d0630055a3db 100644 --- a/cms/templates/widgets/segment-io.html +++ b/cms/templates/widgets/segment-io.html @@ -1,9 +1,20 @@ +<%! +from xmodule.modulestore.django import loc_mapper +%> + +% if context_course: +<% + ctx_loc = context_course.location + locator = loc_mapper().translate_location(ctx_loc.course_id, ctx_loc, False, True) +%> +% endif + % if settings.MITX_FEATURES.get('SEGMENT_IO'): <!-- begin Segment.io --> <script type="text/javascript"> // if inside course, inject the course location into the JS namespace %if context_course: - var course_location_analytics = "${context_course.location}"; + var course_location_analytics = "${locator}"; %endif var analytics=analytics||[];analytics.load=function(e){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=("https:"===document.location.protocol?"https://":"http://")+"d2dq2ahtl5zl1z.cloudfront.net/analytics.js/v1/"+e+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);var r=function(e){return function(){analytics.push([e].concat(Array.prototype.slice.call(arguments,0)))}},i=["identify","track","trackLink","trackForm","trackClick","trackSubmit","pageview","ab","alias","ready"];for(var s=0;s<i.length;s++)analytics[i[s]]=r(i[s])}; @@ -22,7 +33,7 @@ <!-- dummy segment.io --> <script type="text/javascript"> %if context_course: - var course_location_analytics = "${context_course.location}"; + var course_location_analytics = "${locator}"; %endif var analytics = { "track": function() {} diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index 58d9c50194d55b171e7fecd66a06c25e3ff8a780..edc32ed947f2afa3c480fca8af08e0b5d7ff8fa4 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -31,7 +31,7 @@ This def will enumerate through a passed in subsection and list all of the units selected_class = '' %> <div class="section-item ${selected_class}"> - <a href="${reverse('edit_unit', args=[unit.location])}" class="${unit_state}-item"> + <a href="${unit_locator.url_reverse('unit')}" class="${unit_state}-item"> <span class="${unit.scope_ids.block_type}-icon"></span> <span class="unit-name">${unit.display_name_with_default}</span> </a> diff --git a/cms/urls.py b/cms/urls.py index 99e9cbfaba297fffac586b590d648900734c60ef..e17d4c729a0f238cb5f2af96d2769d6482c7db83 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -11,10 +11,6 @@ from ratelimitbackend import admin admin.autodiscover() urlpatterns = patterns('', # nopep8 - url(r'^$', 'contentstore.views.howitworks', name='homepage'), - url(r'^edit/(?P<location>.*?)$', 'contentstore.views.edit_unit', name='edit_unit'), - url(r'^subsection/(?P<location>.*?)$', 'contentstore.views.edit_subsection', name='edit_subsection'), - url(r'^preview_component/(?P<location>.*?)$', 'contentstore.views.preview_component', name='preview_component'), url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'), url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'), @@ -24,35 +20,9 @@ urlpatterns = patterns('', # nopep8 url(r'^transcripts/rename$', 'contentstore.views.rename_transcripts', name='rename_transcripts'), url(r'^transcripts/save$', 'contentstore.views.save_transcripts', name='save_transcripts'), - url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'), - url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'), - url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'), - url(r'^reorder_static_tabs', 'contentstore.views.reorder_static_tabs', name='reorder_static_tabs'), - url(r'^preview/xblock/(?P<usage_id>.*?)/handler/(?P<handler>[^/]*)(?:/(?P<suffix>[^/]*))?$', 'contentstore.views.preview_handler', name='preview_handler'), - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)$', - 'contentstore.views.get_course_settings', name='settings_details'), - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)$', - 'contentstore.views.course_config_graders_page', name='settings_grading'), - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-details/(?P<name>[^/]+)/section/(?P<section>[^/]+).*$', - 'contentstore.views.course_settings_updates', name='course_settings'), - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-grading/(?P<name>[^/]+)/(?P<grader_index>.*)$', - 'contentstore.views.course_grader_updates', name='course_settings'), - # This is the URL to initially render the course advanced settings. - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)$', - 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'), - # This is the URL used by BackBone for updating and re-fetching the model. - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/settings-advanced/(?P<name>[^/]+)/update.*$', - 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'), - - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/(?P<category>[^/]+)/(?P<name>[^/]+)/gradeas.*$', - 'contentstore.views.assignment_type_update', name='assignment_type_update'), - - url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', - 'contentstore.views.edit_tabs', name='edit_tabs'), - url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)$', 'contentstore.views.textbook_index', name='textbook_index'), url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/textbooks/(?P<name>[^/]+)/new$', @@ -79,18 +49,12 @@ urlpatterns = patterns('', # nopep8 # User creation and updating views urlpatterns += patterns( '', - url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'), - url(r'^signup$', 'contentstore.views.signup', name='signup'), url(r'^create_account$', 'student.views.create_account'), url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'), - # form page - url(r'^login$', 'contentstore.views.old_login_redirect', name='old_login'), - url(r'^signin$', 'contentstore.views.login_page', name='login'), # ajax view that actually does the work url(r'^login_post$', 'student.views.login_user', name='login_post'), - url(r'^logout$', 'student.views.logout_user', name='logout'), ) @@ -98,7 +62,12 @@ urlpatterns += patterns( urlpatterns += patterns( 'contentstore.views', + url(r'^$', 'howitworks', name='homepage'), + url(r'^howitworks$', 'howitworks'), + url(r'^signup$', 'signup', name='signup'), + url(r'^signin$', 'login_page', name='login'), url(r'^request_course_creator$', 'request_course_creator'), + # (?ix) == ignore case and verbose (multiline regex) url(r'(?ix)^course_team/{}(/)?(?P<email>.+)?$'.format(parsers.URL_RE_SOURCE), 'course_team_handler'), url(r'(?ix)^course_info/{}$'.format(parsers.URL_RE_SOURCE), 'course_info_handler'), @@ -107,6 +76,8 @@ urlpatterns += patterns( 'course_info_update_handler' ), url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'), + url(r'(?ix)^subsection($|/){}$'.format(parsers.URL_RE_SOURCE), 'subsection_handler'), + url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_handler'), url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'), url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'), url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'), @@ -114,6 +85,10 @@ urlpatterns += patterns( url(r'(?ix)^import_status/{}/(?P<filename>.+)$'.format(parsers.URL_RE_SOURCE), 'import_status_handler'), url(r'(?ix)^export/{}$'.format(parsers.URL_RE_SOURCE), 'export_handler'), url(r'(?ix)^xblock($|/){}$'.format(parsers.URL_RE_SOURCE), 'xblock_handler'), + url(r'(?ix)^tabs/{}$'.format(parsers.URL_RE_SOURCE), 'tabs_handler'), + url(r'(?ix)^settings/details/{}$'.format(parsers.URL_RE_SOURCE), 'settings_handler'), + url(r'(?ix)^settings/grading/{}(/)?(?P<grader_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'grading_handler'), + url(r'(?ix)^settings/advanced/{}$'.format(parsers.URL_RE_SOURCE), 'advanced_settings_handler'), ) js_info_dict = { diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 5872955780811cc2bb2c7267e97771ccbf4ffcaa..a995dff22b56e0a3fdb2a80455cd91d7d65f6a46 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -21,7 +21,7 @@ from django.core.exceptions import ValidationError if settings.MITX_FEATURES.get('AUTH_USE_CAS'): from django_cas.views import login as django_cas_login -from student.models import UserProfile, TestCenterUser, TestCenterRegistration +from student.models import UserProfile from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden from django.utils.http import urlquote, is_safe_url @@ -880,146 +880,7 @@ def provider_xrds(request): return response -#------------------- -# Pearson -#------------------- def course_from_id(course_id): """Return the CourseDescriptor corresponding to this course_id""" course_loc = CourseDescriptor.id_to_location(course_id) return modulestore().get_instance(course_id, course_loc) - - -@csrf_exempt -def test_center_login(request): - ''' Log in students taking exams via Pearson - - Takes a POST request that contains the following keys: - - code - a security code provided by Pearson - - clientCandidateID - - registrationID - - exitURL - the url that we redirect to once we're done - - vueExamSeriesCode - a code that indicates the exam that we're using - ''' - # Imports from lms/djangoapps/courseware -- these should not be - # in a common djangoapps. - from courseware.views import get_module_for_descriptor, jump_to - from courseware.model_data import FieldDataCache - - # errors are returned by navigating to the error_url, adding a query parameter named "code" - # which contains the error code describing the exceptional condition. - def makeErrorURL(error_url, error_code): - log.error("generating error URL with error code {}".format(error_code)) - return "{}?code={}".format(error_url, error_code) - - # get provided error URL, which will be used as a known prefix for returning error messages to the - # Pearson shell. - error_url = request.POST.get("errorURL") - - # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson - # with the code we calculate for the same parameters. - if 'code' not in request.POST: - return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode")) - code = request.POST.get("code") - - # calculate SHA for query string - # TODO: figure out how to get the original query string, so we can hash it and compare. - - if 'clientCandidateID' not in request.POST: - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")) - client_candidate_id = request.POST.get("clientCandidateID") - - # TODO: check remaining parameters, and maybe at least log if they're not matching - # expected values.... - # registration_id = request.POST.get("registrationID") - # exit_url = request.POST.get("exitURL") - - # find testcenter_user that matches the provided ID: - try: - testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) - except TestCenterUser.DoesNotExist: - AUDIT_LOG.error("not able to find demographics for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")) - - AUDIT_LOG.info("Attempting to log in test-center user '{}' for test of cand {}".format(testcenteruser.user.username, client_candidate_id)) - - # find testcenter_registration that matches the provided exam code: - # Note that we could rely in future on either the registrationId or the exam code, - # or possibly both. But for now we know what to do with an ExamSeriesCode, - # while we currently have no record of RegistrationID values at all. - if 'vueExamSeriesCode' not in request.POST: - # we are not allowed to make up a new error code, according to Pearson, - # so instead of "missingExamSeriesCode", we use a valid one that is - # inaccurate but at least distinct. (Sigh.) - AUDIT_LOG.error("missing exam series code for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")) - exam_series_code = request.POST.get('vueExamSeriesCode') - - registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) - if not registrations: - AUDIT_LOG.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")) - - # TODO: figure out what to do if there are more than one registrations.... - # for now, just take the first... - registration = registrations[0] - - course_id = registration.course_id - course = course_from_id(course_id) # assume it will be found.... - if not course: - AUDIT_LOG.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) - exam = course.get_test_center_exam(exam_series_code) - if not exam: - AUDIT_LOG.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) - location = exam.exam_url - log.info("Proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) - - # check if the test has already been taken - timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) - if not timelimit_descriptor: - log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) - - timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, - timelimit_module_cache, course_id, position=None) - if not timelimit_module.category == 'timelimit': - log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) - - if timelimit_module and timelimit_module.has_ended: - AUDIT_LOG.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) - return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")) - - # check if we need to provide an accommodation: - time_accommodation_mapping = {'ET12ET': 'ADDHALFTIME', - 'ET30MN': 'ADD30MIN', - 'ETDBTM': 'ADDDOUBLE', } - - time_accommodation_code = None - for code in registration.get_accommodation_codes(): - if code in time_accommodation_mapping: - time_accommodation_code = time_accommodation_mapping[code] - - if time_accommodation_code: - timelimit_module.accommodation_code = time_accommodation_code - AUDIT_LOG.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) - - # UGLY HACK!!! - # Login assumes that authentication has occurred, and that there is a - # backend annotation on the user object, indicating which backend - # against which the user was authenticated. We're authenticating here - # against the registration entry, and assuming that the request given - # this information is correct, we allow the user to be logged in - # without a password. This could all be formalized in a backend object - # that does the above checking. - # TODO: (brian) create a backend class to do this. - # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") - login(request, testcenteruser.user) - AUDIT_LOG.info("Logged in user '{}' for test of cand {} on exam {} for course {}: URL = {}".format(testcenteruser.user.username, client_candidate_id, exam_series_code, course_id, location)) - - # And start the test: - return jump_to(request, course_id, location) diff --git a/common/djangoapps/student/management/commands/pearson_dump.py b/common/djangoapps/student/management/commands/pearson_dump.py deleted file mode 100644 index 0c9e215f77fe209a795c1d576af5dd583c0fb469..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_dump.py +++ /dev/null @@ -1,77 +0,0 @@ -from optparse import make_option -from json import dump -from datetime import datetime - -from django.core.management.base import BaseCommand - -from student.models import TestCenterRegistration - - -class Command(BaseCommand): - - args = '<output JSON file>' - help = """ - Dump information as JSON from TestCenterRegistration tables, including username and status. - """ - - option_list = BaseCommand.option_list + ( - make_option('--course_id', - action='store', - dest='course_id', - help='Specify a particular course.'), - make_option('--exam_series_code', - action='store', - dest='exam_series_code', - default=None, - help='Specify a particular exam, using the Pearson code'), - make_option('--accommodation_pending', - action='store_true', - dest='accommodation_pending', - default=False, - ), - ) - - def handle(self, *args, **options): - if len(args) < 1: - outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json") - else: - outputfile = args[0] - - # construct the query object to dump: - registrations = TestCenterRegistration.objects.all() - if 'course_id' in options and options['course_id']: - registrations = registrations.filter(course_id=options['course_id']) - if 'exam_series_code' in options and options['exam_series_code']: - registrations = registrations.filter(exam_series_code=options['exam_series_code']) - - # collect output: - output = [] - for registration in registrations: - if 'accommodation_pending' in options and options['accommodation_pending'] and not registration.accommodation_is_pending: - continue - record = {'username': registration.testcenter_user.user.username, - 'email': registration.testcenter_user.email, - 'first_name': registration.testcenter_user.first_name, - 'last_name': registration.testcenter_user.last_name, - 'client_candidate_id': registration.client_candidate_id, - 'client_authorization_id': registration.client_authorization_id, - 'course_id': registration.course_id, - 'exam_series_code': registration.exam_series_code, - 'accommodation_request': registration.accommodation_request, - 'accommodation_code': registration.accommodation_code, - 'registration_status': registration.registration_status(), - 'demographics_status': registration.demographics_status(), - 'accommodation_status': registration.accommodation_status(), - } - if len(registration.upload_error_message) > 0: - record['registration_error'] = registration.upload_error_message - if len(registration.testcenter_user.upload_error_message) > 0: - record['demographics_error'] = registration.testcenter_user.upload_error_message - if registration.needs_uploading: - record['needs_uploading'] = True - - output.append(record) - - # dump output: - with open(outputfile, 'w') as outfile: - dump(output, outfile, indent=2) diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py deleted file mode 100644 index efb4a55387d5bff6f70bedf0d8aa76c4b695e837..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_export_cdd.py +++ /dev/null @@ -1,111 +0,0 @@ -import csv -import os -from collections import OrderedDict -from datetime import datetime -from optparse import make_option - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from student.models import TestCenterUser -from pytz import UTC - - -class Command(BaseCommand): - - CSV_TO_MODEL_FIELDS = OrderedDict([ - # Skipping optional field CandidateID - ("ClientCandidateID", "client_candidate_id"), - ("FirstName", "first_name"), - ("LastName", "last_name"), - ("MiddleName", "middle_name"), - ("Suffix", "suffix"), - ("Salutation", "salutation"), - ("Email", "email"), - # Skipping optional fields Username and Password - ("Address1", "address_1"), - ("Address2", "address_2"), - ("Address3", "address_3"), - ("City", "city"), - ("State", "state"), - ("PostalCode", "postal_code"), - ("Country", "country"), - ("Phone", "phone"), - ("Extension", "extension"), - ("PhoneCountryCode", "phone_country_code"), - ("FAX", "fax"), - ("FAXCountryCode", "fax_country_code"), - ("CompanyName", "company_name"), - # Skipping optional field CustomQuestion - ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store - ]) - - # define defaults, even thought 'store_true' shouldn't need them. - # (call_command will set None as default value for all options that don't have one, - # so one cannot rely on presence/absence of flags in that world.) - option_list = BaseCommand.option_list + ( - make_option('--dest-from-settings', - action='store_true', - dest='dest-from-settings', - default=False, - help='Retrieve the destination to export to from django.'), - make_option('--destination', - action='store', - dest='destination', - default=None, - help='Where to store the exported files') - ) - - def handle(self, **options): - # update time should use UTC in order to be comparable to the user_updated_at - # field - uploaded_at = datetime.now(UTC) - - # if specified destination is an existing directory, then - # create a filename for it automatically. If it doesn't exist, - # then we will create the directory. - # Name will use timestamp -- this is UTC, so it will look funny, - # but it should at least be consistent with the other timestamps - # used in the system. - if 'dest-from-settings' in options and options['dest-from-settings']: - if 'LOCAL_EXPORT' in settings.PEARSON: - dest = settings.PEARSON['LOCAL_EXPORT'] - else: - raise CommandError('--dest-from-settings was enabled but the' - 'PEARSON[LOCAL_EXPORT] setting was not set.') - elif 'destination' in options and options['destination']: - dest = options['destination'] - else: - raise CommandError('--destination or --dest-from-settings must be used') - - if not os.path.isdir(dest): - os.makedirs(dest) - - destfile = os.path.join(dest, uploaded_at.strftime("cdd-%Y%m%d-%H%M%S.dat")) - - # strings must be in latin-1 format. CSV parser will - # otherwise convert unicode objects to ascii. - def ensure_encoding(value): - if isinstance(value, unicode): - return value.encode('iso-8859-1') - else: - return value - -# dump_all = options['dump_all'] - - with open(destfile, "wb") as outfile: - writer = csv.DictWriter(outfile, - Command.CSV_TO_MODEL_FIELDS, - delimiter="\t", - quoting=csv.QUOTE_MINIMAL, - extrasaction='ignore') - writer.writeheader() - for tcu in TestCenterUser.objects.order_by('id'): - if tcu.needs_uploading: # or dump_all - record = dict((csv_field, ensure_encoding(getattr(tcu, model_field))) - for csv_field, model_field - in Command.CSV_TO_MODEL_FIELDS.items()) - record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S") - writer.writerow(record) - tcu.uploaded_at = uploaded_at - tcu.save() diff --git a/common/djangoapps/student/management/commands/pearson_export_ead.py b/common/djangoapps/student/management/commands/pearson_export_ead.py deleted file mode 100644 index ec10ab15996c2bac0fd37be895a0fa4b8ad9f99e..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_export_ead.py +++ /dev/null @@ -1,103 +0,0 @@ -import csv -import os -from collections import OrderedDict -from datetime import datetime -from optparse import make_option - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE -from pytz import UTC - - -class Command(BaseCommand): - - CSV_TO_MODEL_FIELDS = OrderedDict([ - ('AuthorizationTransactionType', 'authorization_transaction_type'), - ('AuthorizationID', 'authorization_id'), - ('ClientAuthorizationID', 'client_authorization_id'), - ('ClientCandidateID', 'client_candidate_id'), - ('ExamAuthorizationCount', 'exam_authorization_count'), - ('ExamSeriesCode', 'exam_series_code'), - ('Accommodations', 'accommodation_code'), - ('EligibilityApptDateFirst', 'eligibility_appointment_date_first'), - ('EligibilityApptDateLast', 'eligibility_appointment_date_last'), - ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store - ]) - - option_list = BaseCommand.option_list + ( - make_option('--dest-from-settings', - action='store_true', - dest='dest-from-settings', - default=False, - help='Retrieve the destination to export to from django.'), - make_option('--destination', - action='store', - dest='destination', - default=None, - help='Where to store the exported files'), - make_option('--dump_all', - action='store_true', - dest='dump_all', - default=False, - ), - make_option('--force_add', - action='store_true', - dest='force_add', - default=False, - ), - ) - - def handle(self, **options): - # update time should use UTC in order to be comparable to the user_updated_at - # field - uploaded_at = datetime.now(UTC) - - # if specified destination is an existing directory, then - # create a filename for it automatically. If it doesn't exist, - # then we will create the directory. - # Name will use timestamp -- this is UTC, so it will look funny, - # but it should at least be consistent with the other timestamps - # used in the system. - if 'dest-from-settings' in options and options['dest-from-settings']: - if 'LOCAL_EXPORT' in settings.PEARSON: - dest = settings.PEARSON['LOCAL_EXPORT'] - else: - raise CommandError('--dest-from-settings was enabled but the' - 'PEARSON[LOCAL_EXPORT] setting was not set.') - elif 'destination' in options and options['destination']: - dest = options['destination'] - else: - raise CommandError('--destination or --dest-from-settings must be used') - - if not os.path.isdir(dest): - os.makedirs(dest) - - destfile = os.path.join(dest, uploaded_at.strftime("ead-%Y%m%d-%H%M%S.dat")) - - dump_all = options['dump_all'] - - with open(destfile, "wb") as outfile: - writer = csv.DictWriter(outfile, - Command.CSV_TO_MODEL_FIELDS, - delimiter="\t", - quoting=csv.QUOTE_MINIMAL, - extrasaction='ignore') - writer.writeheader() - for tcr in TestCenterRegistration.objects.order_by('id'): - if dump_all or tcr.needs_uploading: - record = dict((csv_field, getattr(tcr, model_field)) - for csv_field, model_field - in Command.CSV_TO_MODEL_FIELDS.items()) - record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S") - record["EligibilityApptDateFirst"] = record["EligibilityApptDateFirst"].strftime("%Y/%m/%d") - record["EligibilityApptDateLast"] = record["EligibilityApptDateLast"].strftime("%Y/%m/%d") - if record["Accommodations"] == ACCOMMODATION_REJECTED_CODE: - record["Accommodations"] = "" - if options['force_add']: - record['AuthorizationTransactionType'] = 'Add' - - writer.writerow(record) - tcr.uploaded_at = uploaded_at - tcr.save() diff --git a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py b/common/djangoapps/student/management/commands/pearson_import_conf_zip.py deleted file mode 100644 index 3edb3a76d745d7a2f46b6ae4df18fc55b979f1a6..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py +++ /dev/null @@ -1,119 +0,0 @@ -import csv -from time import strptime, strftime -from datetime import datetime -from zipfile import ZipFile, is_zipfile - -from dogapi import dog_http_api -from pytz import UTC - -from django.core.management.base import BaseCommand, CommandError -from django.conf import settings - -import django_startup - -from student.models import TestCenterUser, TestCenterRegistration - - -django_startup.autostartup() - - -class Command(BaseCommand): - - args = '<input zip file>' - help = """ - Import Pearson confirmation files and update TestCenterUser - and TestCenterRegistration tables with status. - """ - - @staticmethod - def datadog_error(string, tags): - dog_http_api.event("Pearson Import", string, alert_type='error', tags=[tags]) - - def handle(self, *args, **kwargs): - if len(args) < 1: - print Command.help - return - - source_zip = args[0] - if not is_zipfile(source_zip): - error = "Input file is not a zipfile: \"{}\"".format(source_zip) - Command.datadog_error(error, source_zip) - raise CommandError(error) - - # loop through all files in zip, and process them based on filename prefix: - with ZipFile(source_zip, 'r') as zipfile: - for fileinfo in zipfile.infolist(): - with zipfile.open(fileinfo) as zipentry: - if fileinfo.filename.startswith("eac-"): - self.process_eac(zipentry) - elif fileinfo.filename.startswith("vcdc-"): - self.process_vcdc(zipentry) - else: - error = "Unrecognized confirmation file type\"{}\" in confirmation zip file \"{}\"".format(fileinfo.filename, zipfile) - Command.datadog_error(error, source_zip) - raise CommandError(error) - - def process_eac(self, eacfile): - print "processing eac" - reader = csv.DictReader(eacfile, delimiter="\t") - for row in reader: - client_authorization_id = row['ClientAuthorizationID'] - if not client_authorization_id: - if row['Status'] == 'Error': - Command.datadog_error("Error in EAD file processing ({}): {}".format(row['Date'], row['Message']), eacfile.name) - else: - Command.datadog_error("Encountered bad record: {}".format(row), eacfile.name) - else: - try: - registration = TestCenterRegistration.objects.get(client_authorization_id=client_authorization_id) - Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name) - # now update the record: - registration.upload_status = row['Status'] - registration.upload_error_message = row['Message'] - try: - registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S')) - except ValueError as ve: - Command.datadog_error("Bad Date value found for {}: message {}".format(client_authorization_id, ve), eacfile.name) - # store the authorization Id if one is provided. (For debugging) - if row['AuthorizationID']: - try: - registration.authorization_id = int(row['AuthorizationID']) - except ValueError as ve: - Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name) - - registration.confirmed_at = datetime.now(UTC) - registration.save() - except TestCenterRegistration.DoesNotExist: - Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name) - - def process_vcdc(self, vcdcfile): - print "processing vcdc" - reader = csv.DictReader(vcdcfile, delimiter="\t") - for row in reader: - client_candidate_id = row['ClientCandidateID'] - if not client_candidate_id: - if row['Status'] == 'Error': - Command.datadog_error("Error in CDD file processing ({}): {}".format(row['Date'], row['Message']), vcdcfile.name) - else: - Command.datadog_error("Encountered bad record: {}".format(row), vcdcfile.name) - else: - try: - tcuser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) - Command.datadog_error("Found demographics record for user {}".format(tcuser.user.username), vcdcfile.name) - # now update the record: - tcuser.upload_status = row['Status'] - tcuser.upload_error_message = row['Message'] - try: - tcuser.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S')) - except ValueError as ve: - Command.datadog_error("Bad Date value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name) - # store the candidate Id if one is provided. (For debugging) - if row['CandidateID']: - try: - tcuser.candidate_id = int(row['CandidateID']) - except ValueError as ve: - Command.datadog_error("Bad CandidateID value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name) - tcuser.confirmed_at = datetime.utcnow() - tcuser.save() - except TestCenterUser.DoesNotExist: - Command.datadog_error(" Failed to find record for client_candidate_id {}".format(client_candidate_id), vcdcfile.name) diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py deleted file mode 100644 index 50e56bb4bee986bfa5dc6b7a2c705c33884d7466..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py +++ /dev/null @@ -1,206 +0,0 @@ -from optparse import make_option - -from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError - -from student.models import TestCenterUser, TestCenterRegistration, TestCenterRegistrationForm, get_testcenter_registration -from student.views import course_from_id -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.exceptions import ItemNotFoundError - - -class Command(BaseCommand): - option_list = BaseCommand.option_list + ( - # registration info: - make_option( - '--accommodation_request', - action='store', - dest='accommodation_request', - ), - make_option( - '--accommodation_code', - action='store', - dest='accommodation_code', - ), - make_option( - '--client_authorization_id', - action='store', - dest='client_authorization_id', - ), - # exam info: - make_option( - '--exam_series_code', - action='store', - dest='exam_series_code', - ), - make_option( - '--eligibility_appointment_date_first', - action='store', - dest='eligibility_appointment_date_first', - help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.' - ), - make_option( - '--eligibility_appointment_date_last', - action='store', - dest='eligibility_appointment_date_last', - help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.' - ), - # internal values: - make_option( - '--authorization_id', - action='store', - dest='authorization_id', - help='ID we receive from Pearson for a particular authorization' - ), - make_option( - '--upload_status', - action='store', - dest='upload_status', - help='status value assigned by Pearson' - ), - make_option( - '--upload_error_message', - action='store', - dest='upload_error_message', - help='error message provided by Pearson on a failure.' - ), - # control values: - make_option( - '--ignore_registration_dates', - action='store_true', - dest='ignore_registration_dates', - help='find exam info for course based on exam_series_code, even if the exam is not active.' - ), - make_option( - '--create_dummy_exam', - action='store_true', - dest='create_dummy_exam', - help='create dummy exam info for course, even if course exists' - ), - ) - args = "<student_username course_id>" - help = "Create or modify a TestCenterRegistration entry for a given Student" - - @staticmethod - def is_valid_option(option_name): - base_options = set(option.dest for option in BaseCommand.option_list) - return option_name not in base_options - - - def handle(self, *args, **options): - username = args[0] - course_id = args[1] - print username, course_id - - our_options = dict((k, v) for k, v in options.items() - if Command.is_valid_option(k) and v is not None) - try: - student = User.objects.get(username=username) - except User.DoesNotExist: - raise CommandError("User \"{}\" does not exist".format(username)) - - try: - testcenter_user = TestCenterUser.objects.get(user=student) - except TestCenterUser.DoesNotExist: - raise CommandError("User \"{}\" does not have an existing demographics record".format(username)) - - # get an "exam" object. Check to see if a course_id was specified, and use information from that: - exam = None - create_dummy_exam = 'create_dummy_exam' in our_options and our_options['create_dummy_exam'] - if not create_dummy_exam: - try: - course = course_from_id(course_id) - if 'ignore_registration_dates' in our_options: - examlist = [exam for exam in course.test_center_exams if exam.exam_series_code == our_options.get('exam_series_code')] - exam = examlist[0] if len(examlist) > 0 else None - else: - exam = course.current_test_center_exam - except ItemNotFoundError: - pass - else: - # otherwise use explicit values (so we don't have to define a course): - exam_name = "Dummy Placeholder Name" - exam_info = {'Exam_Series_Code': our_options['exam_series_code'], - 'First_Eligible_Appointment_Date': our_options['eligibility_appointment_date_first'], - 'Last_Eligible_Appointment_Date': our_options['eligibility_appointment_date_last'], - } - exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info) - # update option values for date_first and date_last to use YYYY-MM-DD format - # instead of YYYY-MM-DDTHH:MM - our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d") - our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d") - - if exam is None: - raise CommandError("Exam for course_id {} does not exist".format(course_id)) - - exam_code = exam.exam_series_code - - UPDATE_FIELDS = ('accommodation_request', - 'accommodation_code', - 'client_authorization_id', - 'exam_series_code', - 'eligibility_appointment_date_first', - 'eligibility_appointment_date_last', - ) - - # create and save the registration: - needs_updating = False - registrations = get_testcenter_registration(student, course_id, exam_code) - if len(registrations) > 0: - registration = registrations[0] - for fieldname in UPDATE_FIELDS: - if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]: - needs_updating = True; - else: - accommodation_request = our_options.get('accommodation_request', '') - registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) - needs_updating = True - - - if needs_updating: - # first update the record with the new values, if any: - for fieldname in UPDATE_FIELDS: - if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields: - registration.__setattr__(fieldname, our_options[fieldname]) - - # the registration form normally populates the data dict with - # the accommodation request (if any). But here we want to - # specify only those values that might change, so update the dict with existing - # values. - form_options = dict(our_options) - for propname in TestCenterRegistrationForm.Meta.fields: - if propname not in form_options: - form_options[propname] = registration.__getattribute__(propname) - form = TestCenterRegistrationForm(instance=registration, data=form_options) - if form.is_valid(): - form.update_and_save() - print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code) - else: - if (len(form.errors) > 0): - print "Field Form errors encountered:" - for fielderror in form.errors: - for msg in form.errors[fielderror]: - print "Field Form Error: {} -- {}".format(fielderror, msg) - if (len(form.non_field_errors()) > 0): - print "Non-field Form errors encountered:" - for nonfielderror in form.non_field_errors: - print "Non-field Form Error: %s" % nonfielderror - - else: - print "No changes necessary to make to existing user's registration." - - # override internal values: - change_internal = False - if 'exam_series_code' in our_options: - exam_code = our_options['exam_series_code'] - registration = get_testcenter_registration(student, course_id, exam_code)[0] - for internal_field in ['upload_error_message', 'upload_status', 'authorization_id']: - if internal_field in our_options: - registration.__setattr__(internal_field, our_options[internal_field]) - change_internal = True - - if change_internal: - print "Updated confirmation information in existing user's registration." - registration.save() - else: - print "No changes necessary to make to confirmation information in existing user's registration." diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_user.py b/common/djangoapps/student/management/commands/pearson_make_tc_user.py deleted file mode 100644 index 10ef0bd067d2e72a8cddec1e6717b07da82fd134..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_make_tc_user.py +++ /dev/null @@ -1,190 +0,0 @@ -from optparse import make_option - -from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError - -from student.models import TestCenterUser, TestCenterUserForm - - -class Command(BaseCommand): - option_list = BaseCommand.option_list + ( - # demographics: - make_option( - '--first_name', - action='store', - dest='first_name', - ), - make_option( - '--middle_name', - action='store', - dest='middle_name', - ), - make_option( - '--last_name', - action='store', - dest='last_name', - ), - make_option( - '--suffix', - action='store', - dest='suffix', - ), - make_option( - '--salutation', - action='store', - dest='salutation', - ), - make_option( - '--address_1', - action='store', - dest='address_1', - ), - make_option( - '--address_2', - action='store', - dest='address_2', - ), - make_option( - '--address_3', - action='store', - dest='address_3', - ), - make_option( - '--city', - action='store', - dest='city', - ), - make_option( - '--state', - action='store', - dest='state', - help='Two letter code (e.g. MA)' - ), - make_option( - '--postal_code', - action='store', - dest='postal_code', - ), - make_option( - '--country', - action='store', - dest='country', - help='Three letter country code (ISO 3166-1 alpha-3), like USA' - ), - make_option( - '--phone', - action='store', - dest='phone', - help='Pretty free-form (parens, spaces, dashes), but no country code' - ), - make_option( - '--extension', - action='store', - dest='extension', - ), - make_option( - '--phone_country_code', - action='store', - dest='phone_country_code', - help='Phone country code, just "1" for the USA' - ), - make_option( - '--fax', - action='store', - dest='fax', - help='Pretty free-form (parens, spaces, dashes), but no country code' - ), - make_option( - '--fax_country_code', - action='store', - dest='fax_country_code', - help='Fax country code, just "1" for the USA' - ), - make_option( - '--company_name', - action='store', - dest='company_name', - ), - # internal values: - make_option( - '--client_candidate_id', - action='store', - dest='client_candidate_id', - help='ID we assign a user to identify them to Pearson' - ), - make_option( - '--upload_status', - action='store', - dest='upload_status', - help='status value assigned by Pearson' - ), - make_option( - '--upload_error_message', - action='store', - dest='upload_error_message', - help='error message provided by Pearson on a failure.' - ), - ) - args = "<student_username>" - help = "Create or modify a TestCenterUser entry for a given Student" - - @staticmethod - def is_valid_option(option_name): - base_options = set(option.dest for option in BaseCommand.option_list) - return option_name not in base_options - - - def handle(self, *args, **options): - username = args[0] - print username - - our_options = dict((k, v) for k, v in options.items() - if Command.is_valid_option(k) and v is not None) - student = User.objects.get(username=username) - try: - testcenter_user = TestCenterUser.objects.get(user=student) - needs_updating = testcenter_user.needs_update(our_options) - except TestCenterUser.DoesNotExist: - # do additional initialization here: - testcenter_user = TestCenterUser.create(student) - needs_updating = True - - if needs_updating: - # the registration form normally populates the data dict with - # all values from the testcenter_user. But here we only want to - # specify those values that change, so update the dict with existing - # values. - form_options = dict(our_options) - for propname in TestCenterUser.user_provided_fields(): - if propname not in form_options: - form_options[propname] = testcenter_user.__getattribute__(propname) - form = TestCenterUserForm(instance=testcenter_user, data=form_options) - if form.is_valid(): - form.update_and_save() - else: - errorlist = [] - if (len(form.errors) > 0): - errorlist.append("Field Form errors encountered:") - for fielderror in form.errors: - errorlist.append("Field Form Error: {}".format(fielderror)) - if (len(form.non_field_errors()) > 0): - errorlist.append("Non-field Form errors encountered:") - for nonfielderror in form.non_field_errors: - errorlist.append("Non-field Form Error: {}".format(nonfielderror)) - raise CommandError("\n".join(errorlist)) - else: - print "No changes necessary to make to existing user's demographics." - - # override internal values: - change_internal = False - testcenter_user = TestCenterUser.objects.get(user=student) - for internal_field in ['upload_error_message', 'upload_status', 'client_candidate_id']: - if internal_field in our_options: - testcenter_user.__setattr__(internal_field, our_options[internal_field]) - change_internal = True - - if change_internal: - testcenter_user.save() - print "Updated confirmation information in existing user's demographics." - else: - print "No changes necessary to make to confirmation information in existing user's demographics." diff --git a/common/djangoapps/student/management/commands/pearson_transfer.py b/common/djangoapps/student/management/commands/pearson_transfer.py deleted file mode 100644 index b00cf27ffb5c86e9163d0360c874554121296b6e..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/pearson_transfer.py +++ /dev/null @@ -1,167 +0,0 @@ -from optparse import make_option -import os -from stat import S_ISDIR - -import boto -from dogapi import dog_http_api, dog_stats_api -import paramiko - -from django.conf import settings -from django.core.management import call_command -from django.core.management.base import BaseCommand, CommandError - -import django_startup - - -django_startup.autostartup() - - -class Command(BaseCommand): - help = """ - This command handles the importing and exporting of student records for - Pearson. It uses some other Django commands to export and import the - files and then uploads over SFTP to Pearson and stuffs the entry in an - S3 bucket for archive purposes. - - Usage: ./manage.py pearson-transfer --mode [import|export|both] - """ - - option_list = BaseCommand.option_list + ( - make_option('--mode', - action='store', - dest='mode', - default='both', - choices=('import', 'export', 'both'), - help='mode is import, export, or both'), - ) - - def handle(self, **options): - - if not hasattr(settings, 'PEARSON'): - raise CommandError('No PEARSON entries in auth/env.json.') - - # check settings needed for either import or export: - for value in ['SFTP_HOSTNAME', 'SFTP_USERNAME', 'SFTP_PASSWORD', 'S3_BUCKET']: - if value not in settings.PEARSON: - raise CommandError('No entry in the PEARSON settings' - '(env/auth.json) for {0}'.format(value)) - - for value in ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY']: - if not hasattr(settings, value): - raise CommandError('No entry in the AWS settings' - '(env/auth.json) for {0}'.format(value)) - - # check additional required settings for import and export: - if options['mode'] in ('export', 'both'): - for value in ['LOCAL_EXPORT', 'SFTP_EXPORT']: - if value not in settings.PEARSON: - raise CommandError('No entry in the PEARSON settings' - '(env/auth.json) for {0}'.format(value)) - # make sure that the import directory exists or can be created: - source_dir = settings.PEARSON['LOCAL_EXPORT'] - if not os.path.isdir(source_dir): - os.makedirs(source_dir) - - if options['mode'] in ('import', 'both'): - for value in ['LOCAL_IMPORT', 'SFTP_IMPORT']: - if value not in settings.PEARSON: - raise CommandError('No entry in the PEARSON settings' - '(env/auth.json) for {0}'.format(value)) - # make sure that the import directory exists or can be created: - dest_dir = settings.PEARSON['LOCAL_IMPORT'] - if not os.path.isdir(dest_dir): - os.makedirs(dest_dir) - - - def sftp(files_from, files_to, mode, deleteAfterCopy=False): - with dog_stats_api.timer('pearson.{0}'.format(mode), tags='sftp'): - try: - t = paramiko.Transport((settings.PEARSON['SFTP_HOSTNAME'], 22)) - t.connect(username=settings.PEARSON['SFTP_USERNAME'], - password=settings.PEARSON['SFTP_PASSWORD']) - sftp = paramiko.SFTPClient.from_transport(t) - - if mode == 'export': - try: - sftp.chdir(files_to) - except IOError: - raise CommandError('SFTP destination path does not exist: {}'.format(files_to)) - for filename in os.listdir(files_from): - sftp.put(files_from + '/' + filename, filename) - if deleteAfterCopy: - os.remove(os.path.join(files_from, filename)) - else: - try: - sftp.chdir(files_from) - except IOError: - raise CommandError('SFTP source path does not exist: {}'.format(files_from)) - for filename in sftp.listdir('.'): - # skip subdirectories - if not S_ISDIR(sftp.stat(filename).st_mode): - sftp.get(filename, files_to + '/' + filename) - # delete files from sftp server once they are successfully pulled off: - if deleteAfterCopy: - sftp.remove(filename) - except: - dog_http_api.event('pearson {0}'.format(mode), - 'sftp uploading failed', - alert_type='error') - raise - finally: - sftp.close() - t.close() - - def s3(files_from, bucket, mode, deleteAfterCopy=False): - with dog_stats_api.timer('pearson.{0}'.format(mode), tags='s3'): - try: - for filename in os.listdir(files_from): - source_file = os.path.join(files_from, filename) - # use mode as name of directory into which to write files - dest_file = os.path.join(mode, filename) - upload_file_to_s3(bucket, source_file, dest_file) - if deleteAfterCopy: - os.remove(files_from + '/' + filename) - except: - dog_http_api.event('pearson {0}'.format(mode), - 's3 archiving failed') - raise - - def upload_file_to_s3(bucket, source_file, dest_file): - """ - Upload file to S3 - """ - s3 = boto.connect_s3(settings.AWS_ACCESS_KEY_ID, - settings.AWS_SECRET_ACCESS_KEY) - from boto.s3.key import Key - b = s3.get_bucket(bucket) - k = Key(b) - k.key = "{filename}".format(filename=dest_file) - k.set_contents_from_filename(source_file) - - def export_pearson(): - options = {'dest-from-settings': True} - call_command('pearson_export_cdd', **options) - call_command('pearson_export_ead', **options) - mode = 'export' - sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy=False) - s3(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=True) - - def import_pearson(): - mode = 'import' - try: - sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy=True) - s3(settings.PEARSON['LOCAL_IMPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=False) - except Exception as e: - dog_http_api.event('Pearson Import failure', str(e)) - raise e - else: - for filename in os.listdir(settings.PEARSON['LOCAL_IMPORT']): - filepath = os.path.join(settings.PEARSON['LOCAL_IMPORT'], filename) - call_command('pearson_import_conf_zip', filepath) - os.remove(filepath) - - # actually do the work! - if options['mode'] in ('export', 'both'): - export_pearson() - if options['mode'] in ('import', 'both'): - import_pearson() diff --git a/common/djangoapps/student/management/commands/tests/__init__.py b/common/djangoapps/student/management/commands/tests/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/common/djangoapps/student/management/commands/tests/test_pearson.py b/common/djangoapps/student/management/commands/tests/test_pearson.py deleted file mode 100644 index 68fa10eaaaf92aa3d97941239f48e3b9a80233d2..0000000000000000000000000000000000000000 --- a/common/djangoapps/student/management/commands/tests/test_pearson.py +++ /dev/null @@ -1,380 +0,0 @@ -''' -Created on Jan 17, 2013 - -@author: brian -''' -import logging -import os -from tempfile import mkdtemp -import cStringIO -import shutil -import sys - -from django.test import TestCase -from django.core.management import call_command -from nose.plugins.skip import SkipTest - -from student.models import User, TestCenterUser, get_testcenter_registration - -log = logging.getLogger(__name__) - - -def create_tc_user(username): - user = User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass') - options = { - 'first_name': 'TestFirst', - 'last_name': 'TestLast', - 'address_1': 'Test Address', - 'city': 'TestCity', - 'state': 'Alberta', - 'postal_code': 'A0B 1C2', - 'country': 'CAN', - 'phone': '252-1866', - 'phone_country_code': '1', - } - call_command('pearson_make_tc_user', username, **options) - return TestCenterUser.objects.get(user=user) - - -def create_tc_registration(username, course_id='org1/course1/term1', exam_code='exam1', accommodation_code=None): - - options = {'exam_series_code': exam_code, - 'eligibility_appointment_date_first': '2013-01-01T00:00', - 'eligibility_appointment_date_last': '2013-12-31T23:59', - 'accommodation_code': accommodation_code, - 'create_dummy_exam': True, - } - - call_command('pearson_make_tc_registration', username, course_id, **options) - user = User.objects.get(username=username) - registrations = get_testcenter_registration(user, course_id, exam_code) - return registrations[0] - - -def create_multiple_registrations(prefix='test'): - username1 = '{}_multiple1'.format(prefix) - create_tc_user(username1) - create_tc_registration(username1) - create_tc_registration(username1, course_id='org1/course2/term1') - create_tc_registration(username1, exam_code='exam2') - username2 = '{}_multiple2'.format(prefix) - create_tc_user(username2) - create_tc_registration(username2) - username3 = '{}_multiple3'.format(prefix) - create_tc_user(username3) - create_tc_registration(username3, course_id='org1/course2/term1') - username4 = '{}_multiple4'.format(prefix) - create_tc_user(username4) - create_tc_registration(username4, exam_code='exam2') - - -def get_command_error_text(*args, **options): - stderr_string = None - old_stderr = sys.stderr - sys.stderr = cStringIO.StringIO() - try: - call_command(*args, **options) - except SystemExit, why1: - # The goal here is to catch CommandError calls. - # But these are actually translated into nice messages, - # and sys.exit(1) is then called. For testing, we - # want to catch what sys.exit throws, and get the - # relevant text either from stdout or stderr. - if (why1.message > 0): - stderr_string = sys.stderr.getvalue() - else: - raise why1 - except Exception, why: - raise why - - finally: - sys.stderr = old_stderr - - if stderr_string is None: - raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) - return stderr_string - - -def get_error_string_for_management_call(*args, **options): - stdout_string = None - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = cStringIO.StringIO() - sys.stderr = cStringIO.StringIO() - try: - call_command(*args, **options) - except SystemExit, why1: - # The goal here is to catch CommandError calls. - # But these are actually translated into nice messages, - # and sys.exit(1) is then called. For testing, we - # want to catch what sys.exit throws, and get the - # relevant text either from stdout or stderr. - if (why1.message == 1): - stdout_string = sys.stdout.getvalue() - stderr_string = sys.stderr.getvalue() - else: - raise why1 - except Exception, why: - raise why - - finally: - sys.stdout = old_stdout - sys.stderr = old_stderr - - if stdout_string is None: - raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) - return stdout_string, stderr_string - - -def get_file_info(dirpath): - filelist = os.listdir(dirpath) - print 'Files found: {}'.format(filelist) - numfiles = len(filelist) - if numfiles == 1: - filepath = os.path.join(dirpath, filelist[0]) - with open(filepath, 'r') as cddfile: - filecontents = cddfile.readlines() - numlines = len(filecontents) - return filepath, numlines - else: - raise Exception("Expected to find a single file in {}, but found {}".format(dirpath, filelist)) - - -class PearsonTestCase(TestCase): - ''' - Base class for tests running Pearson-related commands - ''' - - def assertErrorContains(self, error_message, expected): - self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected)) - - def setUp(self): - self.import_dir = mkdtemp(prefix="import") - self.addCleanup(shutil.rmtree, self.import_dir) - self.export_dir = mkdtemp(prefix="export") - self.addCleanup(shutil.rmtree, self.export_dir) - - def tearDown(self): - pass - # and clean up the database: -# TestCenterUser.objects.all().delete() -# TestCenterRegistration.objects.all().delete() - - -class PearsonCommandTestCase(PearsonTestCase): - - def test_missing_demographic_fields(self): - # We won't bother to test all details of form validation here. - # It is enough to show that it works here, but deal with test cases for the form - # validation in the student tests, not these management tests. - username = 'baduser' - User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass') - options = {} - error_string = get_command_error_text('pearson_make_tc_user', username, **options) - self.assertTrue(error_string.find('Field Form errors encountered:') >= 0) - self.assertTrue(error_string.find('Field Form Error: city') >= 0) - self.assertTrue(error_string.find('Field Form Error: first_name') >= 0) - self.assertTrue(error_string.find('Field Form Error: last_name') >= 0) - self.assertTrue(error_string.find('Field Form Error: country') >= 0) - self.assertTrue(error_string.find('Field Form Error: phone_country_code') >= 0) - self.assertTrue(error_string.find('Field Form Error: phone') >= 0) - self.assertTrue(error_string.find('Field Form Error: address_1') >= 0) - self.assertErrorContains(error_string, 'Field Form Error: address_1') - - def test_create_good_testcenter_user(self): - testcenter_user = create_tc_user("test_good_user") - self.assertIsNotNone(testcenter_user) - - def test_create_good_testcenter_registration(self): - username = 'test_good_registration' - create_tc_user(username) - registration = create_tc_registration(username) - self.assertIsNotNone(registration) - - def test_cdd_missing_option(self): - error_string = get_command_error_text('pearson_export_cdd', **{}) - self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used') - - def test_ead_missing_option(self): - error_string = get_command_error_text('pearson_export_ead', **{}) - self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used') - - def test_export_single_cdd(self): - # before we generate any tc_users, we expect there to be nothing to output: - options = {'dest-from-settings': True} - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines") - os.remove(filepath) - - # generating a tc_user should result in a line in the output - username = 'test_single_cdd' - create_tc_user(username) - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line") - os.remove(filepath) - - # output after registration should not have any entries again. - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines") - os.remove(filepath) - - # if we modify the record, then it should be output again: - user_options = {'first_name': 'NewTestFirst', } - call_command('pearson_make_tc_user', username, **user_options) - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line") - os.remove(filepath) - - def test_export_single_ead(self): - # before we generate any registrations, we expect there to be nothing to output: - options = {'dest-from-settings': True} - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines") - os.remove(filepath) - - # generating a registration should result in a line in the output - username = 'test_single_ead' - create_tc_user(username) - create_tc_registration(username) - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect ead file to have one non-header line") - os.remove(filepath) - - # output after registration should not have any entries again. - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines") - os.remove(filepath) - - # if we modify the record, then it should be output again: - create_tc_registration(username, accommodation_code='EQPMNT') - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect ead file to have one non-header line") - os.remove(filepath) - - def test_export_multiple(self): - create_multiple_registrations("export") - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): - options = {'dest-from-settings': True} - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 5, "Expect cdd file to have four non-header lines: total was {}".format(numlines)) - os.remove(filepath) - - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 7, "Expect ead file to have six non-header lines: total was {}".format(numlines)) - os.remove(filepath) - - -# def test_bad_demographic_option(self): -# username = 'nonuser' -# output_string, stderrmsg = get_error_string_for_management_call('pearson_make_tc_user', username, **{'--garbage' : None }) -# print stderrmsg -# self.assertErrorContains(stderrmsg, 'Unexpected option') -# -# def test_missing_demographic_user(self): -# username = 'nonuser' -# output_string, error_string = get_error_string_for_management_call('pearson_make_tc_user', username, **{}) -# self.assertErrorContains(error_string, 'User matching query does not exist') - -# credentials for a test SFTP site: -SFTP_HOSTNAME = 'ec2-23-20-150-101.compute-1.amazonaws.com' -SFTP_USERNAME = 'pearsontest' -SFTP_PASSWORD = 'password goes here' - -S3_BUCKET = 'edx-pearson-archive' -AWS_ACCESS_KEY_ID = 'put yours here' -AWS_SECRET_ACCESS_KEY = 'put yours here' - - -class PearsonTransferTestCase(PearsonTestCase): - ''' - Class for tests running Pearson transfers - ''' - - def test_transfer_config(self): - stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'}) - self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') - - stderrmsg = get_command_error_text('pearson_transfer') - self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') - - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir, - 'LOCAL_IMPORT': self.import_dir}): - stderrmsg = get_command_error_text('pearson_transfer') - self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings') - - def test_transfer_export_missing_dest_dir(self): - raise SkipTest() - create_multiple_registrations('export_missing_dest') - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir, - 'SFTP_EXPORT': 'this/does/not/exist', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'export'} - stderrmsg = get_command_error_text('pearson_transfer', **options) - self.assertErrorContains(stderrmsg, 'Error: SFTP destination path does not exist') - - def test_transfer_export(self): - raise SkipTest() - create_multiple_registrations("transfer_export") - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir, - 'SFTP_EXPORT': 'results/topvue', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'export'} -# call_command('pearson_transfer', **options) -# # confirm that the export directory is still empty: -# self.assertEqual(len(os.listdir(self.export_dir)), 0, "expected export directory to be empty") - - def test_transfer_import_missing_source_dir(self): - raise SkipTest() - create_multiple_registrations('import_missing_src') - with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir, - 'SFTP_IMPORT': 'this/does/not/exist', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'import'} - stderrmsg = get_command_error_text('pearson_transfer', **options) - self.assertErrorContains(stderrmsg, 'Error: SFTP source path does not exist') - - def test_transfer_import(self): - raise SkipTest() - create_multiple_registrations('import_missing_src') - with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir, - 'SFTP_IMPORT': 'results', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'import'} - call_command('pearson_transfer', **options) - self.assertEqual(len(os.listdir(self.import_dir)), 0, "expected import directory to be empty") diff --git a/common/djangoapps/student/migrations/0029_remove_pearson.py b/common/djangoapps/student/migrations/0029_remove_pearson.py new file mode 100644 index 0000000000000000000000000000000000000000..b92b27017fcffcb6ba7b49975116ce3078cb400a --- /dev/null +++ b/common/djangoapps/student/migrations/0029_remove_pearson.py @@ -0,0 +1,185 @@ +# -*- 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): + # Deleting model 'TestCenterUser' + db.delete_table('student_testcenteruser') + + # Deleting model 'TestCenterRegistration' + db.delete_table('student_testcenterregistration') + + + def backwards(self, orm): + # Adding model 'TestCenterUser' + db.create_table('student_testcenteruser', ( + ('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)), + ('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + ('postal_code', self.gf('django.db.models.fields.CharField')(blank=True, max_length=16, db_index=True)), + ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)), + ('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)), + ('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)), + ('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)), + ('state', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)), + ('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)), + ('company_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=50, db_index=True)), + ('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)), + ('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)), + ('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('phone', self.gf('django.db.models.fields.CharField')(max_length=35)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)), + ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(blank=True, null=True, db_index=True)), + ('extension', self.gf('django.db.models.fields.CharField')(blank=True, max_length=8, db_index=True)), + ('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)), + ('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)), + ('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, unique=True, db_index=True)), + ('address_1', self.gf('django.db.models.fields.CharField')(max_length=40)), + ('address_2', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)), + ('address_3', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)), + )) + db.send_create_signal('student', ['TestCenterUser']) + + # Adding model 'TestCenterRegistration' + db.create_table('student_testcenterregistration', ( + ('client_authorization_id', self.gf('django.db.models.fields.CharField')(max_length=20, unique=True, db_index=True)), + ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('authorization_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)), + ('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)), + ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)), + ('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)), + ('accommodation_request', self.gf('django.db.models.fields.CharField')(max_length=1024, blank=True)), + ('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)), + ('exam_series_code', self.gf('django.db.models.fields.CharField')(max_length=15, db_index=True)), + ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)), + ('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('testcenter_user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['student.TestCenterUser'])), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)), + )) + db.send_create_signal('student', ['TestCenterRegistration']) + + + 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.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.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + '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 15cc802b07bfe502a670485ccf36f712f4141c88..02c9a38b82084fffe0bccd81c653dff5c94970c6 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -11,7 +11,6 @@ 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 @@ -22,7 +21,7 @@ from django.contrib.auth.models import User from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models from django.db.models.signals import post_save -from django.dispatch import receiver +from django.dispatch import receiver, Signal import django.dispatch from django.forms import ModelForm, forms from django.core.exceptions import ObjectDoesNotExist @@ -37,7 +36,7 @@ from track.views import server_track from eventtracking import tracker -unenroll_done = django.dispatch.Signal(providing_args=["course_enrollment"]) +unenroll_done = Signal(providing_args=["course_enrollment"]) log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") @@ -202,480 +201,6 @@ class UserProfile(models.Model): def set_meta(self, js): self.meta = json.dumps(js) -TEST_CENTER_STATUS_ACCEPTED = "Accepted" -TEST_CENTER_STATUS_ERROR = "Error" - - -class TestCenterUser(models.Model): - """This is our representation of the User for in-person testing, and - specifically for Pearson at this point. A few things to note: - - * Pearson only supports Latin-1, so we have to make sure that the data we - capture here will work with that encoding. - * While we have a lot of this demographic data in UserProfile, it's much - more free-structured there. We'll try to pre-pop the form with data from - UserProfile, but we'll need to have a step where people who are signing - up re-enter their demographic data into the fields we specify. - * Users are only created here if they register to take an exam in person. - - The field names and lengths are modeled on the conventions and constraints - of Pearson's data import system, including oddities such as suffix having - a limit of 255 while last_name only gets 50. - - Also storing here the confirmation information received from Pearson (if any) - as to the success or failure of the upload. (VCDC file) - """ - # Our own record keeping... - user = models.ForeignKey(User, unique=True, default=None) - created_at = models.DateTimeField(auto_now_add=True, db_index=True) - updated_at = models.DateTimeField(auto_now=True, db_index=True) - # user_updated_at happens only when the user makes a change to their data, - # and is something Pearson needs to know to manage updates. Unlike - # updated_at, this will not get incremented when we do a batch data import. - user_updated_at = models.DateTimeField(db_index=True) - - # Unique ID we assign our user for the Test Center. - client_candidate_id = models.CharField(unique=True, max_length=50, db_index=True) - - # Name - first_name = models.CharField(max_length=30, db_index=True) - last_name = models.CharField(max_length=50, db_index=True) - middle_name = models.CharField(max_length=30, blank=True) - suffix = models.CharField(max_length=255, blank=True) - salutation = models.CharField(max_length=50, blank=True) - - # Address - address_1 = models.CharField(max_length=40) - address_2 = models.CharField(max_length=40, blank=True) - address_3 = models.CharField(max_length=40, blank=True) - city = models.CharField(max_length=32, db_index=True) - # state example: HI -- they have an acceptable list that we'll just plug in - # state is required if you're in the US or Canada, but otherwise not. - state = models.CharField(max_length=20, blank=True, db_index=True) - # postal_code required if you're in the US or Canada - postal_code = models.CharField(max_length=16, blank=True, db_index=True) - # country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG") - country = models.CharField(max_length=3, db_index=True) - - # Phone - phone = models.CharField(max_length=35) - extension = models.CharField(max_length=8, blank=True, db_index=True) - phone_country_code = models.CharField(max_length=3, db_index=True) - fax = models.CharField(max_length=35, blank=True) - # fax_country_code required *if* fax is present. - fax_country_code = models.CharField(max_length=3, blank=True) - - # Company - company_name = models.CharField(max_length=50, blank=True, db_index=True) - - # time at which edX sent the registration to the test center - uploaded_at = models.DateTimeField(null=True, blank=True, db_index=True) - - # confirmation back from the test center, as well as timestamps - # on when they processed the request, and when we received - # confirmation back. - processed_at = models.DateTimeField(null=True, db_index=True) - upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted' - upload_error_message = models.CharField(max_length=512, blank=True) - # Unique ID given to us for this User by the Testing Center. It's null when - # we first create the User entry, and may be assigned by Pearson later. - # (However, it may never be set if we are always initiating such candidate creation.) - candidate_id = models.IntegerField(null=True, db_index=True) - confirmed_at = models.DateTimeField(null=True, db_index=True) - - @property - def needs_uploading(self): - return self.uploaded_at is None or self.uploaded_at < self.user_updated_at - - @staticmethod - def user_provided_fields(): - return ['first_name', 'middle_name', 'last_name', 'suffix', 'salutation', - 'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country', - 'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name'] - - @property - def email(self): - return self.user.email - - def needs_update(self, fields): - for fieldname in TestCenterUser.user_provided_fields(): - if fieldname in fields and getattr(self, fieldname) != fields[fieldname]: - return True - - return False - - @staticmethod - def _generate_edx_id(prefix): - NUM_DIGITS = 12 - return u"{}{:012}".format(prefix, randint(1, 10 ** NUM_DIGITS - 1)) - - @staticmethod - def _generate_candidate_id(): - return TestCenterUser._generate_edx_id("edX") - - @classmethod - def create(cls, user): - testcenter_user = cls(user=user) - # testcenter_user.candidate_id remains unset - # assign an ID of our own: - cand_id = cls._generate_candidate_id() - while TestCenterUser.objects.filter(client_candidate_id=cand_id).exists(): - cand_id = cls._generate_candidate_id() - testcenter_user.client_candidate_id = cand_id - return testcenter_user - - @property - def is_accepted(self): - return self.upload_status == TEST_CENTER_STATUS_ACCEPTED - - @property - def is_rejected(self): - return self.upload_status == TEST_CENTER_STATUS_ERROR - - @property - def is_pending(self): - return not self.is_accepted and not self.is_rejected - - -class TestCenterUserForm(ModelForm): - class Meta: - model = TestCenterUser - fields = ('first_name', 'middle_name', 'last_name', 'suffix', 'salutation', - 'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country', - 'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name') - - def update_and_save(self): - new_user = self.save(commit=False) - # create additional values here: - new_user.user_updated_at = datetime.now(UTC) - new_user.upload_status = '' - new_user.save() - log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username)) - - # add validation: - - def clean_country(self): - code = self.cleaned_data['country'] - if code and (len(code) != 3 or not code.isalpha()): - raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG') - return code.upper() - - def clean(self): - def _can_encode_as_latin(fieldvalue): - try: - fieldvalue.encode('iso-8859-1') - except UnicodeEncodeError: - return False - return True - - cleaned_data = super(TestCenterUserForm, self).clean() - - # check for interactions between fields: - if 'country' in cleaned_data: - country = cleaned_data.get('country') - if country == 'USA' or country == 'CAN': - if 'state' in cleaned_data and len(cleaned_data['state']) == 0: - self._errors['state'] = self.error_class([u'Required if country is USA or CAN.']) - del cleaned_data['state'] - - if 'postal_code' in cleaned_data and len(cleaned_data['postal_code']) == 0: - self._errors['postal_code'] = self.error_class([u'Required if country is USA or CAN.']) - del cleaned_data['postal_code'] - - if 'fax' in cleaned_data and len(cleaned_data['fax']) > 0 and 'fax_country_code' in cleaned_data and len(cleaned_data['fax_country_code']) == 0: - self._errors['fax_country_code'] = self.error_class([u'Required if fax is specified.']) - del cleaned_data['fax_country_code'] - - # check encoding for all fields: - cleaned_data_fields = [fieldname for fieldname in cleaned_data] - for fieldname in cleaned_data_fields: - if not _can_encode_as_latin(cleaned_data[fieldname]): - self._errors[fieldname] = self.error_class([u'Must only use characters in Latin-1 (iso-8859-1) encoding']) - del cleaned_data[fieldname] - - # Always return the full collection of cleaned data. - return cleaned_data - -# our own code to indicate that a request has been rejected. -ACCOMMODATION_REJECTED_CODE = 'NONE' - -ACCOMMODATION_CODES = ( - (ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'), - ('EQPMNT', 'Equipment'), - ('ET12ET', 'Extra Time - 1/2 Exam Time'), - ('ET30MN', 'Extra Time - 30 Minutes'), - ('ETDBTM', 'Extra Time - Double Time'), - ('SEPRMM', 'Separate Room'), - ('SRREAD', 'Separate Room and Reader'), - ('SRRERC', 'Separate Room and Reader/Recorder'), - ('SRRECR', 'Separate Room and Recorder'), - ('SRSEAN', 'Separate Room and Service Animal'), - ('SRSGNR', 'Separate Room and Sign Language Interpreter'), -) - -ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES} - - -class TestCenterRegistration(models.Model): - """ - This is our representation of a user's registration for in-person testing, - and specifically for Pearson at this point. A few things to note: - - * Pearson only supports Latin-1, so we have to make sure that the data we - capture here will work with that encoding. This is less of an issue - than for the TestCenterUser. - * Registrations are only created here when a user registers to take an exam in person. - - The field names and lengths are modeled on the conventions and constraints - of Pearson's data import system. - """ - # to find an exam registration, we key off of the user and course_id. - # If multiple exams per course are possible, we would also need to add the - # exam_series_code. - testcenter_user = models.ForeignKey(TestCenterUser, default=None) - course_id = models.CharField(max_length=128, db_index=True) - - created_at = models.DateTimeField(auto_now_add=True, db_index=True) - updated_at = models.DateTimeField(auto_now=True, db_index=True) - # user_updated_at happens only when the user makes a change to their data, - # and is something Pearson needs to know to manage updates. Unlike - # updated_at, this will not get incremented when we do a batch data import. - # The appointment dates, the exam count, and the accommodation codes can be updated, - # but hopefully this won't happen often. - user_updated_at = models.DateTimeField(db_index=True) - # "client_authorization_id" is our unique identifier for the authorization. - # This must be present for an update or delete to be sent to Pearson. - client_authorization_id = models.CharField(max_length=20, unique=True, db_index=True) - - # information about the test, from the course policy: - exam_series_code = models.CharField(max_length=15, db_index=True) - eligibility_appointment_date_first = models.DateField(db_index=True) - eligibility_appointment_date_last = models.DateField(db_index=True) - - # this is really a list of codes, using an '*' as a delimiter. - # So it's not a choice list. We use the special value of ACCOMMODATION_REJECTED_CODE - # to indicate the rejection of an accommodation request. - 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=False) - - # time at which edX sent the registration to the test center - uploaded_at = models.DateTimeField(null=True, db_index=True) - - # confirmation back from the test center, as well as timestamps - # on when they processed the request, and when we received - # confirmation back. - processed_at = models.DateTimeField(null=True, db_index=True) - upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted' - upload_error_message = models.CharField(max_length=512, blank=True) - # Unique ID given to us for this registration by the Testing Center. It's null when - # we first create the registration entry, and may be assigned by Pearson later. - # (However, it may never be set if we are always initiating such candidate creation.) - authorization_id = models.IntegerField(null=True, db_index=True) - confirmed_at = models.DateTimeField(null=True, db_index=True) - - @property - def candidate_id(self): - return self.testcenter_user.candidate_id - - @property - def client_candidate_id(self): - return self.testcenter_user.client_candidate_id - - @property - def authorization_transaction_type(self): - if self.authorization_id is not None: - return 'Update' - elif self.uploaded_at is None: - return 'Add' - elif self.registration_is_rejected: - # Assume that if the registration was rejected before, - # it is more likely this is the (first) correction - # than a second correction in flight before the first was - # processed. - return 'Add' - else: - # TODO: decide what to send when we have uploaded an initial version, - # but have not received confirmation back from that upload. If the - # registration here has been changed, then we don't know if this changed - # registration should be submitted as an 'add' or an 'update'. - # - # If the first registration were lost or in error (e.g. bad code), - # the second should be an "Add". If the first were processed successfully, - # then the second should be an "Update". We just don't know.... - return 'Update' - - @property - def exam_authorization_count(self): - # Someday this could go in the database (with a default value). But at present, - # we do not expect anyone to be authorized to take an exam more than once. - return 1 - - @property - def needs_uploading(self): - return self.uploaded_at is None or self.uploaded_at < self.user_updated_at - - @classmethod - def create(cls, testcenter_user, exam, accommodation_request): - registration = cls(testcenter_user=testcenter_user) - registration.course_id = exam.course_id - registration.accommodation_request = accommodation_request.strip() - registration.exam_series_code = exam.exam_series_code - registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d") - registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d") - registration.client_authorization_id = cls._create_client_authorization_id() - # accommodation_code remains blank for now, along with Pearson confirmation information - return registration - - @staticmethod - def _generate_authorization_id(): - return TestCenterUser._generate_edx_id("edXexam") - - @staticmethod - def _create_client_authorization_id(): - """ - Return a unique id for a registration, suitable for using as an authorization code - for Pearson. It must fit within 20 characters. - """ - # generate a random value, and check to see if it already is in use here - auth_id = TestCenterRegistration._generate_authorization_id() - while TestCenterRegistration.objects.filter(client_authorization_id=auth_id).exists(): - auth_id = TestCenterRegistration._generate_authorization_id() - return auth_id - - # methods for providing registration status details on registration page: - @property - def demographics_is_accepted(self): - return self.testcenter_user.is_accepted - - @property - def demographics_is_rejected(self): - return self.testcenter_user.is_rejected - - @property - def demographics_is_pending(self): - return self.testcenter_user.is_pending - - @property - def accommodation_is_accepted(self): - return len(self.accommodation_request) > 0 and len(self.accommodation_code) > 0 and self.accommodation_code != ACCOMMODATION_REJECTED_CODE - - @property - def accommodation_is_rejected(self): - return len(self.accommodation_request) > 0 and self.accommodation_code == ACCOMMODATION_REJECTED_CODE - - @property - def accommodation_is_pending(self): - return len(self.accommodation_request) > 0 and len(self.accommodation_code) == 0 - - @property - def accommodation_is_skipped(self): - return len(self.accommodation_request) == 0 - - @property - def registration_is_accepted(self): - return self.upload_status == TEST_CENTER_STATUS_ACCEPTED - - @property - def registration_is_rejected(self): - return self.upload_status == TEST_CENTER_STATUS_ERROR - - @property - def registration_is_pending(self): - return not self.registration_is_accepted and not self.registration_is_rejected - - # methods for providing registration status summary on dashboard page: - @property - def is_accepted(self): - return self.registration_is_accepted and self.demographics_is_accepted - - @property - def is_rejected(self): - return self.registration_is_rejected or self.demographics_is_rejected - - @property - def is_pending(self): - return not self.is_accepted and not self.is_rejected - - def get_accommodation_codes(self): - return self.accommodation_code.split('*') - - def get_accommodation_names(self): - return [ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes()] - - @property - def registration_signup_url(self): - return settings.PEARSONVUE_SIGNINPAGE_URL - - def demographics_status(self): - if self.demographics_is_accepted: - return "Accepted" - elif self.demographics_is_rejected: - return "Rejected" - else: - return "Pending" - - def accommodation_status(self): - if self.accommodation_is_skipped: - return "Skipped" - elif self.accommodation_is_accepted: - return "Accepted" - elif self.accommodation_is_rejected: - return "Rejected" - else: - return "Pending" - - def registration_status(self): - if self.registration_is_accepted: - return "Accepted" - elif self.registration_is_rejected: - return "Rejected" - else: - return "Pending" - - -class TestCenterRegistrationForm(ModelForm): - class Meta: - model = TestCenterRegistration - fields = ('accommodation_request', 'accommodation_code') - - def clean_accommodation_request(self): - code = self.cleaned_data['accommodation_request'] - if code and len(code) > 0: - return code.strip() - return code - - def update_and_save(self): - registration = self.save(commit=False) - # create additional values here: - registration.user_updated_at = datetime.now(UTC) - registration.upload_status = '' - registration.save() - log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code)) - - def clean_accommodation_code(self): - code = self.cleaned_data['accommodation_code'] - if code: - code = code.upper() - codes = code.split('*') - for codeval in codes: - if codeval not in ACCOMMODATION_CODE_DICT: - raise forms.ValidationError(u'Invalid accommodation code specified: "{}"'.format(codeval)) - return code - - -def get_testcenter_registration(user, course_id, exam_series_code): - try: - tcu = TestCenterUser.objects.get(user=user) - except TestCenterUser.DoesNotExist: - return [] - return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code) - -# nosetests thinks that anything with _test_ in the name is a test. -# Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html) -get_testcenter_registration.__test__ = False - def unique_id_for_user(user): """ @@ -880,7 +405,7 @@ class CourseEnrollment(models.Model): verified the user authentication and access. """ enrollment = cls.get_or_create_enrollment(user, course_id) - enrollment.update_enrollment(is_active=True) + enrollment.update_enrollment(is_active=True, mode=mode) return enrollment @classmethod diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index f6f32d81b327ebd234182295648c67df082064c7..41d916520f7477254799a305c35ac73d317f55a3 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase): self.user_bad_passwd.password = UNUSABLE_PASSWORD self.user_bad_passwd.save() + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_user_bad_password_reset(self): """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_resp = password_reset(bad_pwd_req) + # If they've got an unusable password, we return a successful response code self.assertEquals(bad_pwd_resp.status_code, 200) - self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, - 'error': 'Invalid e-mail or user'})) + self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True, + 'value': "('registration/password_reset_done.html', [])"})) + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_nonexist_email_password_reset(self): """Now test the exception cases with of reset_password called with invalid email.""" bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) bad_email_resp = password_reset(bad_email_req) + # Note: even if the email is bad, we return a successful response code + # This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX self.assertEquals(bad_email_resp.status_code, 200) - self.assertEquals(bad_email_resp.content, json.dumps({'success': False, - 'error': 'Invalid e-mail or user'})) + self.assertEquals(bad_email_resp.content, json.dumps({'success': True, + 'value': "('registration/password_reset_done.html', [])"})) @unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False), dedent("""Skipping Test because CMS has not provided necessary templates for password reset. @@ -152,38 +157,43 @@ class CourseEndingTest(TestCase): {'status': 'processing', 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False, }) + 'show_survey_button': False, + }) cert_status = {'status': 'unavailable'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'processing', 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False}) + 'show_survey_button': False, + 'mode': None + }) - cert_status = {'status': 'generating', 'grade': '67'} + cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'generating', 'show_disabled_download_button': True, 'show_download_url': False, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) - cert_status = {'status': 'regenerating', 'grade': '67'} + cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'generating', 'show_disabled_download_button': True, 'show_download_url': False, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'verified' }) download_url = 'http://s3.edx/cert' cert_status = {'status': 'downloadable', 'grade': '67', - 'download_url': download_url} + 'download_url': download_url, 'mode': 'honor'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'ready', 'show_disabled_download_button': False, @@ -191,30 +201,33 @@ class CourseEndingTest(TestCase): 'download_url': download_url, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) cert_status = {'status': 'notpassing', 'grade': '67', - 'download_url': download_url} + 'download_url': download_url, 'mode': 'honor'} self.assertEqual(_cert_info(user, course, cert_status), {'status': 'notpassing', 'show_disabled_download_button': False, 'show_download_url': False, 'show_survey_button': True, 'survey_url': survey_url, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) # Test a course that doesn't have a survey specified course2 = Mock(end_of_course_survey_url=None) cert_status = {'status': 'notpassing', 'grade': '67', - 'download_url': download_url} + 'download_url': download_url, 'mode': 'honor'} self.assertEqual(_cert_info(user, course2, cert_status), {'status': 'notpassing', 'show_disabled_download_button': False, 'show_download_url': False, 'show_survey_button': False, - 'grade': '67' + 'grade': '67', + 'mode': 'honor' }) @@ -329,6 +342,14 @@ class EnrollInCourseTest(TestCase): ) self.assertFalse(enrollment_record.is_active) + # Make sure mode is updated properly if user unenrolls & re-enrolls + enrollment = CourseEnrollment.enroll(user, course_id, "verified") + self.assertEquals(enrollment.mode, "verified") + CourseEnrollment.unenroll(user, course_id) + enrollment = CourseEnrollment.enroll(user, course_id, "audit") + self.assertTrue(CourseEnrollment.is_enrolled(user, course_id)) + self.assertEquals(enrollment.mode, "audit") + def assert_no_events_were_emitted(self): """Ensures no events were emitted since the last event related assertion""" self.assertFalse(self.mock_server_track.called) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index f92ffe9d3ea8afebbae82df8ae18eeea403937d3..2c3099a67278f96e6b9517183a06ed6a7bc2daf5 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -39,10 +39,9 @@ from mitxmako.shortcuts import render_to_response, render_to_string from course_modes.models import CourseMode from student.models import ( - Registration, UserProfile, TestCenterUser, TestCenterUserForm, - TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, + Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, - get_testcenter_registration, CourseEnrollmentAllowed, UserStanding, + CourseEnrollmentAllowed, UserStanding, ) from student.forms import PasswordResetFormNoActive @@ -185,7 +184,8 @@ def _cert_info(user, course, cert_status): default_info = {'status': default_status, 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False} + 'show_survey_button': False, + } if cert_status is None: return default_info @@ -203,7 +203,8 @@ def _cert_info(user, course, cert_status): d = {'status': status, 'show_download_url': status == 'ready', - 'show_disabled_download_button': status == 'generating', } + 'show_disabled_download_button': status == 'generating', + 'mode': cert_status.get('mode', None)} if (status in ('generating', 'ready', 'notpassing', 'restricted') and course.end_of_course_survey_url is not None): @@ -296,7 +297,7 @@ def complete_course_mode_info(course_id, enrollment): def dashboard(request): user = request.user - # Build our (course, enorllment) list for the user, but ignore any courses that no + # Build our (course, enrollment) 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. course_enrollment_pairs = [] @@ -964,172 +965,6 @@ def create_account(request, post_override=None): return response -def exam_registration_info(user, course): - """ Returns a Registration object if the user is currently registered for a current - exam of the course. Returns None if the user is not registered, or if there is no - current exam for the course. - """ - exam_info = course.current_test_center_exam - if exam_info is None: - return None - - exam_code = exam_info.exam_series_code - registrations = get_testcenter_registration(user, course.id, exam_code) - if registrations: - registration = registrations[0] - else: - registration = None - return registration - - -@login_required -@ensure_csrf_cookie -def begin_exam_registration(request, course_id): - """ Handles request to register the user for the current - test center exam of the specified course. Called by form - in dashboard.html. - """ - user = request.user - - try: - course = course_from_id(course_id) - except ItemNotFoundError: - log.error("User {0} enrolled in non-existent course {1}".format(user.username, course_id)) - raise Http404 - - # get the exam to be registered for: - # (For now, we just assume there is one at most.) - # if there is no exam now (because someone bookmarked this stupid page), - # then return a 404: - exam_info = course.current_test_center_exam - if exam_info is None: - raise Http404 - - # determine if the user is registered for this course: - registration = exam_registration_info(user, course) - - # we want to populate the registration page with the relevant information, - # if it already exists. Create an empty object otherwise. - try: - testcenteruser = TestCenterUser.objects.get(user=user) - except TestCenterUser.DoesNotExist: - testcenteruser = TestCenterUser() - testcenteruser.user = user - - context = {'course': course, - 'user': user, - 'testcenteruser': testcenteruser, - 'registration': registration, - 'exam_info': exam_info, - } - - return render_to_response('test_center_register.html', context) - - -@ensure_csrf_cookie -def create_exam_registration(request, post_override=None): - """ - JSON call to create a test center exam registration. - Called by form in test_center_register.html - """ - post_vars = post_override if post_override else request.POST - - # first determine if we need to create a new TestCenterUser, or if we are making any update - # to an existing TestCenterUser. - username = post_vars['username'] - user = User.objects.get(username=username) - course_id = post_vars['course_id'] - course = course_from_id(course_id) # assume it will be found.... - - # make sure that any demographic data values received from the page have been stripped. - # Whitespace is not an acceptable response for any of these values - demographic_data = {} - for fieldname in TestCenterUser.user_provided_fields(): - if fieldname in post_vars: - demographic_data[fieldname] = (post_vars[fieldname]).strip() - try: - testcenter_user = TestCenterUser.objects.get(user=user) - needs_updating = testcenter_user.needs_update(demographic_data) - log.info("User {0} enrolled in course {1} {2}updating demographic info for exam registration".format(user.username, course_id, "" if needs_updating else "not ")) - except TestCenterUser.DoesNotExist: - # do additional initialization here: - testcenter_user = TestCenterUser.create(user) - needs_updating = True - log.info("User {0} enrolled in course {1} creating demographic info for exam registration".format(user.username, course_id)) - - # perform validation: - if needs_updating: - # first perform validation on the user information - # using a Django Form. - form = TestCenterUserForm(instance=testcenter_user, data=demographic_data) - if form.is_valid(): - form.update_and_save() - else: - response_data = {'success': False} - # return a list of errors... - response_data['field_errors'] = form.errors - response_data['non_field_errors'] = form.non_field_errors() - return HttpResponse(json.dumps(response_data), mimetype="application/json") - - # create and save the registration: - needs_saving = False - exam = course.current_test_center_exam - exam_code = exam.exam_series_code - registrations = get_testcenter_registration(user, course_id, exam_code) - if registrations: - registration = registrations[0] - # NOTE: we do not bother to check here to see if the registration has changed, - # because at the moment there is no way for a user to change anything about their - # registration. They only provide an optional accommodation request once, and - # cannot make changes to it thereafter. - # It is possible that the exam_info content has been changed, such as the - # scheduled exam dates, but those kinds of changes should not be handled through - # this registration screen. - - else: - accommodation_request = post_vars.get('accommodation_request', '') - registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) - needs_saving = True - log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id)) - - if needs_saving: - # do validation of registration. (Mainly whether an accommodation request is too long.) - form = TestCenterRegistrationForm(instance=registration, data=post_vars) - if form.is_valid(): - form.update_and_save() - else: - response_data = {'success': False} - # return a list of errors... - response_data['field_errors'] = form.errors - response_data['non_field_errors'] = form.non_field_errors() - return HttpResponse(json.dumps(response_data), mimetype="application/json") - - # only do the following if there is accommodation text to send, - # and a destination to which to send it. - # TODO: still need to create the accommodation email templates -# if 'accommodation_request' in post_vars and 'TESTCENTER_ACCOMMODATION_REQUEST_EMAIL' in settings: -# d = {'accommodation_request': post_vars['accommodation_request'] } -# -# # composes accommodation email -# subject = render_to_string('emails/accommodation_email_subject.txt', d) -# # Email subject *must not* contain newlines -# subject = ''.join(subject.splitlines()) -# message = render_to_string('emails/accommodation_email.txt', d) -# -# try: -# dest_addr = settings['TESTCENTER_ACCOMMODATION_REQUEST_EMAIL'] -# from_addr = user.email -# send_mail(subject, message, from_addr, [dest_addr], fail_silently=False) -# except: -# log.exception(sys.exc_info()) -# response_data = {'success': False} -# response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ] -# return HttpResponse(json.dumps(response_data), mimetype="application/json") - - js = {'success': True} - return HttpResponse(json.dumps(js), mimetype="application/json") - - def auto_auth(request): """ Automatically logs the user in with a generated random credentials @@ -1229,11 +1064,8 @@ def password_reset(request): from_email=settings.DEFAULT_FROM_EMAIL, request=request, domain_override=request.get_host()) - return HttpResponse(json.dumps({'success': True, + return HttpResponse(json.dumps({'success': True, 'value': render_to_string('registration/password_reset_done.html', {})})) - else: - return HttpResponse(json.dumps({'success': False, - 'error': _('Invalid e-mail or user')})) def password_reset_confirm_wrapper( @@ -1515,4 +1347,4 @@ def change_email_settings(request): log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') - return HttpResponse(json.dumps({'success': True})) \ No newline at end of file + return HttpResponse(json.dumps({'success': True})) diff --git a/common/djangoapps/util/json_request.py b/common/djangoapps/util/json_request.py index fea91488d42385e966e404f25b3b1483c7996b51..b390c8c11ca2a2fa25447780cee27b140c10d160 100644 --- a/common/djangoapps/util/json_request.py +++ b/common/djangoapps/util/json_request.py @@ -1,5 +1,4 @@ from functools import wraps -import copy import json from django.core.serializers import serialize from django.core.serializers.json import DjangoJSONEncoder diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 6e9dda13f8311f00ff64981399ec0a7ba499b07e..c71c1a76322aabc6328c9cedbf3f887538ec848d 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -946,17 +946,34 @@ class NumericalResponse(LoncapaResponse): class StringResponse(LoncapaResponse): + ''' + This response type allows one or more answers. Use `_or_` separator to set + more than 1 answer. + + Example: + + # One answer + <stringresponse answer="Michigan"> + <textline size="20" /> + </stringresponse > + + # Multiple answers + <stringresponse answer="Martin Luther King_or_Dr. Martin Luther King Jr."> + <textline size="20" /> + </stringresponse > + ''' response_tag = 'stringresponse' hint_tag = 'stringhint' allowed_inputfields = ['textline'] required_attributes = ['answer'] max_inputfields = 1 - correct_answer = None + correct_answer = [] + SEPARATOR = '_or_' def setup_response(self): - self.correct_answer = contextualize_text( - self.xml.get('answer'), self.context).strip() + self.correct_answer = [contextualize_text(answer, self.context).strip() + for answer in self.xml.get('answer').split(self.SEPARATOR)] def get_score(self, student_answers): '''Grade a string response ''' @@ -966,23 +983,25 @@ class StringResponse(LoncapaResponse): def check_string(self, expected, given): if self.xml.get('type') == 'ci': - return given.lower() == expected.lower() - return given == expected + return given.lower() in [i.lower() for i in expected] + return given in expected def check_hint_condition(self, hxml_set, student_answers): given = student_answers[self.answer_id].strip() hints_to_show = [] for hxml in hxml_set: name = hxml.get('name') - correct_answer = contextualize_text( - hxml.get('answer'), self.context).strip() + + correct_answer = [contextualize_text(answer, self.context).strip() + for answer in hxml.get('answer').split(self.SEPARATOR)] + if self.check_string(correct_answer, given): hints_to_show.append(name) log.debug('hints_to_show = %s', hints_to_show) return hints_to_show def get_answers(self): - return {self.answer_id: self.correct_answer} + return {self.answer_id: ' <b>or</b> '.join(self.correct_answer)} #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html index 5f587e214a02401b9b432a3668444e98773bd27b..e74e9f71e51be4f1250b8eec268c718b345a352b 100644 --- a/common/lib/capa/capa/templates/choicetext.html +++ b/common/lib/capa/capa/templates/choicetext.html @@ -55,7 +55,7 @@ % else: <% my_id = content_node.get('contents','') %> <% my_val = value.get(my_id,'') %> - <input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h} "/> + <input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h}"/> %endif <span class="mock_label"> ${content_node['tail_text']} diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index ba20bcbe0d6457168d8e25b431a74a28e6c1111e..99b3f1d52c7e77536ea3d9b3cffcd8d500b0fdd8 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -500,6 +500,7 @@ class StringResponseTest(ResponseTest): xml_factory_class = StringResponseXMLFactory def test_case_sensitive(self): + # Test single answer problem = self.build_problem(answer="Second", case_sensitive=True) # Exact string should be correct @@ -509,7 +510,20 @@ class StringResponseTest(ResponseTest): self.assert_grade(problem, "Other String", "incorrect") self.assert_grade(problem, "second", "incorrect") + # Test multiple answers + answers = ["Second", "Third", "Fourth"] + problem = self.build_problem(answer="_or_".join(answers), case_sensitive=True) + + for answer in answers: + # Exact string should be correct + self.assert_grade(problem, answer, "correct") + + # Other strings and the lowercase version of the string are incorrect + self.assert_grade(problem, "Other String", "incorrect") + self.assert_grade(problem, "second", "incorrect") + def test_case_insensitive(self): + # Test single answer problem = self.build_problem(answer="Second", case_sensitive=False) # Both versions of the string should be allowed, regardless @@ -520,9 +534,28 @@ class StringResponseTest(ResponseTest): # Other strings are not allowed self.assert_grade(problem, "Other String", "incorrect") + # Test multiple answers + answers = ["Second", "Third", "Fourth"] + problem = self.build_problem(answer="_or_".join(answers), case_sensitive=False) + + for answer in answers: + # Exact string should be correct + self.assert_grade(problem, answer, "correct") + self.assert_grade(problem, answer.lower(), "correct") + + # Other strings and the lowercase version of the string are incorrect + self.assert_grade(problem, "Other String", "incorrect") + def test_hints(self): + multiple_answers = [ + "Martin Luther King Junior", + "Doctor Martin Luther King Junior", + "Dr. Martin Luther King Jr.", + "Martin Luther King" + ] hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), - ("minnesota", "minn", "The state capital of Minnesota is St. Paul")] + ("minnesota", "minn", "The state capital of Minnesota is St. Paul"), + ("_or_".join(multiple_answers), "mlk", "He lead the civil right movement in the United States of America.")] problem = self.build_problem(answer="Michigan", case_sensitive=False, @@ -550,6 +583,14 @@ class StringResponseTest(ResponseTest): correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_hint('1_2_1'), "") + # We should get the same hint for each answer + for answer in multiple_answers: + input_dict = {'1_2_1': answer} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + "He lead the civil right movement in the United States of America.") + + def test_computed_hints(self): problem = self.build_problem( answer="Michigan", diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index acb58aa39c554d98fe4a2fcd9fe797830285933a..558668c9fae8e9d247b6d18ff1069396257c86b3 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -20,7 +20,6 @@ XMODULES = [ "section = xmodule.backcompat_module:SemanticSectionDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor", "video = xmodule.video_module:VideoDescriptor", "videoalpha = xmodule.video_module:VideoDescriptor", diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 6d6d204eb810732be7fb6dba6bf65e777baeb287..0ddd27d20251b14bedcf839d5af072524364db67 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -213,7 +213,6 @@ class CourseFields(object): discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) discussion_topics = Dict(help="Map of topics names to ids", scope=Scope.settings) discussion_sort_alpha = Boolean(scope=Scope.settings, default=False, help="Sort forum categories and subcategories alphabetically.") - testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings) @@ -426,20 +425,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): if self.discussion_topics == {}: self.discussion_topics = {'General': {'id': self.location.html_id()}} - self.test_center_exams = [] - test_center_info = self.testcenter_info - if test_center_info is not None: - for exam_name in test_center_info: - try: - exam_info = test_center_info[exam_name] - self.test_center_exams.append(self.TestCenterExam(self.id, exam_name, exam_info)) - except Exception as err: - # If we can't parse the test center exam info, don't break - # the rest of the courseware. - msg = 'Error %s: Unable to load test-center exam info for exam "%s" of course "%s"' % (err, exam_name, self.id) - log.error(msg) - continue - # TODO check that this is still needed here and can't be by defaults. if not self.tabs: # When calling the various _tab methods, can omit the 'type':'blah' from the @@ -597,6 +582,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): @property def raw_grader(self): + # force the caching of the xblock value so that it can detect the change + # pylint: disable=pointless-statement + self.grading_policy['GRADER'] return self._grading_policy['RAW_GRADER'] @raw_grader.setter @@ -873,93 +861,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): return True - class TestCenterExam(object): - def __init__(self, course_id, exam_name, exam_info): - self.course_id = course_id - self.exam_name = exam_name - self.exam_info = exam_info - self.exam_series_code = exam_info.get('Exam_Series_Code') or exam_name - self.display_name = exam_info.get('Exam_Display_Name') or self.exam_series_code - self.first_eligible_appointment_date = self._try_parse_time('First_Eligible_Appointment_Date') - if self.first_eligible_appointment_date is None: - raise ValueError("First appointment date must be specified") - # TODO: If defaulting the last appointment date, it should be the - # *end* of the same day, not the same time. It's going to be used as the - # end of the exam overall, so we don't want the exam to disappear too soon. - # It's also used optionally as the registration end date, so time matters there too. - self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date - if self.last_eligible_appointment_date is None: - raise ValueError("Last appointment date must be specified") - self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or - datetime.fromtimestamp(0, UTC())) - self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date - # do validation within the exam info: - if self.registration_start_date > self.registration_end_date: - raise ValueError("Registration start date must be before registration end date") - if self.first_eligible_appointment_date > self.last_eligible_appointment_date: - raise ValueError("First appointment date must be before last appointment date") - if self.registration_end_date > self.last_eligible_appointment_date: - raise ValueError("Registration end date must be before last appointment date") - self.exam_url = exam_info.get('Exam_URL') - - def _try_parse_time(self, key): - """ - Parse an optional metadata key containing a time: if present, complain - if it doesn't parse. - Return None if not present or invalid. - """ - if key in self.exam_info: - try: - return Date().from_json(self.exam_info[key]) - except ValueError as e: - msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e) - log.warning(msg) - return None - - def has_started(self): - return datetime.now(UTC()) > self.first_eligible_appointment_date - - def has_ended(self): - return datetime.now(UTC()) > self.last_eligible_appointment_date - - def has_started_registration(self): - return datetime.now(UTC()) > self.registration_start_date - - def has_ended_registration(self): - return datetime.now(UTC()) > self.registration_end_date - - def is_registering(self): - now = datetime.now(UTC()) - return now >= self.registration_start_date and now <= self.registration_end_date - - @property - def first_eligible_appointment_date_text(self): - return self.first_eligible_appointment_date.strftime("%b %d, %Y") - - @property - def last_eligible_appointment_date_text(self): - return self.last_eligible_appointment_date.strftime("%b %d, %Y") - - @property - def registration_end_date_text(self): - return date_utils.get_default_time_display(self.registration_end_date) - - @property - def current_test_center_exam(self): - exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()] - if len(exams) > 1: - # TODO: output some kind of warning. This should already be - # caught if we decide to do validation at load time. - return exams[0] - elif len(exams) == 1: - return exams[0] - else: - return None - - def get_test_center_exam(self, exam_series_code): - exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] - return exams[0] if len(exams) == 1 else None - @property def number(self): return self.location.course diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index bcab8dc4faf26e619cb5a125f4274c65e1a01e0b..ddf57e846d37420800743198023a110d801d1c76 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -726,21 +726,24 @@ section.problem { } a.full { - @include position(absolute, 0 0 1px 0); + @include position(absolute, 0 0px 1px 0px); @include box-sizing(border-box); display: block; padding: 4px; - width: 100%; background: #f3f3f3; text-align: right; - font-size: .8em; + font-size: 1em; + + &.full-top{ + @include position(absolute, 1px 0px auto 0px); + } } } } .external-grader-message { section { - padding-top: $baseline/2; + padding-top: ($baseline*1.5); padding-left: $baseline; background-color: #fafafa; color: #2c2c2c; diff --git a/common/lib/xmodule/xmodule/js/spec/collapsible.coffee b/common/lib/xmodule/xmodule/js/spec/collapsible.coffee new file mode 100644 index 0000000000000000000000000000000000000000..181d6b46c1093cb284a653970cce2fbb52edebee --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/collapsible.coffee @@ -0,0 +1,115 @@ +describe 'Collapsible', -> + html = custom_labels = html_custom = el = undefined + + initialize = (template) => + setFixtures(template) + el = $('.collapsible') + Collapsible.setCollapsibles(el) + + disableFx = () => + $.fx.off = true + + enableFx = () => + $.fx.off = false + + beforeEach -> + html = ''' + <section class="collapsible"> + <div class="shortform"> + shortform message + </div> + <div class="longform"> + <p>longform is visible</p> + </div> + </section> + ''' + html_custom = ''' + <section class="collapsible"> + <div class="shortform-custom" data-open-text="Show shortform-custom" data-close-text="Hide shortform-custom"> + shortform message + </div> + <div class="longform"> + <p>longform is visible</p> + </div> + </section> + ''' + + describe 'setCollapsibles', -> + + it 'Default container initialized correctly', -> + initialize(html) + + expect(el.find('.shortform')).toContain '.full-top' + expect(el.find('.shortform')).toContain '.full-bottom' + expect(el.find('.longform')).toBeHidden() + expect(el.find('.full')).toHandle('click') + + it 'Custom container initialized correctly', -> + initialize(html_custom) + + expect(el.find('.shortform-custom')).toContain '.full-custom' + expect(el.find('.full-custom')).toHaveText "Show shortform-custom" + expect(el.find('.longform')).toBeHidden() + expect(el.find('.full-custom')).toHandle('click') + + describe 'toggleFull', -> + + beforeEach -> + disableFx() + + afterEach -> + enableFx() + + it 'Default container', -> + initialize(html) + + event = jQuery.Event('click', { + target: el.find('.full').get(0) + }) + + assertChanges = (state='closed') => + anchors = el.find('.full') + + if state is 'closed' + expect(el.find('.longform')).toBeHidden() + expect(el).not.toHaveClass('open') + text = "See full output" + else + expect(el.find('.longform')).toBeVisible() + expect(el).toHaveClass('open') + text = "Hide output" + + $.each anchors, (index, el) => + expect(el).toHaveText text + + Collapsible.toggleFull(event, "See full output", "Hide output") + assertChanges('opened') + Collapsible.toggleFull(event, "See full output", "Hide output") + assertChanges('closed') + + it 'Custom container', -> + initialize(html_custom) + + event = jQuery.Event('click', { + target: el.find('.full-custom').get(0) + }) + + assertChanges = (state='closed') => + anchors = el.find('.full-custom') + + if state is 'closed' + expect(el.find('.longform')).toBeHidden() + expect(el).not.toHaveClass('open') + text = "Show shortform-custom" + else + expect(el.find('.longform')).toBeVisible() + expect(el).toHaveClass('open') + text = "Hide shortform-custom" + + $.each anchors, (index, el) => + expect(el).toHaveText text + + Collapsible.toggleFull(event, "Show shortform-custom", "Hide shortform-custom") + assertChanges('opened') + Collapsible.toggleFull(event, "Show shortform-custom", "Hide shortform-custom") + assertChanges('closed') diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee index c0c9b884416ad1152fbe7b238a840b9d55a384ca..ea1341cc23e5c3c42731cdcb2b89dcbdad3dbf14 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -104,45 +104,45 @@ describe 'MarkdownEditingDescriptor', -> Enter the number of fingers on a human hand: = 5 - + [Explanation] Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14. - + Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like. - + If you look at your hand, you can count that you have five fingers. [Explanation] """) expect(data).toEqual("""<problem> <p>A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.</p> - + <p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p> - + <p>Enter the numerical value of Pi:</p> <numericalresponse answer="3.14159"> <responseparam type="tolerance" default=".02" /> <formulaequationinput /> </numericalresponse> - + <p>Enter the approximate value of 502*9:</p> <numericalresponse answer="4518"> <responseparam type="tolerance" default="15%" /> <formulaequationinput /> </numericalresponse> - + <p>Enter the number of fingers on a human hand:</p> <numericalresponse answer="5"> <formulaequationinput /> </numericalresponse> - + <solution> <div class="detailed-solution"> <p>Explanation</p> - + <p>Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.</p> - + <p>Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.</p> - + <p>If you look at your hand, you can count that you have five fingers.</p> </div> @@ -161,12 +161,27 @@ describe 'MarkdownEditingDescriptor', -> </numericalresponse> + </problem>""") + it 'markup with multiple answers doesn\'t break numerical response', -> + data = MarkdownEditingDescriptor.markdownToXml(""" + Enter 1 with a tolerance: + = 1 +- .02 + or= 2 +- 5% + """) + expect(data).toEqual("""<problem> + <p>Enter 1 with a tolerance:</p> + <numericalresponse answer="1"> + <responseparam type="tolerance" default=".02" /> + <formulaequationinput /> + </numericalresponse> + + </problem>""") it 'converts multiple choice to xml', -> data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets. - + One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make. - + What Apple device competed with the portable CD player? ( ) The iPad ( ) Napster @@ -174,16 +189,16 @@ describe 'MarkdownEditingDescriptor', -> ( ) The vegetable peeler ( ) Android ( ) The Beatles - + [Explanation] The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks. [Explanation] """) expect(data).toEqual("""<problem> <p>A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.</p> - + <p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p> - + <p>What Apple device competed with the portable CD player?</p> <multiplechoiceresponse> <choicegroup type="MultipleChoice"> @@ -195,76 +210,102 @@ describe 'MarkdownEditingDescriptor', -> <choice correct="false">The Beatles</choice> </choicegroup> </multiplechoiceresponse> - + <solution> <div class="detailed-solution"> <p>Explanation</p> - + <p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p> </div> </solution> - </problem>""") + </problem>""") it 'converts OptionResponse to xml', -> data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer. - + The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag. - + Translation between Option Response and __________ is extremely straightforward: [[(Multiple Choice), String Response, Numerical Response, External Response, Image Response]] - + [Explanation] Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question. [Explanation] """) expect(data).toEqual("""<problem> <p>OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.</p> - + <p>The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.</p> - + <p>Translation between Option Response and __________ is extremely straightforward:</p> - + <optionresponse> <optioninput options="('Multiple Choice','String Response','Numerical Response','External Response','Image Response')" correct="Multiple Choice"></optioninput> </optionresponse> - + <solution> <div class="detailed-solution"> <p>Explanation</p> - + <p>Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.</p> </div> </solution> - </problem>""") + </problem>""") it 'converts StringResponse to xml', -> data = MarkdownEditingDescriptor.markdownToXml("""A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box. - + The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear. - + Which US state has Lansing as its capital? = Michigan - + [Explanation] Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides. [Explanation] """) expect(data).toEqual("""<problem> <p>A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.</p> - + <p>The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.</p> - + <p>Which US state has Lansing as its capital?</p> <stringresponse answer="Michigan" type="ci"> <textline size="20"/> </stringresponse> - + <solution> <div class="detailed-solution"> <p>Explanation</p> - + <p>Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.</p> + </div> + </solution> + </problem>""") + it 'converts StringResponse with multiple answers to xml', -> + data = MarkdownEditingDescriptor.markdownToXml("""Who lead the civil right movement in the United States of America? + = Dr. Martin Luther King Jr. + or= Doctor Martin Luther King Junior + or= Martin Luther King + or= Martin Luther King Junior + + [Explanation] + Test Explanation. + [Explanation] + """) + expect(data).toEqual("""<problem> + <p>Who lead the civil right movement in the United States of America?</p> + <stringresponse answer="Dr. Martin Luther King Jr._or_Doctor Martin Luther King Junior_or_Martin Luther King_or_Martin Luther King Junior" type="ci"> + <textline size="20"/> + </stringresponse> + + <solution> + <div class="detailed-solution"> + <p>Explanation</p> + + <p>Test Explanation.</p> + </div> </solution> </problem>""") @@ -273,26 +314,26 @@ describe 'MarkdownEditingDescriptor', -> data = MarkdownEditingDescriptor.markdownToXml("""Not a header A header ============== - + Multiple choice w/ parentheticals ( ) option (with parens) ( ) xd option (x) ()) parentheses inside () no space b4 close paren - + Choice checks [ ] option1 [x] [x] correct [x] redundant [(] distractor [] no space - + Option with multiple correct ones [[one option, (correct one), (should not be correct)]] - + Option with embedded parens [[My (heart), another, (correct)]] - + What happens w/ empty correct options? [[()]] @@ -300,21 +341,21 @@ describe 'MarkdownEditingDescriptor', -> [explanation] orphaned start - + No p tags in the below <script type='javascript'> var two = 2; console.log(two * 2); </script> - + But in this there should be <div> Great ideas require offsetting. - + bad tests require drivel </div> - + [code] Code should be nicely monospaced. [/code] @@ -322,7 +363,7 @@ describe 'MarkdownEditingDescriptor', -> expect(data).toEqual("""<problem> <p>Not a header</p> <h1>A header</h1> - + <p>Multiple choice w/ parentheticals</p> <multiplechoiceresponse> <choicegroup type="MultipleChoice"> @@ -332,7 +373,7 @@ describe 'MarkdownEditingDescriptor', -> <choice correct="false">no space b4 close paren</choice> </choicegroup> </multiplechoiceresponse> - + <p>Choice checks</p> <choiceresponse> <checkboxgroup direction="vertical"> @@ -343,25 +384,25 @@ describe 'MarkdownEditingDescriptor', -> <choice correct="false">no space</choice> </checkboxgroup> </choiceresponse> - + <p>Option with multiple correct ones</p> - + <optionresponse> <optioninput options="('one option','correct one','should not be correct')" correct="correct one"></optioninput> </optionresponse> - + <p>Option with embedded parens</p> - + <optionresponse> <optioninput options="('My (heart)','another','correct')" correct="correct"></optioninput> </optionresponse> - + <p>What happens w/ empty correct options?</p> - + <optionresponse> <optioninput options="('')" correct=""></optioninput> </optionresponse> - + <solution> <div class="detailed-solution"> <p>Explanation</p> @@ -379,14 +420,14 @@ describe 'MarkdownEditingDescriptor', -> console.log(two * 2); </script> - + <p>But in this there should be</p> <div> <p>Great ideas require offsetting.</p> - + <p>bad tests require drivel</p> </div> - + <pre><code> Code should be nicely monospaced. </code></pre> diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js index 8921e819e4fcdd7e32968fe7cb018081295a277f..72b5e4e3b2268f2182723982f98173e662be40d4 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js @@ -270,7 +270,7 @@ } }); - // Disabled 10/29/13 due to flakiness in master + // Disabled 11/25/13 due to flakiness in master xdescribe('multiple YT on page', function () { var state1, state2, state3; diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 29f4567c8a1b479223aaa0fc590ec1ba273262f8..4e45d3283883fd7b83eab9b436a9f1ea54539b14 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -456,7 +456,7 @@ expect(videoCaption.currentIndex).toEqual(5); }); - // Disabled 10/25/13 due to flakiness in master + // Disabled 11/25/13 due to flakiness in master xit('scroll caption to new position', function () { expect($.fn.scrollTo).toHaveBeenCalled(); }); @@ -537,7 +537,7 @@ }); }); - // Disabled 10/23/13 due to flakiness in master + // Disabled 11/25/13 due to flakiness in master xdescribe('scrollCaption', function () { beforeEach(function () { initialize(); @@ -682,7 +682,7 @@ .toHaveAttr('title', 'Turn off captions'); }); - // Test turned off due to flakiness (30.10.2013). + // Test turned off due to flakiness (11/25/13) xit('scroll the caption', function () { // After transcripts are shown, and the video plays for a // bit. diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js index 6f33d978fb1941d3110dad66f924e8611c22cf44..71cf4f45c4302df844ad541d7b5990e2d0e4c8aa 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_focus_grabber_spec.js @@ -72,7 +72,17 @@ expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled(); }); - it('after controls hide focus grabbers are enabled', function () { + // Disabled on 18.11.2013 due to flakiness on local dev machine. + // + // Video FocusGrabber: after controls hide focus grabbers are + // enabled [fail] + // Expected spy enableFocusGrabber to have been called. + // + // Approximately 1 in 8 times this test fails. + // + // TODO: Most likely, focusGrabber will be disabled in the future. This + // test could become unneeded in the future. + xit('after controls hide focus grabbers are enabled', function () { runs(function () { // Captions should not be "sticky" for the autohide mechanism // to work. diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 88da768f84b31959791b396138a1bea531aca124..62e14753568a58d7aa33984905e356794424022b 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -4,11 +4,21 @@ videoProgressSlider, videoSpeedControl, videoVolumeControl, oldOTBD; - function initialize(fixture) { - if (typeof fixture === 'undefined') { - loadFixtures('video_all.html'); - } else { + function initialize(fixture, params) { + if (_.isString(fixture)) { loadFixtures(fixture); + } else { + if (_.isObject(fixture)) { + params = fixture; + } + + loadFixtures('video_all.html'); + } + + if (_.isObject(params)) { + $('#example') + .find('#video_id') + .data(params); } state = new Video('#example'); @@ -532,8 +542,54 @@ }); }); - // Disabled 10/24/13 due to flakiness in master - xdescribe('updatePlayTime', function () { + describe('update with start & end time', function () { + var START_TIME = 1, END_TIME = 2; + + beforeEach(function () { + initialize({start: START_TIME, end: END_TIME}); + + spyOn(videoPlayer, 'update').andCallThrough(); + spyOn(videoPlayer, 'pause').andCallThrough(); + spyOn(videoProgressSlider, 'notifyThroughHandleEnd') + .andCallThrough(); + }); + + it('video is paused on first endTime, start & end time are reset', function () { + var checkForStartEndTimeSet = true; + + videoProgressSlider.notifyThroughHandleEnd.reset(); + videoPlayer.pause.reset(); + videoPlayer.play(); + + waitsFor(function () { + if ( + !isFinite(videoPlayer.currentTime) || + videoPlayer.currentTime <= 0 + ) { + return false; + } + + if (checkForStartEndTimeSet) { + checkForStartEndTimeSet = false; + + expect(videoPlayer.startTime).toBe(START_TIME); + expect(videoPlayer.endTime).toBe(END_TIME); + } + + return videoPlayer.pause.calls.length === 1 + }, 5000, 'pause() has been called'); + + runs(function () { + expect(videoPlayer.startTime).toBe(0); + expect(videoPlayer.endTime).toBe(null); + + expect(videoProgressSlider.notifyThroughHandleEnd) + .toHaveBeenCalledWith({end: true}); + }); + }); + }); + + describe('updatePlayTime', function () { beforeEach(function () { initialize(); @@ -548,7 +604,7 @@ duration = videoPlayer.duration(); if (duration > 0) { - return true; + return true; } return false; @@ -612,6 +668,74 @@ }); }); + describe('updatePlayTime when start & end times are defined', function () { + var START_TIME = 1, + END_TIME = 2; + + beforeEach(function () { + initialize({start: START_TIME, end: END_TIME}); + + spyOn(videoPlayer, 'updatePlayTime').andCallThrough(); + spyOn(videoPlayer.player, 'seekTo').andCallThrough(); + spyOn(videoProgressSlider, 'updateStartEndTimeRegion') + .andCallThrough(); + }); + + it('when duration becomes available, updatePlayTime() is called', function () { + var duration; + + expect(videoPlayer.initialSeekToStartTime).toBeTruthy(); + expect(videoPlayer.seekToStartTimeOldSpeed).toBe('void'); + + videoPlayer.play(); + + waitsFor(function () { + duration = videoPlayer.duration(); + + return duration > 0 && + videoPlayer.initialSeekToStartTime === false; + }, 'duration becomes available', 1000); + + runs(function () { + expect(videoPlayer.startTime).toBe(START_TIME); + expect(videoPlayer.endTime).toBe(END_TIME); + + expect(videoPlayer.player.seekTo).toHaveBeenCalledWith(START_TIME); + + expect(videoProgressSlider.updateStartEndTimeRegion) + .toHaveBeenCalledWith({duration: duration}); + + expect(videoPlayer.seekToStartTimeOldSpeed).toBe(state.speed); + }); + }); + }); + + describe('updatePlayTime with invalid endTime', function () { + beforeEach(function () { + initialize({end: 100000}); + + spyOn(videoPlayer, 'updatePlayTime').andCallThrough(); + }); + + it('invalid endTime is reset to null', function () { + var duration; + + videoPlayer.updatePlayTime.reset(); + videoPlayer.play(); + + waitsFor(function () { + duration = videoPlayer.duration(); + + return duration > 0 && + videoPlayer.initialSeekToStartTime === false; + }, 'updatePlayTime was invoked and duration is set', 5000); + + runs(function () { + expect(videoPlayer.endTime).toBe(null); + }); + }); + }); + describe('toggleFullScreen', function () { describe('when the video player is not full screen', function () { beforeEach(function () { diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js index 751506b410bb3a9498ddc6fb08b0981d025423b7..a8450c39cdf5ee330805aa64292e648443af98aa 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_progress_slider_spec.js @@ -153,7 +153,7 @@ }); }); - // Turned off test due to flakiness (30.10.2013). + // Turned off test due to flakiness (11/25/13) xit('trigger seek event', function() { runs(function () { videoProgressSlider.onSlide( @@ -219,7 +219,7 @@ }); }); - // Turned off test due to flakiness (30.10.2013). + // Turned off test due to flakiness (11/25/13) xit('trigger seek event', function() { runs(function () { videoProgressSlider.onStop( @@ -285,6 +285,55 @@ expect(params).toEqual(expectedParams); }); }); + + describe('notifyThroughHandleEnd', function () { + beforeEach(function () { + initialize(); + + spyOnEvent(videoProgressSlider.handle, 'focus'); + spyOn(videoProgressSlider, 'notifyThroughHandleEnd') + .andCallThrough(); + }); + + it('params.end = true', function () { + videoProgressSlider.notifyThroughHandleEnd({end: true}); + + expect(videoProgressSlider.handle.attr('title')) + .toBe('video ended'); + + expect('focus').toHaveBeenTriggeredOn(videoProgressSlider.handle); + }); + + it('params.end = false', function () { + videoProgressSlider.notifyThroughHandleEnd({end: false}); + + expect(videoProgressSlider.handle.attr('title')) + .toBe('video position'); + + expect('focus').not.toHaveBeenTriggeredOn(videoProgressSlider.handle); + }); + + it('is called when video plays', function () { + videoPlayer.play(); + + waitsFor(function () { + var duration = videoPlayer.duration(), + currentTime = videoPlayer.currentTime; + + return ( + isFinite(duration) && + duration > 0 && + isFinite(currentTime) && + currentTime > 0 + ); + }, 'duration is set, video is playing', 5000); + + runs(function () { + expect(videoProgressSlider.notifyThroughHandleEnd) + .toHaveBeenCalledWith({end: false}); + }); + }); + }); }); }).call(this); diff --git a/common/lib/xmodule/xmodule/js/src/collapsible.coffee b/common/lib/xmodule/xmodule/js/src/collapsible.coffee index 2f6b2c97819046b3be13a2c7649239a1fa96cf40..837231c482be7f73d8f411acc2a0e48d5e3718f5 100644 --- a/common/lib/xmodule/xmodule/js/src/collapsible.coffee +++ b/common/lib/xmodule/xmodule/js/src/collapsible.coffee @@ -1,7 +1,7 @@ class @Collapsible # Set of library functions that provide a simple way to add collapsible - # functionality to elements. + # functionality to elements. # setCollapsibles: # Scan element's content for generic collapsible containers @@ -9,12 +9,15 @@ class @Collapsible ### el: container ### + linkTop = '<a href="#" class="full full-top">See full output</a>' + linkBottom = '<a href="#" class="full full-bottom">See full output</a>' + # standard longform + shortfom pattern el.find('.longform').hide() - el.find('.shortform').append('<a href="#" class="full">See full output</a>') + el.find('.shortform').append(linkTop, linkBottom) # custom longform + shortform text pattern - short_custom = el.find('.shortform-custom') + short_custom = el.find('.shortform-custom') # set up each one individually short_custom.each (index, elt) => open_text = $(elt).data('open-text') @@ -31,13 +34,18 @@ class @Collapsible @toggleFull: (event, open_text, close_text) => event.preventDefault() - $(event.target).parent().siblings().slideToggle() - $(event.target).parent().parent().toggleClass('open') + parent = $(event.target).parent() + parent.siblings().slideToggle() + parent.parent().toggleClass('open') if $(event.target).text() == open_text new_text = close_text else new_text = open_text - $(event.target).text(new_text) + if $(event.target).hasClass('full') + el = parent.find('.full') + else + el = $(event.target) + el.text(new_text) @toggleHint: (event) => event.preventDefault() diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index ba85db574b0004e78ea4f133d69b8823ba2946b0..9f5d36100949c3d65e4cdb9bc6b8bf6b63526d15 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -228,11 +228,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor }); // replace string and numerical - xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) { - var string; - var floatValue = parseFloat(p); + xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) { + var string, + answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'), + floatValue = parseFloat(answersList[0]); + if(!isNaN(floatValue)) { - var params = /(.*?)\+\-\s*(.*?$)/.exec(p); + var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]); if(params) { string = '<numericalresponse answer="' + floatValue + '">\n'; string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n'; @@ -242,10 +244,16 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor string += ' <formulaequationinput />\n'; string += '</numericalresponse>\n\n'; } else { - string = '<stringresponse answer="' + p + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n'; + var answers = []; + + for(var i = 0; i < answersList.length; i++) { + answers.push(answersList[i]) + } + + string = '<stringresponse answer="' + answers.join('_or_') + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n'; } return string; - }); + }); // replace selects xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) { @@ -262,13 +270,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor selectString += '</optionresponse>\n\n'; return selectString; }); - + // replace explanations xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) { var selectString = '<solution>\n<div class="detailed-solution">\nExplanation\n\n' + p1 + '\n</div>\n</solution>'; return selectString; }); - + // replace code blocks xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, function(match, p1) { var selectString = '<pre><code>\n' + p1 + '</code></pre>'; @@ -293,7 +301,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor // rid white space xml = xml.replace(/\n\n\n/g, '\n'); - + // surround w/ problem tag xml = '<problem>\n' + xml + '\n</problem>'; diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index c5dab0af85f0866a2fb1eab608b06b2d1a421f4f..50eff093ee658c1fb91edb977cac26c900b01cf0 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -3,7 +3,7 @@ // VideoPlayer module. define( 'video/03_video_player.js', -['video/02_html5_video.js', 'video/00_resizer.js' ], +['video/02_html5_video.js', 'video/00_resizer.js'], function (HTML5Video, Resizer) { var dfd = $.Deferred(); @@ -83,11 +83,9 @@ function (HTML5Video, Resizer) { state.videoPlayer.initialSeekToStartTime = true; - state.videoPlayer.oneTimePauseAtEndTime = true; - - // The initial value of the variable `seekToStartTimeOldSpeed` - // should always differ from the value returned by the duration - // function. + // At the start, the initial value of the variable + // `seekToStartTimeOldSpeed` should always differ from the value + // returned by the duration function. state.videoPlayer.seekToStartTimeOldSpeed = 'void'; state.videoPlayer.playerVars = { @@ -215,8 +213,7 @@ function (HTML5Video, Resizer) { // This function gets the video's current play position in time // (currentTime) and its duration. - // It is called at a regular interval when the video is playing (see - // below). + // It is called at a regular interval when the video is playing. function update() { this.videoPlayer.currentTime = this.videoPlayer.player .getCurrentTime(); @@ -224,22 +221,28 @@ function (HTML5Video, Resizer) { if (isFinite(this.videoPlayer.currentTime)) { this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime); - // We need to pause the video is current time is smaller (or equal) - // than end time. Also, we must make sure that the end time is the - // one that was set in the configuration parameter. If it differs, - // this means that it was either reset to the end, or the duration - // changed it's value. + // We need to pause the video if current time is smaller (or equal) + // than end time. Also, we must make sure that this is only done + // once. // - // In the case of YouTube Flash mode, we must remember that the - // start and end times are rescaled based on the current speed of - // the video. + // If `endTime` is not `null`, then we are safe to pause the + // video. `endTime` will be set to `null`, and this if statement + // will not be executed on next runs. if ( - this.videoPlayer.endTime <= this.videoPlayer.currentTime && - this.videoPlayer.oneTimePauseAtEndTime + this.videoPlayer.endTime != null && + this.videoPlayer.endTime <= this.videoPlayer.currentTime ) { - this.videoPlayer.oneTimePauseAtEndTime = false; this.videoPlayer.pause(); - this.videoPlayer.endTime = this.videoPlayer.duration(); + + // After the first time the video reached the `endTime`, + // `startTime` and `endTime` are disabled. The video will play + // from start to the end on subsequent runs. + this.videoPlayer.startTime = 0; + this.videoPlayer.endTime = null; + + this.trigger('videoProgressSlider.notifyThroughHandleEnd', { + end: true + }); } } } @@ -321,8 +324,10 @@ function (HTML5Video, Resizer) { } ); + // After the user seeks, startTime and endTime are disabled. The video + // will play from start to the end on subsequent runs. this.videoPlayer.startTime = 0; - this.videoPlayer.endTime = duration; + this.videoPlayer.endTime = null; this.videoPlayer.player.seekTo(newTime, true); @@ -344,11 +349,21 @@ function (HTML5Video, Resizer) { var time = this.videoPlayer.duration(); this.trigger('videoControl.pause', null); + this.trigger('videoProgressSlider.notifyThroughHandleEnd', { + end: true + }); if (this.config.show_captions) { this.trigger('videoCaption.pause', null); } + // When only `startTime` is set, the video will play to the end + // starting at `startTime`. After the first time the video reaches the + // end, `startTime` and `endTime` are disabled. The video will play + // from start to the end on subsequent runs. + this.videoPlayer.startTime = 0; + this.videoPlayer.endTime = null; + // Sometimes `onEnded` events fires when `currentTime` not equal // `duration`. In this case, slider doesn't reach the end point of // timeline. @@ -391,6 +406,10 @@ function (HTML5Video, Resizer) { this.trigger('videoControl.play', null); + this.trigger('videoProgressSlider.notifyThroughHandleEnd', { + end: false + }); + if (this.config.show_captions) { this.trigger('videoCaption.play', null); } @@ -531,7 +550,7 @@ function (HTML5Video, Resizer) { function updatePlayTime(time) { var duration = this.videoPlayer.duration(), - durationChange; + durationChange, tempStartTime, tempEndTime; if ( duration > 0 && @@ -545,13 +564,23 @@ function (HTML5Video, Resizer) { this.videoPlayer.initialSeekToStartTime === false ) { durationChange = true; - } else { + } else { // this.videoPlayer.initialSeekToStartTime === true + this.videoPlayer.initialSeekToStartTime = false; + durationChange = false; } - this.videoPlayer.initialSeekToStartTime = false; this.videoPlayer.seekToStartTimeOldSpeed = this.speed; + // Current startTime and endTime could have already been reset. + // We will remember their current values, and reset them at the + // end. We need to perform the below calculations on start and end + // times so that the range on the slider gets correctly updated in + // the case of speed change in Flash player mode (for YouTube + // videos). + tempStartTime = this.videoPlayer.startTime; + tempEndTime = this.videoPlayer.endTime; + // We retrieve the original times. They could have been changed due // to the fact of speed change (duration change). This happens when // in YouTube Flash mode. There each speed is a different video, @@ -566,31 +595,33 @@ function (HTML5Video, Resizer) { this.videoPlayer.startTime /= Number(this.speed); } } + + // An `endTime` of `null` means that either the user didn't set + // and `endTime`, or it was set to a value greater than the + // duration of the video. + // + // If `endTime` is `null`, the video will play to the end. We do + // not set the `endTime` to the duration of the video because + // sometimes in YouTube mode the duration changes slightly during + // the course of playback. This would cause the video to pause just + // before the actual end of the video. if ( - this.videoPlayer.endTime === null || + this.videoPlayer.endTime !== null && this.videoPlayer.endTime > duration ) { - this.videoPlayer.endTime = duration; - } else { + this.videoPlayer.endTime = null; + } else if (this.videoPlayer.endTime !== null) { if (this.currentPlayerMode === 'flash') { this.videoPlayer.endTime /= Number(this.speed); } } - // If this is not a duration change (if it is, we continue playing - // from current time), then we need to seek the video to the start - // time. - // - // We seek only if start time differs from zero. - if (durationChange === false && this.videoPlayer.startTime > 0) { - this.videoPlayer.player.seekTo(this.videoPlayer.startTime); - } - // Rebuild the slider start-end range (if it doesn't take up the - // whole slider). + // whole slider). Remember that endTime === null means the end time + // is set to the end of video by default. if (!( this.videoPlayer.startTime === 0 && - this.videoPlayer.endTime === duration + this.videoPlayer.endTime === null )) { this.trigger( 'videoProgressSlider.updateStartEndTimeRegion', @@ -599,6 +630,28 @@ function (HTML5Video, Resizer) { } ); } + + // If this is not a duration change (if it is, we continue playing + // from current time), then we need to seek the video to the start + // time. + // + // We seek only if start time differs from zero, and we haven't + // performed already such a seek. + if ( + durationChange === false && + this.videoPlayer.startTime > 0 && + !(tempStartTime === 0 && tempEndTime === null) + ) { + this.videoPlayer.player.seekTo(this.videoPlayer.startTime); + } + + // Reset back the actual startTime and endTime if they have been + // already reset (a seek event happened, the video already ended + // once, or endTime has already been reached once). + if (tempStartTime === 0 && tempEndTime === null) { + this.videoPlayer.startTime = 0; + this.videoPlayer.endTime = null; + } } this.trigger( diff --git a/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js b/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js index 59750a0f11f5850ade0f54cbc02f6a8a93485521..e10c4fbb243f33ae2eadabc1f0920e8ac8f7beb4 100644 --- a/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js +++ b/common/lib/xmodule/xmodule/js/src/video/06_video_progress_slider.js @@ -41,7 +41,8 @@ function () { onSlide: onSlide, onStop: onStop, updatePlayTime: updatePlayTime, - updateStartEndTimeRegion: updateStartEndTimeRegion + updateStartEndTimeRegion: updateStartEndTimeRegion, + notifyThroughHandleEnd: notifyThroughHandleEnd }; state.bindTo(methodsDict, state.videoProgressSlider, state); @@ -111,11 +112,6 @@ function () { duration = params.duration; } - // If the range spans the entire length of video, we don't do anything. - if (!this.videoPlayer.startTime && !this.videoPlayer.endTime) { - return; - } - start = this.videoPlayer.startTime; // If end is set to null, then we set it to the end of the video. We @@ -199,8 +195,6 @@ function () { }, 200); } - // Changed for tests -- JM: Check if it is the cause of Chrome Bug Valera - // noticed function updatePlayTime(params) { var time = Math.floor(params.time), duration = Math.floor(params.duration); @@ -215,6 +209,33 @@ function () { } } + // When the video stops playing (either because the end was reached, or + // because endTime was reached), the screen reader must be notified that + // the video is no longer playing. We do this by a little trick. Setting + // the title attribute of the slider know to "video ended", and focusing + // on it. The screen reader will read the attr text. + // + // The user can then tab his way forward, landing on the next control + // element, the Play button. + // + // @param params - object with property `end`. If set to true, the + // function must set the title attribute to + // `video ended`; + // if set to false, the function must reset the attr to + // it's original state. + // + // This function will be triggered from VideoPlayer methods onEnded(), + // onPlay(), and update() (update method handles endTime). + function notifyThroughHandleEnd(params) { + if (params.end) { + this.videoProgressSlider.handle + .attr('title', 'video ended') + .focus(); + } else { + this.videoProgressSlider.handle.attr('title', 'video position'); + } + } + // Returns a string describing the current time of video in hh:mm:ss // format. function getTimeDescription(time) { diff --git a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py index 33b65b8b10531950abfb5823565df0ec806cf36a..c7355c2f29a353998b7c728acfb20dea7e00bfb1 100644 --- a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py +++ b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py @@ -204,21 +204,16 @@ class LocMapperStore(object): self._decode_from_mongo(old_name), None) elif usage_id == locator.usage_id: - # figure out revision - # enforce the draft only if category in [..] logic - if category in draft.DIRECT_ONLY_CATEGORIES: - revision = None - elif locator.branch == candidate['draft_branch']: - revision = draft.DRAFT - else: - revision = None + # Always return revision=None because the + # old draft module store wraps locations as draft before + # trying to access things. return Location( 'i4x', candidate['_id']['org'], candidate['_id']['course'], category, self._decode_from_mongo(old_name), - revision) + None) return None def add_block_location_translator(self, location, old_course_id=None, usage_id=None): diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index f368032f174edc9946a5c633e203d91b20a07375..2f6fabfce0d4fb11ce8418eeefb92adab5df2819 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -778,11 +778,7 @@ class MongoModuleStore(ModuleStoreWriteBase): children: A list of child item identifiers """ - # We expect the children IDs to always be the non-draft version. With view refactoring - # for split, we are now passing the draft version in some cases. - children_ids = [Location(child).replace(revision=None).url() for child in children] - - self._update_single_item(location, {'definition.children': children_ids}) + self._update_single_item(location, {'definition.children': children}) # recompute (and update) the metadata inheritance tree which is cached self.refresh_cached_metadata_inheritance_tree(Location(location)) # fire signal that we've written to DB diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py index 6318c5e68f199e401ff9b4099db7c85d54860517..f7a025015552a757c4e328630dc82aee9a715ddc 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py @@ -81,7 +81,7 @@ class DraftModuleStore(MongoModuleStore): try: return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth)) except ItemNotFoundError: - return wrap_draft(super(DraftModuleStore, self).get_item(as_published(location), depth=depth)) + return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth)) def get_instance(self, course_id, location, depth=0): """ @@ -169,7 +169,7 @@ class DraftModuleStore(MongoModuleStore): try: draft_item = self.get_item(location) if not getattr(draft_item, 'is_draft', False): - self.convert_to_draft(as_published(location)) + self.convert_to_draft(location) except ItemNotFoundError, e: if not allow_not_found: raise e @@ -187,7 +187,7 @@ class DraftModuleStore(MongoModuleStore): draft_loc = as_draft(location) draft_item = self.get_item(location) if not getattr(draft_item, 'is_draft', False): - self.convert_to_draft(as_published(location)) + self.convert_to_draft(location) return super(DraftModuleStore, self).update_children(draft_loc, children) @@ -203,7 +203,7 @@ class DraftModuleStore(MongoModuleStore): draft_item = self.get_item(location) if not getattr(draft_item, 'is_draft', False): - self.convert_to_draft(as_published(location)) + self.convert_to_draft(location) if 'is_draft' in metadata: del metadata['is_draft'] @@ -262,7 +262,7 @@ class DraftModuleStore(MongoModuleStore): """ Turn the published version into a draft, removing the published version """ - self.convert_to_draft(as_published(location)) + self.convert_to_draft(location) super(DraftModuleStore, self).delete_item(location) def _query_children_for_cache_children(self, items): diff --git a/common/lib/xmodule/xmodule/modulestore/split_migrator.py b/common/lib/xmodule/xmodule/modulestore/split_migrator.py index 27a758c0839d05d56d1ad256d913d27ba544d2e0..355f5bfa62d3b5d972fe4f91b38349d7e3ef84a5 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/split_migrator.py @@ -88,7 +88,7 @@ class SplitMigrator(object): index_info = self.split_modulestore.get_course_index_info(course_version_locator) versions = index_info['versions'] versions['draft'] = versions['published'] - self.split_modulestore.update_course_index(course_version_locator, {'versions': versions}, update_versions=True) + self.split_modulestore.update_course_index(index_info) # clean up orphans in published version: in old mongo, parents pointed to the union of their published and draft # children which meant some pointers were to non-existent locations in 'direct' diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py index 9b2a652a95efe048f972e3650dbaf4243dbe1674..ded67104b4d11caad7a972a436ec7fd2bbc1754b 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py @@ -22,5 +22,4 @@ class DefinitionLazyLoader(object): Fetch the definition. Note, the caller should replace this lazy loader pointer with the result so as not to fetch more than once """ - return self.modulestore.definitions.find_one( - {'_id': self.definition_locator.definition_id}) + return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py new file mode 100644 index 0000000000000000000000000000000000000000..510c100048fc0b47a9f400e34683bac5bab27a7c --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py @@ -0,0 +1,116 @@ +""" +Segregation of pymongo functions from the data modeling mechanisms for split modulestore. +""" +import pymongo + +class MongoConnection(object): + """ + Segregation of pymongo functions from the data modeling mechanisms for split modulestore. + """ + def __init__( + self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs + ): + """ + Create & open the connection, authenticate, and provide pointers to the collections + """ + self.database = pymongo.database.Database( + pymongo.MongoClient( + host=host, + port=port, + tz_aware=tz_aware, + **kwargs + ), + db + ) + + if user is not None and password is not None: + self.database.authenticate(user, password) + + self.course_index = self.database[collection + '.active_versions'] + self.structures = self.database[collection + '.structures'] + self.definitions = self.database[collection + '.definitions'] + + # every app has write access to the db (v having a flag to indicate r/o v write) + # Force mongo to report errors, at the expense of performance + # pymongo docs suck but explanation: + # http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html + self.course_index.write_concern = {'w': 1} + self.structures.write_concern = {'w': 1} + self.definitions.write_concern = {'w': 1} + + def get_structure(self, key): + """ + Get the structure from the persistence mechanism whose id is the given key + """ + return self.structures.find_one({'_id': key}) + + def find_matching_structures(self, query): + """ + Find the structure matching the query. Right now the query must be a legal mongo query + :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + """ + return self.structures.find(query) + + def insert_structure(self, structure): + """ + Create the structure in the db + """ + self.structures.insert(structure) + + def update_structure(self, structure): + """ + Update the db record for structure + """ + self.structures.update({'_id': structure['_id']}, structure) + + def get_course_index(self, key): + """ + Get the course_index from the persistence mechanism whose id is the given key + """ + return self.course_index.find_one({'_id': key}) + + def find_matching_course_indexes(self, query): + """ + Find the course_index matching the query. Right now the query must be a legal mongo query + :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + """ + return self.course_index.find(query) + + def insert_course_index(self, course_index): + """ + Create the course_index in the db + """ + self.course_index.insert(course_index) + + def update_course_index(self, course_index): + """ + Update the db record for course_index + """ + self.course_index.update({'_id': course_index['_id']}, course_index) + + def delete_course_index(self, key): + """ + Delete the course_index from the persistence mechanism whose id is the given key + """ + return self.course_index.remove({'_id': key}) + + def get_definition(self, key): + """ + Get the definition from the persistence mechanism whose id is the given key + """ + return self.definitions.find_one({'_id': key}) + + def find_matching_definitions(self, query): + """ + Find the definitions matching the query. Right now the query must be a legal mongo query + :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + """ + return self.definitions.find(query) + + def insert_definition(self, definition): + """ + Create the definition in the db + """ + self.definitions.insert(definition) + + diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 61524165968f656a3731a43ccdb93a52d63c9619..a6349e6113b61ad016830e451adc2dbe166dcbe9 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -1,7 +1,6 @@ import threading import datetime import logging -import pymongo import re from importlib import import_module from path import path @@ -21,6 +20,7 @@ from .caching_descriptor_system import CachingDescriptorSystem from xblock.fields import Scope from xblock.runtime import Mixologist from bson.objectid import ObjectId +from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection log = logging.getLogger(__name__) #============================================================================== @@ -49,7 +49,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): A Mongodb backed ModuleStore supporting versions, inheritance, and sharing. """ - # pylint: disable=W0201 def __init__(self, doc_store_config, fs_root, render_template, default_class=None, error_tracker=null_error_tracker, @@ -62,44 +61,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): super(SplitMongoModuleStore, self).__init__(**kwargs) self.loc_mapper = loc_mapper - def do_connection( - db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs - ): - """ - Create & open the connection, authenticate, and provide pointers to the collections - """ - self.db = pymongo.database.Database( - pymongo.MongoClient( - host=host, - port=port, - tz_aware=tz_aware, - **kwargs - ), - db - ) - - if user is not None and password is not None: - self.db.authenticate(user, password) - - self.course_index = self.db[collection + '.active_versions'] - self.structures = self.db[collection + '.structures'] - self.definitions = self.db[collection + '.definitions'] - - do_connection(**doc_store_config) + self.db_connection = MongoConnection(**doc_store_config) + self.db = self.db_connection.database # Code review question: How should I expire entries? # _add_cache could use a lru mechanism to control the cache size? self.thread_cache = threading.local() - - # every app has write access to the db (v having a flag to indicate r/o v write) - # Force mongo to report errors, at the expense of performance - # pymongo docs suck but explanation: - # http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html - self.course_index.write_concern = {'w': 1} - self.structures.write_concern = {'w': 1} - self.definitions.write_concern = {'w': 1} - if default_class is not None: module_path, _, class_name = default_class.rpartition('.') class_ = getattr(import_module(module_path), class_name) @@ -138,7 +106,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): block['definition'] = DefinitionLazyLoader(self, block['definition']) else: # Load all descendants by id - descendent_definitions = self.definitions.find({ + descendent_definitions = self.db_connection.find_matching_definitions({ '_id': {'$in': [block['definition'] for block in new_module_data.itervalues()]}}) # turn into a map @@ -226,7 +194,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if course_locator.course_id is not None and course_locator.branch is not None: # use the course_id - index = self.course_index.find_one({'_id': course_locator.course_id}) + index = self.db_connection.get_course_index(course_locator.course_id) if index is None: raise ItemNotFoundError(course_locator) if course_locator.branch not in index['versions']: @@ -241,7 +209,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # cast string to ObjectId if necessary version_guid = course_locator.as_object_id(version_guid) - entry = self.structures.find_one({'_id': version_guid}) + entry = self.db_connection.get_structure(version_guid) # b/c more than one course can use same structure, the 'course_id' and 'branch' are not intrinsic to structure # and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so, @@ -269,7 +237,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if qualifiers is None: qualifiers = {} qualifiers.update({"versions.{}".format(branch): {"$exists": True}}) - matching = self.course_index.find(qualifiers) + matching = self.db_connection.find_matching_course_indexes(qualifiers) # collect ids and then query for those version_guids = [] @@ -279,7 +247,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): version_guids.append(version_guid) id_version_map[version_guid] = structure['_id'] - course_entries = self.structures.find({'_id': {'$in': version_guids}}) + course_entries = self.db_connection.find_matching_structures({'_id': {'$in': version_guids}}) # get the block for the course element (s/b the root) result = [] @@ -455,7 +423,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): """ if course_locator.course_id is None: return None - index = self.course_index.find_one({'_id': course_locator.course_id}) + index = self.db_connection.get_course_index(course_locator.course_id) return index # TODO figure out a way to make this info accessible from the course descriptor @@ -487,7 +455,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'edited_on': when the change was made } """ - definition = self.definitions.find_one({'_id': definition_locator.definition_id}) + definition = self.db_connection.get_definition(definition_locator.definition_id) if definition is None: return None return definition['edit_info'] @@ -509,14 +477,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # TODO if depth is significant, it may make sense to get all that have the same original_version # and reconstruct the subtree from version_guid - next_entries = self.structures.find({'previous_version' : version_guid}) + next_entries = self.db_connection.find_matching_structures({'previous_version' : version_guid}) # must only scan cursor's once next_versions = [struct for struct in next_entries] result = {version_guid: [CourseLocator(version_guid=struct['_id']) for struct in next_versions]} depth = 1 while depth < version_history_depth and len(next_versions) > 0: depth += 1 - next_entries = self.structures.find({'previous_version': + next_entries = self.db_connection.find_matching_structures({'previous_version': {'$in': [struct['_id'] for struct in next_versions]}}) next_versions = [struct for struct in next_entries] for course_structure in next_versions: @@ -537,7 +505,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): course_struct = self._lookup_course(block_locator.version_agnostic())['structure'] usage_id = block_locator.usage_id update_version_field = 'blocks.{}.edit_info.update_version'.format(usage_id) - all_versions_with_block = self.structures.find({'original_version': course_struct['original_version'], + all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'], update_version_field: {'$exists': True}}) # find (all) root versions and build map previous: [successors] possible_roots = [] @@ -596,7 +564,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): "original_version": new_id, } } - self.definitions.insert(document) + self.db_connection.insert_definition(document) definition_locator = DefinitionLocator(new_id) return definition_locator @@ -618,7 +586,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # if this looks in cache rather than fresh fetches, then it will probably not detect # actual change b/c the descriptor and cache probably point to the same objects - old_definition = self.definitions.find_one({'_id': definition_locator.definition_id}) + old_definition = self.db_connection.get_definition(definition_locator.definition_id) if old_definition is None: raise ItemNotFoundError(definition_locator.url()) @@ -630,7 +598,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): old_definition['edit_info']['edited_on'] = datetime.datetime.now(UTC) # previous version id old_definition['edit_info']['previous_version'] = definition_locator.definition_id - self.definitions.insert(old_definition) + self.db_connection.insert_definition(old_definition) return DefinitionLocator(old_definition['_id']), True else: return definition_locator, False @@ -657,7 +625,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_blocks: the current list of blocks. :param category: """ - existing_uses = self.course_index.find({"_id": {"$regex": id_root}}) + existing_uses = self.db_connection.find_matching_course_indexes({"_id": {"$regex": id_root}}) if existing_uses.count() > 0: max_found = 0 matcher = re.compile(id_root + r'(\d+)') @@ -779,11 +747,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): parent['edit_info']['update_version'] = new_id if continue_version: # db update - self.structures.update({'_id': new_id}, new_structure) + self.db_connection.update_structure(new_structure) # clear cache so things get refetched and inheritance recomputed self._clear_cache(new_id) else: - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) # update the index entry if appropriate if index_entry is not None: @@ -856,7 +824,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'original_version': definition_id, } } - self.definitions.insert(definition_entry) + self.db_connection.insert_definition(definition_entry) new_id = ObjectId() draft_structure = { @@ -880,7 +848,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): } } } - self.structures.insert(draft_structure) + self.db_connection.insert_structure(draft_structure) if versions_dict is None: versions_dict = {master_branch: new_id} @@ -898,20 +866,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if block_fields is not None: root_block['fields'].update(block_fields) if definition_fields is not None: - definition = self.definitions.find_one({'_id': root_block['definition']}) + definition = self.db_connection.get_definition(root_block['definition']) definition['fields'].update(definition_fields) definition['edit_info']['previous_version'] = definition['_id'] definition['edit_info']['edited_by'] = user_id definition['edit_info']['edited_on'] = datetime.datetime.now(UTC) definition['_id'] = ObjectId() - self.definitions.insert(definition) + self.db_connection.insert_definition(definition) root_block['definition'] = definition['_id'] root_block['edit_info']['edited_on'] = datetime.datetime.now(UTC) root_block['edit_info']['edited_by'] = user_id root_block['edit_info']['previous_version'] = root_block['edit_info'].get('update_version') root_block['edit_info']['update_version'] = new_id - self.structures.insert(draft_structure) + self.db_connection.insert_structure(draft_structure) versions_dict[master_branch] = new_id # create the index entry @@ -926,7 +894,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'edited_by': user_id, 'edited_on': datetime.datetime.now(UTC), 'versions': versions_dict} - self.course_index.insert(index_entry) + self.db_connection.insert_course_index(index_entry) return self.get_course(CourseLocator(course_id=new_id, branch=master_branch)) def update_item(self, descriptor, user_id, force=False): @@ -978,7 +946,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'previous_version': block_data['edit_info']['update_version'], 'update_version': new_id, } - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) # update the index entry if appropriate if index_entry is not None: self._update_head(index_entry, descriptor.location.branch, new_id) @@ -1016,7 +984,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): is_updated = self._persist_subdag(xblock, user_id, new_structure['blocks'], new_id) if is_updated: - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) # update the index entry if appropriate if index_entry is not None: @@ -1115,31 +1083,18 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): '''Deprecated, use update_item.''' raise NotImplementedError('use update_item') - def update_course_index(self, course_locator, new_values_dict, update_versions=False): + def update_course_index(self, updated_index_entry): """ - Change the given course's index entry for the given fields. new_values_dict - should be a subset of the dict returned by get_course_index_info. - It cannot include '_id' (will raise IllegalArgument). - Provide update_versions=True if you intend this to replace the versions hash. - Note, this operation can be dangerous and break running courses. + Change the given course's index entry. - If the dict includes versions and not update_versions, it will raise an exception. - - If the dict includes edited_on or edited_by, it will raise an exception + Note, this operation can be dangerous and break running courses. Does not return anything useful. """ # TODO how should this log the change? edited_on and edited_by for this entry # has the semantic of who created the course and when; so, changing those will lose # that information. - if '_id' in new_values_dict: - raise ValueError("Cannot override _id") - if 'edited_on' in new_values_dict or 'edited_by' in new_values_dict: - raise ValueError("Cannot set edited_on or edited_by") - if not update_versions and 'versions' in new_values_dict: - raise ValueError("Cannot override versions without setting update_versions") - self.course_index.update({'_id': course_locator.course_id}, - {'$set': new_values_dict}) + self.db_connection.update_course_index(updated_index_entry) def delete_item(self, usage_locator, user_id, delete_children=False, force=False): """ @@ -1182,7 +1137,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): remove_subtree(usage_locator.usage_id) # update index if appropriate and structures - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) result = CourseLocator(version_guid=new_id) @@ -1204,11 +1159,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_id: uses course_id rather than locator to emphasize its global effect """ - index = self.course_index.find_one({'_id': course_id}) + index = self.db_connection.get_course_index(course_id) if index is None: raise ItemNotFoundError(course_id) # this is the only real delete in the system. should it do something else? - self.course_index.remove(index['_id']) + self.db_connection.delete_course_index(index['_id']) def get_errored_courses(self): """ @@ -1296,7 +1251,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): block['fields']["children"] = [ usage_id for usage_id in block['fields']["children"] if usage_id in original_structure['blocks'] ] - self.structures.update({'_id': original_structure['_id']}, original_structure) + self.db_connection.update_structure(original_structure) # clear cache again b/c inheritance may be wrong over orphans self._clear_cache(original_structure['_id']) @@ -1379,7 +1334,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): else: return None else: - index_entry = self.course_index.find_one({'_id': locator.course_id}) + index_entry = self.db_connection.get_course_index(locator.course_id) is_head = ( locator.version_guid is None or index_entry['versions'][locator.branch] == locator.version_guid @@ -1424,9 +1379,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_locator: :param new_id: """ - self.course_index.update( - {"_id": index_entry["_id"]}, - {"$set": {"versions.{}".format(branch): new_id}}) + index_entry['versions'][branch] = new_id + self.db_connection.update_course_index(index_entry) def _partition_fields_by_scope(self, category, fields): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index 2a1c165993614aa72019c59f47e42498da9c160a..2fbff10423897ea16910632ba2450f95ac6d17a3 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -274,7 +274,9 @@ class TestLocationMapper(unittest.TestCase): course_id=prob_locator.course_id, branch='draft', usage_id=prob_locator.usage_id ) prob_location = loc_mapper().translate_locator_to_location(prob_locator) - self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', 'draft')) + # Even though the problem was set as draft, we always return revision=None to work + # with old mongo/draft modulestores. + self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) prob_locator = BlockUsageLocator( course_id=new_style_course_id, usage_id='problem2', branch='production' ) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py index 049fbd2ef800e33181535ac3ba564d1adf0c095a..74975f28961b44d876441b82ffa1be7ff2f5b92e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py @@ -59,9 +59,9 @@ class TestMigration(unittest.TestCase): dbref = self.loc_mapper.db dbref.drop_collection(self.loc_mapper.location_map) split_db = self.split_mongo.db - split_db.drop_collection(split_db.course_index) - split_db.drop_collection(split_db.structures) - split_db.drop_collection(split_db.definitions) + split_db.drop_collection(self.split_mongo.db_connection.course_index) + split_db.drop_collection(self.split_mongo.db_connection.structures) + split_db.drop_collection(self.split_mongo.db_connection.definitions) # old_mongo doesn't give a db attr, but all of the dbs are the same dbref.drop_collection(self.old_mongo.collection) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index aa095d8e4c4a35bf5cc38010448b20f47401e52a..92de35e39f78382534c6315e4584efcfd2aa4c7b 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -1018,41 +1018,29 @@ class TestCourseCreation(SplitModuleTest): Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc. """ locator = CourseLocator(course_id="GreekHero", branch='draft') - modulestore().update_course_index(locator, {'org': 'funkyU'}) + course_info = modulestore().get_course_index_info(locator) + course_info['org'] = 'funkyU' + modulestore().update_course_index(course_info) course_info = modulestore().get_course_index_info(locator) self.assertEqual(course_info['org'], 'funkyU') - modulestore().update_course_index(locator, {'org': 'moreFunky', 'prettyid': 'Ancient Greek Demagods'}) + course_info['org'] = 'moreFunky' + course_info['prettyid'] = 'Ancient Greek Demagods' + modulestore().update_course_index(course_info) course_info = modulestore().get_course_index_info(locator) self.assertEqual(course_info['org'], 'moreFunky') self.assertEqual(course_info['prettyid'], 'Ancient Greek Demagods') - self.assertRaises(ValueError, modulestore().update_course_index, locator, {'_id': 'funkygreeks'}) - - with self.assertRaises(ValueError): - modulestore().update_course_index( - locator, - {'edited_on': datetime.datetime.now(UTC)} - ) - with self.assertRaises(ValueError): - modulestore().update_course_index( - locator, - {'edited_by': 'sneak'} - ) - - self.assertRaises(ValueError, modulestore().update_course_index, locator, - {'versions': {'draft': self.GUID_D1}}) - # an allowed but not necessarily recommended way to revert the draft version versions = course_info['versions'] versions['draft'] = self.GUID_D1 - modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) + modulestore().update_course_index(course_info) course = modulestore().get_course(locator) self.assertEqual(str(course.location.version_guid), self.GUID_D1) # an allowed but not recommended way to publish a course versions['published'] = self.GUID_D1 - modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) + modulestore().update_course_index(course_info) course = modulestore().get_course(CourseLocator(course_id=locator.course_id, branch="published")) self.assertEqual(str(course.location.version_guid), self.GUID_D1) @@ -1068,9 +1056,9 @@ class TestCourseCreation(SplitModuleTest): self.assertEqual(new_course.location.usage_id, 'top') self.assertEqual(new_course.category, 'chapter') # look at db to verify - db_structure = modulestore().structures.find_one({ - '_id': new_course.location.as_object_id(new_course.location.version_guid) - }) + db_structure = modulestore().db_connection.get_structure( + new_course.location.as_object_id(new_course.location.version_guid) + ) self.assertIsNotNone(db_structure, "Didn't find course") self.assertNotIn('course', db_structure['blocks']) self.assertIn('top', db_structure['blocks']) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 5512af21431d4279e8b3b625ae5ee5e430f06ad4..9d055356fcb3dee41a8eb2d6806778c305e6be2b 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -79,7 +79,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # tags that really need unique names--they store (or should store) state. need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', - 'videosequence', 'poll_question', 'timelimit') + 'videosequence', 'poll_question', 'vertical') attr = xml_data.attrib tag = xml_data.tag diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index 6e33a54323f8e3f608480beb840a0c10ed539219..2b5efcb87f12c606544db45441ad01b9b870f73d 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -97,16 +97,15 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d if len(draft_verticals) > 0: draft_course_dir = export_fs.makeopendir('drafts') for draft_vertical in draft_verticals: - if getattr(draft_vertical, 'is_draft', False): - parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id) - # Don't try to export orphaned items. - if len(parent_locs) > 0: - logging.debug('parent_locs = {0}'.format(parent_locs)) - draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url() - sequential = modulestore.get_item(Location(parent_locs[0])) - index = sequential.children.index(draft_vertical.location.url()) - draft_vertical.xml_attributes['index_in_children_list'] = str(index) - draft_vertical.export_to_xml(draft_course_dir) + parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id) + # Don't try to export orphaned items. + if len(parent_locs) > 0: + logging.debug('parent_locs = {0}'.format(parent_locs)) + draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url() + sequential = modulestore.get_item(Location(parent_locs[0])) + index = sequential.children.index(draft_vertical.location.url()) + draft_vertical.xml_attributes['index_in_children_list'] = str(index) + draft_vertical.export_to_xml(draft_course_dir) def export_extra_content(export_fs, modulestore, course_id, course_location, category_type, dirname, file_suffix=''): diff --git a/common/lib/xmodule/xmodule/tests/rendering/__init__.py b/common/lib/xmodule/xmodule/tests/rendering/__init__.py index 9a3a52262ecfa20e9b2a003d8ec988777b4dd22f..d93c168c8c58bf5c8c73baa8af1e3c8b5aadc8f1 100644 --- a/common/lib/xmodule/xmodule/tests/rendering/__init__.py +++ b/common/lib/xmodule/xmodule/tests/rendering/__init__.py @@ -1,2 +1 @@ import core -import xmodule_asserts \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py b/common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py deleted file mode 100644 index fa4dd66b06d9908f82574fc4cd5d1e215c134f77..0000000000000000000000000000000000000000 --- a/common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -View assertion functions for XModules -""" - -from __future__ import absolute_import - -from nose.tools import assert_equals, assert_not_equals # pylint: disable=no-name-in-module - -from xmodule.timelimit_module import TimeLimitModule, TimeLimitDescriptor - -from xmodule.tests.rendering.core import assert_student_view_valid_html, assert_student_view_invalid_html - - -@assert_student_view_valid_html.register(TimeLimitModule) -@assert_student_view_valid_html.register(TimeLimitDescriptor) -def _(block, html): - """ - Assert that a TimeLimitModule renders student_view html correctly - """ - assert_not_equals(0, block.get_display_items()) - assert_student_view_valid_html(block.get_children()[0], html) - - -@assert_student_view_invalid_html.register(TimeLimitModule) -@assert_student_view_invalid_html.register(TimeLimitDescriptor) -def _(block, html): - """ - Assert that a TimeLimitModule renders student_view html correctly - """ - assert_equals(0, len(block.get_display_items())) - assert_equals(u"", html) diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 04a4e84f73cdfc9980ed6d429c7a649e86d86ea4..c1f4bb2ee2e544abd944275d3f17f86739d205e6 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -92,6 +92,16 @@ class ImportTestCase(BaseCourseTestCase): self.assertNotEqual(descriptor1.location, descriptor2.location) + # Check that each vertical gets its very own url_name + bad_xml = '''<vertical display_name="abc"><problem url_name="exam1:2013_Spring:abc"/></vertical>''' + bad_xml2 = '''<vertical display_name="abc"><problem url_name="exam2:2013_Spring:abc"/></vertical>''' + + descriptor1 = system.process_xml(bad_xml) + descriptor2 = system.process_xml(bad_xml2) + + self.assertNotEqual(descriptor1.location, descriptor2.location) + + def test_reimport(self): '''Make sure an already-exported error xml tag loads properly''' diff --git a/common/lib/xmodule/xmodule/timelimit_module.py b/common/lib/xmodule/xmodule/timelimit_module.py deleted file mode 100644 index 73744b5e8bd75e9ec6efa7c69f98a6689cb98985..0000000000000000000000000000000000000000 --- a/common/lib/xmodule/xmodule/timelimit_module.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging - -from lxml import etree -from time import time - -from xmodule.editing_module import XMLEditingDescriptor -from xmodule.xml_module import XmlDescriptor -from xmodule.x_module import XModule -from xmodule.progress import Progress -from xmodule.exceptions import NotFoundError -from xblock.fields import Float, String, Boolean, Scope -from xblock.fragment import Fragment - - -log = logging.getLogger(__name__) - - -class TimeLimitFields(object): - has_children = True - - beginning_at = Float(help="The time this timer was started", scope=Scope.user_state) - ending_at = Float(help="The time this timer will end", scope=Scope.user_state) - accomodation_code = String(help="A code indicating accommodations to be given the student", scope=Scope.user_state) - time_expired_redirect_url = String(help="Url to redirect users to after the timelimit has expired", scope=Scope.settings) - duration = Float(help="The length of this timer", scope=Scope.settings) - suppress_toplevel_navigation = Boolean(help="Whether the toplevel navigation should be suppressed when viewing this module", scope=Scope.settings) - - -class TimeLimitModule(TimeLimitFields, XModule): - ''' - Wrapper module which imposes a time constraint for the completion of its child. - ''' - - # For a timed activity, we are only interested here - # in time-related accommodations, and these should be disjoint. - # (For proctored exams, it is possible to have multiple accommodations - # apply to an exam, so they require accommodating a multi-choice.) - TIME_ACCOMMODATION_CODES = (('NONE', 'No Time Accommodation'), - ('ADDHALFTIME', 'Extra Time - 1 1/2 Time'), - ('ADD30MIN', 'Extra Time - 30 Minutes'), - ('DOUBLE', 'Extra Time - Double Time'), - ('TESTING', 'Extra Time -- Large amount for testing purposes') - ) - - def _get_accommodated_duration(self, duration): - ''' - Get duration for activity, as adjusted for accommodations. - Input and output are expressed in seconds. - ''' - if self.accommodation_code is None or self.accommodation_code == 'NONE': - return duration - elif self.accommodation_code == 'ADDHALFTIME': - # TODO: determine what type to return - return int(duration * 1.5) - elif self.accommodation_code == 'ADD30MIN': - return (duration + (30 * 60)) - elif self.accommodation_code == 'DOUBLE': - return (duration * 2) - elif self.accommodation_code == 'TESTING': - # when testing, set timer to run for a week at a time. - return 3600 * 24 * 7 - - @property - def has_begun(self): - return self.beginning_at is not None - - @property - def has_ended(self): - if not self.ending_at: - return False - return self.ending_at < time() - - def begin(self, duration): - ''' - Sets the starting time and ending time for the activity, - based on the duration provided (in seconds). - ''' - self.beginning_at = time() - modified_duration = self._get_accommodated_duration(duration) - self.ending_at = self.beginning_at + modified_duration - - def get_remaining_time_in_ms(self): - return int((self.ending_at - time()) * 1000) - - def student_view(self, context): - # assumes there is one and only one child, so it only renders the first child - children = self.get_display_items() - if children: - child = children[0] - return child.render('student_view', context) - else: - return Fragment() - - def get_progress(self): - ''' Return the total progress, adding total done and total available. - (assumes that each submodule uses the same "units" for progress.) - ''' - # TODO: Cache progress or children array? - children = self.get_children() - progresses = [child.get_progress() for child in children] - progress = reduce(Progress.add_counts, progresses) - return progress - - def handle_ajax(self, _dispatch, _data): - raise NotFoundError('Unexpected dispatch type') - - def get_icon_class(self): - children = self.get_children() - if children: - return children[0].get_icon_class() - else: - return "other" - -class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor): - - module_class = TimeLimitModule - - @classmethod - def definition_from_xml(cls, xml_object, system): - children = [] - for child in xml_object: - try: - children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url()) - except Exception as e: - log.exception("Unable to load child when parsing TimeLimit wrapper. Continuing...") - if system.error_tracker is not None: - system.error_tracker("ERROR: " + str(e)) - continue - return {}, children - - def definition_to_xml(self, resource_fs): - xml_object = etree.Element('timelimit') - for child in self.get_children(): - xml_object.append( - etree.fromstring(child.export_to_xml(resource_fs))) - return xml_object diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index c64f1e0be3dd2afc70dc67e2f09c8611a73f55bb..e692dfb21ca4d76b6aef3fe1535c6b7ac620c273 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -3,7 +3,6 @@ import copy import logging import os import sys -from collections import namedtuple from lxml import etree from xblock.fields import Dict, Scope, ScopeIds @@ -133,15 +132,12 @@ class XmlDescriptor(XModuleDescriptor): 'ispublic', # if True, then course is listed for all users; see 'xqa_key', # for xqaa server access 'giturl', # url of git server for origin of file - # information about testcenter exams is a dict (of dicts), not a string, - # so it cannot be easily exportable as a course element's attribute. - 'testcenter_info', # VS[compat] Remove once unused. 'name', 'slug') metadata_to_strip = ('data_dir', 'tabs', 'grading_policy', 'published_by', 'published_date', - 'discussion_blackouts', 'testcenter_info', + 'discussion_blackouts', # VS[compat] -- remove the below attrs once everything is in the CMS 'course', 'org', 'url_name', 'filename', # Used for storing xml attributes between import and export, for roundtrips diff --git a/common/static/coffee/src/discussion/discussion.coffee b/common/static/coffee/src/discussion/discussion.coffee index 5a52cd4de01648a289d539215520eaa862b9ab78..954dd80129441d65646492a92a9d863857cd2f8b 100644 --- a/common/static/coffee/src/discussion/discussion.coffee +++ b/common/static/coffee/src/discussion/discussion.coffee @@ -25,9 +25,8 @@ if Backbone? @add model model - retrieveAnotherPage: (mode, options={}, sort_options={})-> - @current_page += 1 - data = { page: @current_page } + retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)-> + data = { page: @current_page + 1 } switch mode when 'search' url = DiscussionUtil.urlFor 'search' @@ -59,6 +58,7 @@ if Backbone? @reset new_collection @pages = response.num_pages @current_page = response.page + error: error sortByDate: (thread) -> # diff --git a/common/static/coffee/src/discussion/discussion_module_view.coffee b/common/static/coffee/src/discussion/discussion_module_view.coffee index 3dde9bf950adc8832d8c64b623d13726b8c590ac..f99eba3872e72197a612fb9a72203fc0e151e9ae 100644 --- a/common/static/coffee/src/discussion/discussion_module_view.coffee +++ b/common/static/coffee/src/discussion/discussion_module_view.coffee @@ -36,12 +36,15 @@ if Backbone? event.preventDefault() @newPostForm.slideUp(300) + hideDiscussion: -> + @$("section.discussion").slideUp() + @toggleDiscussionBtn.removeClass('shown') + @toggleDiscussionBtn.find('.button-text').html("Show Discussion") + @showed = false + toggleDiscussion: (event) -> if @showed - @$("section.discussion").slideUp() - @toggleDiscussionBtn.removeClass('shown') - @toggleDiscussionBtn.find('.button-text').html("Show Discussion") - @showed = false + @hideDiscussion() else @toggleDiscussionBtn.addClass('shown') @toggleDiscussionBtn.find('.button-text').html("Hide Discussion") @@ -51,9 +54,17 @@ if Backbone? @showed = true else $elem = @toggleDiscussionBtn - @loadPage $elem + @loadPage( + $elem, + => + @hideDiscussion() + DiscussionUtil.discussionAlert( + "Sorry", + "We had some trouble loading the discussion. Please try again." + ) + ) - loadPage: ($elem)=> + loadPage: ($elem, error) => discussionId = @$el.data("discussion-id") url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}" DiscussionUtil.safeAjax @@ -63,6 +74,7 @@ if Backbone? type: "GET" dataType: 'json' success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId) + error: error renderDiscussion: ($elem, response, textStatus, discussionId) => window.user = new DiscussionUser(response.user_info) @@ -131,5 +143,14 @@ if Backbone? navigateToPage: (event) => event.preventDefault() window.history.pushState({}, window.document.title, event.target.href) + currPage = @page @page = $(event.target).data('page-number') - @loadPage($(event.target)) + @loadPage( + $(event.target), + => + @page = currPage + DiscussionUtil.discussionAlert( + "Sorry", + "We had some trouble loading the threads you requested. Please try again." + ) + ) diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 73cfde8a0672f43f643f6ff0d219da1921db03f9..89014c5f57f1c9cd3a045d03b0c8f9755293ad34 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -87,6 +87,13 @@ class @DiscussionUtil "notifications_status" : "/notification_prefs/status" }[name] + @makeFocusTrap: (elem) -> + elem.keydown( + (event) -> + if event.which == 9 # Tab + event.preventDefault() + ) + @discussionAlert: (header, body) -> if $("#discussion-alert").length == 0 alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none") @@ -99,12 +106,7 @@ class @DiscussionUtil " <button class='dismiss'>OK</button>" + "</div>" ) - # Capture focus - alertDiv.find("button").keydown( - (event) -> - if event.which == 9 # Tab - event.preventDefault() - ) + @makeFocusTrap(alertDiv.find("button")) alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none") alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200}) $("body").append(alertDiv).append(alertTrigger) diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 5a1e3fa9ae124cb2083e1c7938dda7dfcb72fe99..57385c15bd7ecc53124486de647ed646ebd0e3f8 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -124,8 +124,11 @@ if Backbone? loadMorePages: (event) -> if event event.preventDefault() - @$(".more-pages").html('<div class="loading-animation"><span class="sr">Loading more threads</span></div>') + @$(".more-pages").html('<div class="loading-animation" tabindex=0><span class="sr" role="alert">Loading more threads</span></div>') @$(".more-pages").addClass("loading") + loadingDiv = @$(".more-pages .loading-animation") + DiscussionUtil.makeFocusTrap(loadingDiv) + loadingDiv.focus() options = {} switch @mode when 'search' @@ -156,7 +159,11 @@ if Backbone? $(".post-list a").first()?.focus() ) - @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}) + error = => + @renderThreads() + DiscussionUtil.discussionAlert("Sorry", "We had some trouble loading more threads. Please try again.") + + @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}, error) renderThread: (thread) => content = $(_.template($("#thread-list-item-template").html())(thread.toJSON())) diff --git a/common/static/js/capa/choicetextinput.js b/common/static/js/capa/choicetextinput.js index 4d7540f938078be00cfa06fdb1897767cbdb9f10..514e3f67f5e3769382a40a480c9a8733a6692846 100644 --- a/common/static/js/capa/choicetextinput.js +++ b/common/static/js/capa/choicetextinput.js @@ -1,13 +1,13 @@ (function () { var update = function () { // Whenever a value changes create a new serialized version of this - // problem's inputs and set the hidden input fields value to equal it. - var parent = $(this).closest('.problems-wrapper'); + // problem's inputs and set the hidden input field's value to equal it. + var parent = $(this).closest('section.choicetextinput'); // find the closest parent problems-wrapper and use that as the problem // grab the input id from the input // real_input is the hidden input field var real_input = $('input.choicetextvalue', parent); - var all_inputs = $('.choicetextinput .ctinput', parent); + var all_inputs = $('input.ctinput', parent); var user_inputs = {}; $(all_inputs).each(function (index, elt) { var node = $(elt); diff --git a/common/static/js/vendor/backbone-min.js b/common/static/js/vendor/backbone-min.js index fe80cd78df3fab1d614340dd2b8d7a54e0d2252c..bce4fbc1b16378743f9464ff837280ceccb97fda 100644 --- a/common/static/js/vendor/backbone-min.js +++ b/common/static/js/vendor/backbone-min.js @@ -1,4 +1 @@ (function(){var t=this;var e=t.Backbone;var i=[];var r=i.push;var s=i.slice;var n=i.splice;var a;if(typeof exports!=="undefined"){a=exports}else{a=t.Backbone={}}a.VERSION="1.0.0";var h=t._;if(!h&&typeof require!=="undefined")h=require("underscore");a.$=t.jQuery||t.Zepto||t.ender||t.$;a.noConflict=function(){t.Backbone=e;return this};a.emulateHTTP=false;a.emulateJSON=false;var o=a.Events={on:function(t,e,i){if(!l(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,i){if(!l(this,"once",t,[e,i])||!e)return this;var r=this;var s=h.once(function(){r.off(t,s);e.apply(this,arguments)});s._callback=e;return this.on(t,s,i)},off:function(t,e,i){var r,s,n,a,o,u,c,f;if(!this._events||!l(this,"off",t,[e,i]))return this;if(!t&&!e&&!i){this._events={};return this}a=t?[t]:h.keys(this._events);for(o=0,u=a.length;o<u;o++){t=a[o];if(n=this._events[t]){this._events[t]=r=[];if(e||i){for(c=0,f=n.length;c<f;c++){s=n[c];if(e&&e!==s.callback&&e!==s.callback._callback||i&&i!==s.context){r.push(s)}}}if(!r.length)delete this._events[t]}}return this},trigger:function(t){if(!this._events)return this;var e=s.call(arguments,1);if(!l(this,"trigger",t,e))return this;var i=this._events[t];var r=this._events.all;if(i)c(i,e);if(r)c(r,arguments);return this},stopListening:function(t,e,i){var r=this._listeners;if(!r)return this;var s=!e&&!i;if(typeof e==="object")i=this;if(t)(r={})[t._listenerId]=t;for(var n in r){r[n].off(e,i,this);if(s)delete this._listeners[n]}return this}};var u=/\s+/;var l=function(t,e,i,r){if(!i)return true;if(typeof i==="object"){for(var s in i){t[e].apply(t,[s,i[s]].concat(r))}return false}if(u.test(i)){var n=i.split(u);for(var a=0,h=n.length;a<h;a++){t[e].apply(t,[n[a]].concat(r))}return false}return true};var c=function(t,e){var i,r=-1,s=t.length,n=e[0],a=e[1],h=e[2];switch(e.length){case 0:while(++r<s)(i=t[r]).callback.call(i.ctx);return;case 1:while(++r<s)(i=t[r]).callback.call(i.ctx,n);return;case 2:while(++r<s)(i=t[r]).callback.call(i.ctx,n,a);return;case 3:while(++r<s)(i=t[r]).callback.call(i.ctx,n,a,h);return;default:while(++r<s)(i=t[r]).callback.apply(i.ctx,e)}};var f={listenTo:"on",listenToOnce:"once"};h.each(f,function(t,e){o[e]=function(e,i,r){var s=this._listeners||(this._listeners={});var n=e._listenerId||(e._listenerId=h.uniqueId("l"));s[n]=e;if(typeof i==="object")r=this;e[t](i,r,this);return this}});o.bind=o.on;o.unbind=o.off;h.extend(a,o);var d=a.Model=function(t,e){var i;var r=t||{};e||(e={});this.cid=h.uniqueId("c");this.attributes={};h.extend(this,h.pick(e,p));if(e.parse)r=this.parse(r,e)||{};if(i=h.result(this,"defaults")){r=h.defaults({},r,i)}this.set(r,e);this.changed={};this.initialize.apply(this,arguments)};var p=["url","urlRoot","collection"];h.extend(d.prototype,o,{changed:null,validationError:null,idAttribute:"id",initialize:function(){},toJSON:function(t){return h.clone(this.attributes)},sync:function(){return a.sync.apply(this,arguments)},get:function(t){return this.attributes[t]},escape:function(t){return h.escape(this.get(t))},has:function(t){return this.get(t)!=null},set:function(t,e,i){var r,s,n,a,o,u,l,c;if(t==null)return this;if(typeof t==="object"){s=t;i=e}else{(s={})[t]=e}i||(i={});if(!this._validate(s,i))return false;n=i.unset;o=i.silent;a=[];u=this._changing;this._changing=true;if(!u){this._previousAttributes=h.clone(this.attributes);this.changed={}}c=this.attributes,l=this._previousAttributes;if(this.idAttribute in s)this.id=s[this.idAttribute];for(r in s){e=s[r];if(!h.isEqual(c[r],e))a.push(r);if(!h.isEqual(l[r],e)){this.changed[r]=e}else{delete this.changed[r]}n?delete c[r]:c[r]=e}if(!o){if(a.length)this._pending=true;for(var f=0,d=a.length;f<d;f++){this.trigger("change:"+a[f],this,c[a[f]],i)}}if(u)return this;if(!o){while(this._pending){this._pending=false;this.trigger("change",this,i)}}this._pending=false;this._changing=false;return this},unset:function(t,e){return this.set(t,void 0,h.extend({},e,{unset:true}))},clear:function(t){var e={};for(var i in this.attributes)e[i]=void 0;return this.set(e,h.extend({},t,{unset:true}))},hasChanged:function(t){if(t==null)return!h.isEmpty(this.changed);return h.has(this.changed,t)},changedAttributes:function(t){if(!t)return this.hasChanged()?h.clone(this.changed):false;var e,i=false;var r=this._changing?this._previousAttributes:this.attributes;for(var s in t){if(h.isEqual(r[s],e=t[s]))continue;(i||(i={}))[s]=e}return i},previous:function(t){if(t==null||!this._previousAttributes)return null;return this._previousAttributes[t]},previousAttributes:function(){return h.clone(this._previousAttributes)},fetch:function(t){t=t?h.clone(t):{};if(t.parse===void 0)t.parse=true;var e=this;var i=t.success;t.success=function(r){if(!e.set(e.parse(r,t),t))return false;if(i)i(e,r,t);e.trigger("sync",e,r,t)};R(this,t);return this.sync("read",this,t)},save:function(t,e,i){var r,s,n,a=this.attributes;if(t==null||typeof t==="object"){r=t;i=e}else{(r={})[t]=e}if(r&&(!i||!i.wait)&&!this.set(r,i))return false;i=h.extend({validate:true},i);if(!this._validate(r,i))return false;if(r&&i.wait){this.attributes=h.extend({},a,r)}if(i.parse===void 0)i.parse=true;var o=this;var u=i.success;i.success=function(t){o.attributes=a;var e=o.parse(t,i);if(i.wait)e=h.extend(r||{},e);if(h.isObject(e)&&!o.set(e,i)){return false}if(u)u(o,t,i);o.trigger("sync",o,t,i)};R(this,i);s=this.isNew()?"create":i.patch?"patch":"update";if(s==="patch")i.attrs=r;n=this.sync(s,this,i);if(r&&i.wait)this.attributes=a;return n},destroy:function(t){t=t?h.clone(t):{};var e=this;var i=t.success;var r=function(){e.trigger("destroy",e,e.collection,t)};t.success=function(s){if(t.wait||e.isNew())r();if(i)i(e,s,t);if(!e.isNew())e.trigger("sync",e,s,t)};if(this.isNew()){t.success();return false}R(this,t);var s=this.sync("delete",this,t);if(!t.wait)r();return s},url:function(){var t=h.result(this,"urlRoot")||h.result(this.collection,"url")||U();if(this.isNew())return t;return t+(t.charAt(t.length-1)==="/"?"":"/")+encodeURIComponent(this.id)},parse:function(t,e){return t},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return this.id==null},isValid:function(t){return this._validate({},h.extend(t||{},{validate:true}))},_validate:function(t,e){if(!e.validate||!this.validate)return true;t=h.extend({},this.attributes,t);var i=this.validationError=this.validate(t,e)||null;if(!i)return true;this.trigger("invalid",this,i,h.extend(e||{},{validationError:i}));return false}});var v=["keys","values","pairs","invert","pick","omit"];h.each(v,function(t){d.prototype[t]=function(){var e=s.call(arguments);e.unshift(this.attributes);return h[t].apply(h,e)}});var g=a.Collection=function(t,e){e||(e={});if(e.url)this.url=e.url;if(e.model)this.model=e.model;if(e.comparator!==void 0)this.comparator=e.comparator;this._reset();this.initialize.apply(this,arguments);if(t)this.reset(t,h.extend({silent:true},e))};var m={add:true,remove:true,merge:true};var y={add:true,merge:false,remove:false};h.extend(g.prototype,o,{model:d,initialize:function(){},toJSON:function(t){return this.map(function(e){return e.toJSON(t)})},sync:function(){return a.sync.apply(this,arguments)},add:function(t,e){return this.set(t,h.defaults(e||{},y))},remove:function(t,e){t=h.isArray(t)?t.slice():[t];e||(e={});var i,r,s,n;for(i=0,r=t.length;i<r;i++){n=this.get(t[i]);if(!n)continue;delete this._byId[n.id];delete this._byId[n.cid];s=this.indexOf(n);this.models.splice(s,1);this.length--;if(!e.silent){e.index=s;n.trigger("remove",n,this,e)}this._removeReference(n)}return this},set:function(t,e){e=h.defaults(e||{},m);if(e.parse)t=this.parse(t,e);if(!h.isArray(t))t=t?[t]:[];var i,s,a,o,u,l;var c=e.at;var f=this.comparator&&c==null&&e.sort!==false;var d=h.isString(this.comparator)?this.comparator:null;var p=[],v=[],g={};for(i=0,s=t.length;i<s;i++){if(!(a=this._prepareModel(t[i],e)))continue;if(u=this.get(a)){if(e.remove)g[u.cid]=true;if(e.merge){u.set(a.attributes,e);if(f&&!l&&u.hasChanged(d))l=true}}else if(e.add){p.push(a);a.on("all",this._onModelEvent,this);this._byId[a.cid]=a;if(a.id!=null)this._byId[a.id]=a}}if(e.remove){for(i=0,s=this.length;i<s;++i){if(!g[(a=this.models[i]).cid])v.push(a)}if(v.length)this.remove(v,e)}if(p.length){if(f)l=true;this.length+=p.length;if(c!=null){n.apply(this.models,[c,0].concat(p))}else{r.apply(this.models,p)}}if(l)this.sort({silent:true});if(e.silent)return this;for(i=0,s=p.length;i<s;i++){(a=p[i]).trigger("add",a,this,e)}if(l)this.trigger("sort",this,e);return this},reset:function(t,e){e||(e={});for(var i=0,r=this.models.length;i<r;i++){this._removeReference(this.models[i])}e.previousModels=this.models;this._reset();this.add(t,h.extend({silent:true},e));if(!e.silent)this.trigger("reset",this,e);return this},push:function(t,e){t=this._prepareModel(t,e);this.add(t,h.extend({at:this.length},e));return t},pop:function(t){var e=this.at(this.length-1);this.remove(e,t);return e},unshift:function(t,e){t=this._prepareModel(t,e);this.add(t,h.extend({at:0},e));return t},shift:function(t){var e=this.at(0);this.remove(e,t);return e},slice:function(t,e){return this.models.slice(t,e)},get:function(t){if(t==null)return void 0;return this._byId[t.id!=null?t.id:t.cid||t]},at:function(t){return this.models[t]},where:function(t,e){if(h.isEmpty(t))return e?void 0:[];return this[e?"find":"filter"](function(e){for(var i in t){if(t[i]!==e.get(i))return false}return true})},findWhere:function(t){return this.where(t,true)},sort:function(t){if(!this.comparator)throw new Error("Cannot sort a set without a comparator");t||(t={});if(h.isString(this.comparator)||this.comparator.length===1){this.models=this.sortBy(this.comparator,this)}else{this.models.sort(h.bind(this.comparator,this))}if(!t.silent)this.trigger("sort",this,t);return this},sortedIndex:function(t,e,i){e||(e=this.comparator);var r=h.isFunction(e)?e:function(t){return t.get(e)};return h.sortedIndex(this.models,t,r,i)},pluck:function(t){return h.invoke(this.models,"get",t)},fetch:function(t){t=t?h.clone(t):{};if(t.parse===void 0)t.parse=true;var e=t.success;var i=this;t.success=function(r){var s=t.reset?"reset":"set";i[s](r,t);if(e)e(i,r,t);i.trigger("sync",i,r,t)};R(this,t);return this.sync("read",this,t)},create:function(t,e){e=e?h.clone(e):{};if(!(t=this._prepareModel(t,e)))return false;if(!e.wait)this.add(t,e);var i=this;var r=e.success;e.success=function(s){if(e.wait)i.add(t,e);if(r)r(t,s,e)};t.save(null,e);return t},parse:function(t,e){return t},clone:function(){return new this.constructor(this.models)},_reset:function(){this.length=0;this.models=[];this._byId={}},_prepareModel:function(t,e){if(t instanceof d){if(!t.collection)t.collection=this;return t}e||(e={});e.collection=this;var i=new this.model(t,e);if(!i._validate(t,e)){this.trigger("invalid",this,t,e);return false}return i},_removeReference:function(t){if(this===t.collection)delete t.collection;t.off("all",this._onModelEvent,this)},_onModelEvent:function(t,e,i,r){if((t==="add"||t==="remove")&&i!==this)return;if(t==="destroy")this.remove(e,r);if(e&&t==="change:"+e.idAttribute){delete this._byId[e.previous(e.idAttribute)];if(e.id!=null)this._byId[e.id]=e}this.trigger.apply(this,arguments)}});var _=["forEach","each","map","collect","reduce","foldl","inject","reduceRight","foldr","find","detect","filter","select","reject","every","all","some","any","include","contains","invoke","max","min","toArray","size","first","head","take","initial","rest","tail","drop","last","without","indexOf","shuffle","lastIndexOf","isEmpty","chain"];h.each(_,function(t){g.prototype[t]=function(){var e=s.call(arguments);e.unshift(this.models);return h[t].apply(h,e)}});var w=["groupBy","countBy","sortBy"];h.each(w,function(t){g.prototype[t]=function(e,i){var r=h.isFunction(e)?e:function(t){return t.get(e)};return h[t](this.models,r,i)}});var b=a.View=function(t){this.cid=h.uniqueId("view");this._configure(t||{});this._ensureElement();this.initialize.apply(this,arguments);this.delegateEvents()};var x=/^(\S+)\s*(.*)$/;var E=["model","collection","el","id","attributes","className","tagName","events"];h.extend(b.prototype,o,{tagName:"div",$:function(t){return this.$el.find(t)},initialize:function(){},render:function(){return this},remove:function(){this.$el.remove();this.stopListening();return this},setElement:function(t,e){if(this.$el)this.undelegateEvents();this.$el=t instanceof a.$?t:a.$(t);this.el=this.$el[0];if(e!==false)this.delegateEvents();return this},delegateEvents:function(t){if(!(t||(t=h.result(this,"events"))))return this;this.undelegateEvents();for(var e in t){var i=t[e];if(!h.isFunction(i))i=this[t[e]];if(!i)continue;var r=e.match(x);var s=r[1],n=r[2];i=h.bind(i,this);s+=".delegateEvents"+this.cid;if(n===""){this.$el.on(s,i)}else{this.$el.on(s,n,i)}}return this},undelegateEvents:function(){this.$el.off(".delegateEvents"+this.cid);return this},_configure:function(t){if(this.options)t=h.extend({},h.result(this,"options"),t);h.extend(this,h.pick(t,E));this.options=t},_ensureElement:function(){if(!this.el){var t=h.extend({},h.result(this,"attributes"));if(this.id)t.id=h.result(this,"id");if(this.className)t["class"]=h.result(this,"className");var e=a.$("<"+h.result(this,"tagName")+">").attr(t);this.setElement(e,false)}else{this.setElement(h.result(this,"el"),false)}}});a.sync=function(t,e,i){var r=k[t];h.defaults(i||(i={}),{emulateHTTP:a.emulateHTTP,emulateJSON:a.emulateJSON});var s={type:r,dataType:"json"};if(!i.url){s.url=h.result(e,"url")||U()}if(i.data==null&&e&&(t==="create"||t==="update"||t==="patch")){s.contentType="application/json";s.data=JSON.stringify(i.attrs||e.toJSON(i))}if(i.emulateJSON){s.contentType="application/x-www-form-urlencoded";s.data=s.data?{model:s.data}:{}}if(i.emulateHTTP&&(r==="PUT"||r==="DELETE"||r==="PATCH")){s.type="POST";if(i.emulateJSON)s.data._method=r;var n=i.beforeSend;i.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",r);if(n)return n.apply(this,arguments)}}if(s.type!=="GET"&&!i.emulateJSON){s.processData=false}if(s.type==="PATCH"&&window.ActiveXObject&&!(window.external&&window.external.msActiveXFilteringEnabled)){s.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var o=i.xhr=a.ajax(h.extend(s,i));e.trigger("request",e,o,i);return o};var k={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};a.ajax=function(){return a.$.ajax.apply(a.$,arguments)};var S=a.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var $=/\((.*?)\)/g;var T=/(\(\?)?:\w+/g;var H=/\*\w+/g;var A=/[\-{}\[\]+?.,\\\^$|#\s]/g;h.extend(S.prototype,o,{initialize:function(){},route:function(t,e,i){if(!h.isRegExp(t))t=this._routeToRegExp(t);if(h.isFunction(e)){i=e;e=""}if(!i)i=this[e];var r=this;a.history.route(t,function(s){var n=r._extractParameters(t,s);i&&i.apply(r,n);r.trigger.apply(r,["route:"+e].concat(n));r.trigger("route",e,n);a.history.trigger("route",r,e,n)});return this},navigate:function(t,e){a.history.navigate(t,e);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=h.result(this,"routes");var t,e=h.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(A,"\\$&").replace($,"(?:$1)?").replace(T,function(t,e){return e?t:"([^/]+)"}).replace(H,"(.*?)");return new RegExp("^"+t+"$")},_extractParameters:function(t,e){var i=t.exec(e).slice(1);return h.map(i,function(t){return t?decodeURIComponent(t):null})}});var I=a.History=function(){this.handlers=[];h.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var N=/^[#\/]|\s+$/g;var P=/^\/+|\/+$/g;var O=/msie [\w.]+/;var C=/\/$/;I.started=false;h.extend(I.prototype,o,{interval:50,getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=this.location.pathname;var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.substr(i.length)}else{t=this.getHash()}}return t.replace(N,"")},start:function(t){if(I.started)throw new Error("Backbone.history has already been started");I.started=true;this.options=h.extend({},{root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var e=this.getFragment();var i=document.documentMode;var r=O.exec(navigator.userAgent.toLowerCase())&&(!i||i<=7);this.root=("/"+this.root+"/").replace(P,"/");if(r&&this._wantsHashChange){this.iframe=a.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo("body")[0].contentWindow;this.navigate(e)}if(this._hasPushState){a.$(window).on("popstate",this.checkUrl)}else if(this._wantsHashChange&&"onhashchange"in window&&!r){a.$(window).on("hashchange",this.checkUrl)}else if(this._wantsHashChange){this._checkUrlInterval=setInterval(this.checkUrl,this.interval)}this.fragment=e;var s=this.location;var n=s.pathname.replace(/[^\/]$/,"$&/")===this.root;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!n){this.fragment=this.getFragment(null,true);this.location.replace(this.root+this.location.search+"#"+this.fragment);return true}else if(this._wantsPushState&&this._hasPushState&&n&&s.hash){this.fragment=this.getHash().replace(N,"");this.history.replaceState({},document.title,this.root+this.fragment+s.search)}if(!this.options.silent)return this.loadUrl()},stop:function(){a.$(window).off("popstate",this.checkUrl).off("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);I.started=false},route:function(t,e){this.handlers.unshift({route:t,callback:e})},checkUrl:function(t){var e=this.getFragment();if(e===this.fragment&&this.iframe){e=this.getFragment(this.getHash(this.iframe))}if(e===this.fragment)return false;if(this.iframe)this.navigate(e);this.loadUrl()||this.loadUrl(this.getHash())},loadUrl:function(t){var e=this.fragment=this.getFragment(t);var i=h.any(this.handlers,function(t){if(t.route.test(e)){t.callback(e);return true}});return i},navigate:function(t,e){if(!I.started)return false;if(!e||e===true)e={trigger:e};t=this.getFragment(t||"");if(this.fragment===t)return;this.fragment=t;var i=this.root+t;if(this._hasPushState){this.history[e.replace?"replaceState":"pushState"]({},document.title,i)}else if(this._wantsHashChange){this._updateHash(this.location,t,e.replace);if(this.iframe&&t!==this.getFragment(this.getHash(this.iframe))){if(!e.replace)this.iframe.document.open().close();this._updateHash(this.iframe.location,t,e.replace)}}else{return this.location.assign(i)}if(e.trigger)this.loadUrl(t)},_updateHash:function(t,e,i){if(i){var r=t.href.replace(/(javascript:|#).*$/,"");t.replace(r+"#"+e)}else{t.hash="#"+e}}});a.history=new I;var j=function(t,e){var i=this;var r;if(t&&h.has(t,"constructor")){r=t.constructor}else{r=function(){return i.apply(this,arguments)}}h.extend(r,i,e);var s=function(){this.constructor=r};s.prototype=i.prototype;r.prototype=new s;if(t)h.extend(r.prototype,t);r.__super__=i.prototype;return r};d.extend=g.extend=S.extend=b.extend=I.extend=j;var U=function(){throw new Error('A "url" property or function must be specified')};var R=function(t,e){var i=e.error;e.error=function(r){if(i)i(t,r,e);t.trigger("error",t,r,e)}}}).call(this); -/* -//@ sourceMappingURL=backbone-min.map -*/ diff --git a/common/test/data/test_exam_registration/policies/2012_Fall.json b/common/test/data/test_exam_registration/policies/2012_Fall.json index 49af7d15271d449734395ebcb618227fa554f2c2..c7e1e3fffef317744bd6a83bac6bb6f43958aaea 100644 --- a/common/test/data/test_exam_registration/policies/2012_Fall.json +++ b/common/test/data/test_exam_registration/policies/2012_Fall.json @@ -3,21 +3,6 @@ "graceperiod": "2 days 5 hours 59 minutes 59 seconds", "start": "2011-07-17T12:00", "display_name": "Toy Course", - "testcenter_info": { - "Midterm_Exam": { - "Exam_Series_Code": "Midterm_Exam", - "First_Eligible_Appointment_Date": "2012-11-09T00:00", - "Last_Eligible_Appointment_Date": "2012-11-09T23:59" - }, - "Final_Exam": { - "Exam_Series_Code": "mit6002xfall12a", - "Exam_Display_Name": "Final Exam", - "First_Eligible_Appointment_Date": "2013-01-25T00:00", - "Last_Eligible_Appointment_Date": "2013-01-25T23:59", - "Registration_Start_Date": "2013-01-01T00:00", - "Registration_End_Date": "2013-01-21T23:59" - } - } }, "chapter/Overview": { "display_name": "Overview" diff --git a/docs/data/source/internal_data_formats/sql_schema.rst b/docs/data/source/internal_data_formats/sql_schema.rst index 078928c21bea66ed1d383c8f9f2afaf20f55e578..6cfeec31791d6ab6875da14eb0755872906c5779 100644 --- a/docs/data/source/internal_data_formats/sql_schema.rst +++ b/docs/data/source/internal_data_formats/sql_schema.rst @@ -19,7 +19,7 @@ All of our tables will be described below, first in summary form with field type .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `int` @@ -36,13 +36,13 @@ All of our tables will be described below, first in summary form with field type - Date * - `datetime` - Datetime in UTC, precision in seconds. - + `Null` .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `YES` @@ -57,7 +57,7 @@ All of our tables will be described below, first in summary form with field type .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `PRI` @@ -252,19 +252,19 @@ There is an important split in demographic data gathered for the students who si `old_names` A list of the previous names this user had, and the timestamps at which they submitted a request to change those names. These name change request submissions used to require a staff member to approve it before the name change took effect. This is no longer the case, though we still record their previous names. - + Note that the value stored for each entry is the name they had, not the name they requested to get changed to. People often changed their names as the time for certificate generation approached, to replace nicknames with their actual names or correct spelling/punctuation errors. - + The timestamps are UTC, like all datetimes stored in our system. - + `old_emails` A list of previous emails this user had, with timestamps of when they changed them, in a format similar to `old_names`. There was never an approval process for this. - + The timestamps are UTC, like all datetimes stored in our system. - + `6002x_exit_response` Answers to a survey that was sent to students after the prototype 6.002x course in the Spring of 2012. The questions and number of questions were randomly selected to measure how much survey length affected response rate. Only students from this course have this field. - + `courseware` ------------ @@ -277,7 +277,7 @@ There is an important split in demographic data gathered for the students who si .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `NULL` @@ -306,10 +306,10 @@ There is an important split in demographic data gathered for the students who si .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning - * - `NULL` + * - `NULL` - This student signed up before this information was collected * - `''` (blank) - User did not specify level of education. @@ -335,7 +335,7 @@ There is an important split in demographic data gathered for the students who si - None * - `'other'` - Other - + `goals` ------- Text field collected during student signup in response to the prompt, "Goals in signing up for edX". We only started collecting this information after the transition from MITx to edX, so prototype course students will have `NULL` for this field. Students who elected not to enter anything will have a blank string. @@ -382,7 +382,7 @@ Any piece of content in the courseware can store state and score in the `coursew .. warning:: **Modules might not be what you expect!** - + It's important to understand what "modules" are in the context of our system, as the terminology can be confusing. For the conventions of this table and many parts of our code, a "module" is a content piece that appears in the courseware. This can be nearly anything that appears when users are in the courseware tab: a video, a piece of HTML, a problem, etc. Modules can also be collections of other modules, such as sequences, verticals (modules stacked together on the same page), weeks, chapters, etc. In fact, the course itself is a top level module that contains all the other contents of the course as children. You can imagine the entire course as a tree with modules at every node. Modules can store state, but whether and how they do so is up to the implemenation for that particular kind of module. When a user loads page, we look up all the modules they need to render in order to display it, and then we ask the database to look up state for those modules for that user. If there is corresponding entry for that user for a given module, we create a new row and set the state to an empty JSON dictionary. @@ -420,7 +420,7 @@ The `courseware_studentmodule` table holds all courseware state for a given user .. list-table:: :widths: 10 80 :header-rows: 0 - + * - `chapter` - The top level categories for a course. Each of these is usually labeled as a Week in the courseware, but this is just convention. * - `combinedopenended` @@ -437,8 +437,6 @@ The `courseware_studentmodule` table holds all courseware state for a given user - Self assessment problems. An early test of the open ended grading system that is not in widespread use yet. Recently deprecated in favor of `combinedopenended`. * - `sequential` - A collection of videos, problems, and other materials, rendered as a horizontal icon bar in the courseware. - * - `timelimit` - - A special module that records the time you start working on a piece of courseware and enforces time limits, used for Pearson exams. This hasn't been completely generalized yet, so is not available for widespread use. * - `videosequence` - A collection of videos, exercise problems, and other materials, rendered as a horizontal icon bar in the courseware. Use is inconsistent, and some courses use a `sequential` instead. @@ -451,20 +449,20 @@ The `courseware_studentmodule` table holds all courseware state for a given user .. list-table:: Breakdown of example `module_id`: `i4x://MITx/3.091x/problemset/Sample_Problems` :widths: 10 20 70 :header-rows: 1 - + * - Part - Example - Definition * - `i4x://` - - + - - Just a convention we ran with. We had plans for the domain `i4x.org` at one point. * - `org` - `MITx` - The organization part of the ID, indicating what organization created this piece of content. - * - `course_num` + * - `course_num` - `3.091x` - The course number this content was created for. Note that there is no run information here, so you can't know what runs of the course this content is being used for from the `module_id` alone; you have to look at the `courseware_studentmodule.course_id` field. - * - `module_type` + * - `module_type` - `problemset` - The module type, same value as what's in the `courseware_studentmodule.module_type` field. * - `module_name` @@ -501,33 +499,6 @@ The `courseware_studentmodule` table holds all courseware state for a given user `selfassessment` TODO: More details to come. - `timelimit` - This very uncommon type was only used in one Pearson exam for one course, and the format may change significantly in the future. It is currently a JSON dictionary with fields: - - .. list-table:: - :widths: 10 20 70 - :header-rows: 1 - - * - JSON field - - Example - - Definition - * - `beginning_at` - - `1360590255.488154` - - UTC time as measured in seconds since UNIX epoch representing when the exam was started. - * - `ending_at` - - `1360596632.559758` - - UTC time as measured in seconds since UNIX epoch representing the time the exam will close. - * - `accomodation_codes` - - `DOUBLE` - - (optional) Sometimes students are given more time for accessibility reasons. Possible values are: - - * `NONE`: no time accommodation - * `ADDHALFTIME`: 1.5X normal time allowance - * `ADD30MIN`: normal time allowance + 30 minutes - * `DOUBLE`: 2X normal time allowance - * `TESTING`: extra long period (for testing/debugging) - - `grade` ------- Floating point value indicating the total unweighted grade for this problem that the student has scored. Basically how many responses they got right within the problem. @@ -608,13 +579,13 @@ The generatedcertificate table tracks certificate state for students who have be * `notpassing` * `restricted` * `error` - + After a course has been graded and certificates have been issued status will be one of: - + * `downloadable` * `notpassing` * `restricted` - + If the status is `downloadable` then the student passed the course and there will be a certificate available for download. `download_url` diff --git a/lms/djangoapps/branding/tests.py b/lms/djangoapps/branding/tests.py index 26ca4e7014af2c6e2024643ada79e00c4be86378..3d900e3ec31f99ed094242b207b60a8724335b63 100644 --- a/lms/djangoapps/branding/tests.py +++ b/lms/djangoapps/branding/tests.py @@ -5,7 +5,7 @@ import datetime from pytz import UTC from django.conf import settings from django.test.utils import override_settings - +from django.test.client import RequestFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.django import editable_modulestore from xmodule.modulestore.tests.factories import CourseFactory @@ -25,6 +25,7 @@ class AnonymousIndexPageTest(ModuleStoreTestCase): """ def setUp(self): self.store = editable_modulestore() + self.factory = RequestFactory() self.course = CourseFactory.create() self.course.days_early_for_beta = 5 self.course.enrollment_start = datetime.datetime.now(UTC) + datetime.timedelta(days=3) @@ -32,7 +33,11 @@ class AnonymousIndexPageTest(ModuleStoreTestCase): @override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE) def test_none_user_index_access_with_startdate_fails(self): - with self.assertRaises(Exception): + """ + This was a "before" test for a bugfix. If someone fixes the bug another way in the future + and this test begins failing (but the other two pass), then feel free to delete this test. + """ + with self.assertRaisesRegexp(AttributeError, "'NoneType' object has no attribute 'is_authenticated'"): student.views.index(self.factory.get('/'), user=None) # pylint: disable=E1101 @override_settings(MITX_FEATURES=MITX_FEATURES_WITH_STARTDATE) diff --git a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py index 5fb9c53718c3580b370954013a1ce8544a4042e3..5aa223acabb581ab33c5c35d0fbb91f120c21370 100644 --- a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py +++ b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py @@ -93,6 +93,7 @@ class Command(BaseCommand): total = enrolled_students.count() count = 0 start = datetime.datetime.now(UTC) + for student in enrolled_students: count += 1 if count % STATUS_INTERVAL == 0: diff --git a/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py b/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py new file mode 100644 index 0000000000000000000000000000000000000000..c16d51b8ee4e73d036a5a550ba23bc0e1584f381 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py @@ -0,0 +1,86 @@ +# -*- 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 'GeneratedCertificate.mode' + db.add_column('certificates_generatedcertificate', 'mode', + self.gf('django.db.models.fields.CharField')(default='honor', max_length=32), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'GeneratedCertificate.mode' + db.delete_column('certificates_generatedcertificate', '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'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['certificates'] \ No newline at end of file diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 8cd1a292c4b87e0e85a921a81056f034d700cb70..eb6ca407a0a93c992aca200d71202d6fad7e84a8 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import User from django.db import models from datetime import datetime +from model_utils import Choices """ Certificates are created for a student and an offering of a course. @@ -62,7 +63,6 @@ class CertificateStatuses(object): restricted = 'restricted' unavailable = 'unavailable' - class CertificateWhitelist(models.Model): """ Tracks students who are whitelisted, all users @@ -86,11 +86,13 @@ class GeneratedCertificate(models.Model): key = models.CharField(max_length=32, blank=True, default='') distinction = models.BooleanField(default=False) status = models.CharField(max_length=32, default='unavailable') + MODES = Choices('verified', 'honor', 'audit') + mode = models.CharField(max_length=32, choices=MODES, default=MODES.honor) name = models.CharField(blank=True, max_length=255) created_date = models.DateTimeField( - auto_now_add=True, default=datetime.now) + auto_now_add=True, default=datetime.now) modified_date = models.DateTimeField( - auto_now=True, default=datetime.now) + auto_now=True, default=datetime.now) error_reason = models.CharField(max_length=512, blank=True, default='') class Meta: @@ -128,8 +130,9 @@ def certificate_status_for_student(student, course_id): try: generated_certificate = GeneratedCertificate.objects.get( - user=student, course_id=course_id) - d = {'status': generated_certificate.status} + user=student, course_id=course_id) + d = {'status': generated_certificate.status, + 'mode': generated_certificate.mode} if generated_certificate.grade: d['grade'] = generated_certificate.grade if generated_certificate.status == CertificateStatuses.downloadable: @@ -138,4 +141,4 @@ def certificate_status_for_student(student, course_id): return d except GeneratedCertificate.DoesNotExist: pass - return {'status': CertificateStatuses.unavailable} + return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor} diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 5f63bbf1e2fcf7119fe498d27cf8ff0159fd592d..2f9e70517a167a78630f1273191ac6aa0df578c3 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -9,7 +9,8 @@ from capa.xqueue_interface import XQueueInterface from capa.xqueue_interface import make_xheader, make_hashkey from django.conf import settings from requests.auth import HTTPBasicAuth -from student.models import UserProfile +from student.models import UserProfile, CourseEnrollment +from verify_student.models import SoftwareSecurePhotoVerification import json import random @@ -57,7 +58,7 @@ class XQueueCertInterface(object): if settings.XQUEUE_INTERFACE.get('basic_auth') is not None: requests_auth = HTTPBasicAuth( - *settings.XQUEUE_INTERFACE['basic_auth']) + *settings.XQUEUE_INTERFACE['basic_auth']) else: requests_auth = None @@ -68,10 +69,10 @@ class XQueueCertInterface(object): self.request = request self.xqueue_interface = XQueueInterface( - settings.XQUEUE_INTERFACE['url'], - settings.XQUEUE_INTERFACE['django_auth'], - requests_auth, - ) + settings.XQUEUE_INTERFACE['url'], + settings.XQUEUE_INTERFACE['django_auth'], + requests_auth, + ) self.whitelist = CertificateWhitelist.objects.all() self.restricted = UserProfile.objects.filter(allow_certificate=False) self.use_https = True @@ -84,7 +85,7 @@ class XQueueCertInterface(object): course_id - courseenrollment.course_id (string) WARNING: this command will leave the old certificate, if one exists, - laying around in AWS taking up space. If this is a problem, + laying around in AWS taking up space. If this is a problem, take pains to clean up storage before running this command. Change the certificate status to unavailable (if it exists) and request @@ -92,7 +93,7 @@ class XQueueCertInterface(object): Return the status object. """ - # TODO: when del_cert is implemented and plumbed through certificates + # TODO: when del_cert is implemented and plumbed through certificates # repo also, do a deletion followed by a creation r/t a simple # recreation. XXX: this leaves orphan cert files laying around in # AWS. See note in the docstring too. @@ -149,13 +150,15 @@ class XQueueCertInterface(object): """ VALID_STATUSES = [status.generating, - status.unavailable, - status.deleted, + status.unavailable, + status.deleted, status.error, status.notpassing] cert_status = certificate_status_for_student(student, course_id)['status'] + new_status = cert_status + if cert_status in VALID_STATUSES: # grade the student @@ -165,9 +168,6 @@ class XQueueCertInterface(object): course = courses.get_course_by_id(course_id) profile = UserProfile.objects.get(user=student) - cert, created = GeneratedCertificate.objects.get_or_create( - user=student, course_id=course_id) - # Needed self.request.user = student self.request.session = {} @@ -175,45 +175,64 @@ class XQueueCertInterface(object): grade = grades.grade(student, self.request, course) is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() + enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_id) + org = course_id.split('/')[0] + course_num = course_id.split('/')[1] + cert_mode = enrollment_mode + if enrollment_mode == GeneratedCertificate.MODES.verified and SoftwareSecurePhotoVerification.user_is_verified(student): + template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( + org, course_num) + elif (enrollment_mode == GeneratedCertificate.MODES.verified and not + SoftwareSecurePhotoVerification.user_is_verified(student)): + template_pdf = "certificate-template-{0}-{1}.pdf".format( + org, course_num) + cert_mode = GeneratedCertificate.MODES.honor + else: + # honor code and audit students + template_pdf = "certificate-template-{0}-{1}.pdf".format( + org, course_num) - if is_whitelisted or grade['grade'] is not None: + cert, created = GeneratedCertificate.objects.get_or_create( + user=student, course_id=course_id) - key = make_hashkey(random.random()) + cert.mode = cert_mode + cert.user = student + cert.grade = grade['percent'] + cert.course_id = course_id + cert.name = profile.name - cert.grade = grade['percent'] - cert.user = student - cert.course_id = course_id - cert.key = key - cert.name = profile.name + if is_whitelisted or grade['grade'] is not None: # check to see whether the student is on the # the embargoed country restricted list # otherwise, put a new certificate request # on the queue + if self.restricted.filter(user=student).exists(): - cert.status = status.restricted + new_status = status.restricted + cert.status = new_status cert.save() else: + key = make_hashkey(random.random()) + cert.key = key contents = { 'action': 'create', 'username': student.username, 'course_id': course_id, 'name': profile.name, 'grade': grade['grade'], + 'template_pdf': template_pdf, } - cert.status = status.generating + new_status = status.generating + cert.status = new_status cert.save() self._send_to_xqueue(contents, key) else: - cert_status = status.notpassing - cert.grade = grade['percent'] - cert.user = student - cert.course_id = course_id - cert.name = profile.name - cert.status = cert_status + new_status = status.notpassing + cert.status = new_status cert.save() - return cert_status + return new_status def _send_to_xqueue(self, contents, key): @@ -227,7 +246,7 @@ class XQueueCertInterface(object): proto, settings.SITE_NAME, key), key, settings.CERT_QUEUE) (error, msg) = self.xqueue_interface.send_to_queue( - header=xheader, body=json.dumps(contents)) + header=xheader, body=json.dumps(contents)) if error: logger.critical('Unable to add a request to the queue: {} {}'.format(error, msg)) raise Exception('Unable to send queue message') diff --git a/lms/djangoapps/course_wiki/editors.py b/lms/djangoapps/course_wiki/editors.py index e823bfdafa6c86d4c104de98584332c377499951..ab68c0572cacf49804ff11de8cc9d6b632fb2406 100644 --- a/lms/djangoapps/course_wiki/editors.py +++ b/lms/djangoapps/course_wiki/editors.py @@ -60,5 +60,4 @@ class CodeMirror(BaseEditor): "js/vendor/CodeMirror/mitx_markdown.js", "js/wiki/accessible.js", "js/wiki/CodeMirror.init.js", - "js/wiki/cheatsheet.js", ) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 4cc0f90943174eff5f5f9bd6330250750d493b32..f17a3486d537564caef19fe24c0c83af3cfce9ad 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -335,7 +335,6 @@ def _progress_summary(student, request, course): module_creator = section_module.xmodule_runtime.get_module for module_descriptor in yield_dynamic_descriptor_descendents(section_module, module_creator): - course_id = course.id (correct, total) = get_score(course_id, student, module_descriptor, module_creator) if correct is None and total is None: diff --git a/lms/djangoapps/courseware/migrations/0010_rename_xblock_field_content_to_user_state_summary.py b/lms/djangoapps/courseware/migrations/0010_rename_xblock_field_content_to_user_state_summary.py index 51b2e9d0fd4c30602e084178d8c7c9db85b49973..8975cb10721d39a83d51acc3e48db84336fa2ee0 100644 --- a/lms/djangoapps/courseware/migrations/0010_rename_xblock_field_content_to_user_state_summary.py +++ b/lms/djangoapps/courseware/migrations/0010_rename_xblock_field_content_to_user_state_summary.py @@ -134,8 +134,8 @@ class Migration(SchemaMigration): 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) }, - 'courseware.xmoduleuserstatesummary': { - 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummary'}, + 'courseware.xmoduleuserstatesummaryfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'}, 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), 'usage_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index a164543930247bc78634b4e7e1526e6fb36f455a..068c2e95cda9162d2eb64a05b463f192129e1153 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -27,7 +27,6 @@ class StudentModule(models.Model): MODULE_TYPES = (('problem', 'problem'), ('video', 'video'), ('html', 'html'), - ('timelimit', 'timelimit'), ) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) diff --git a/lms/djangoapps/courseware/tests/test_timelimit_module.py b/lms/djangoapps/courseware/tests/test_timelimit_module.py deleted file mode 100644 index 3e3e0943f3b1fe2104bc4ca93d80d7363e03808b..0000000000000000000000000000000000000000 --- a/lms/djangoapps/courseware/tests/test_timelimit_module.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Tests of the TimeLimitModule - -TODO: This should be a test in common/lib/xmodule. However, -actually rendering HTML templates for XModules at this point requires -Django (which is storing the templates), so the test can't run in isolation -""" -from xmodule.modulestore.tests.factories import ItemFactory -from xmodule.tests.rendering.core import assert_student_view - -from . import XModuleRenderingTestBase - - -class TestTimeLimitModuleRendering(XModuleRenderingTestBase): - """ - Tests of TimeLimitModule html rendering - """ - def test_with_children(self): - block = ItemFactory.create(category='timelimit') - block.xmodule_runtime = self.new_module_runtime() - ItemFactory.create(category='html', data='<html>This is just text</html>', parent=block) - - assert_student_view(block, block.render('student_view')) - - def test_without_children(self): - block = ItemFactory.create(category='timelimit') - block.xmodule_runtime = self.new_module_runtime() - - assert_student_view(block, block.render('student_view')) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 6646ea1e637f6b2ab017bd15798a451efb8312c2..69fd33f417678423a92856479e046c9ba03555fd 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -172,71 +172,6 @@ def save_child_position(seq_module, child_name): seq_module.save() -def check_for_active_timelimit_module(request, course_id, course): - """ - Looks for a timing module for the given user and course that is currently active. - If found, returns a context dict with timer-related values to enable display of time remaining. - """ - context = {} - - # TODO (cpennington): Once we can query the course structure, replace this with such a query - timelimit_student_modules = StudentModule.objects.filter(student=request.user, course_id=course_id, module_type='timelimit') - if timelimit_student_modules: - for timelimit_student_module in timelimit_student_modules: - # get the corresponding section_descriptor for the given StudentModel entry: - module_state_key = timelimit_student_module.module_state_key - timelimit_descriptor = modulestore().get_instance(course_id, Location(module_state_key)) - timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course.id, request.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, - timelimit_module_cache, course.id, position=None) - if timelimit_module is not None and timelimit_module.category == 'timelimit' and \ - timelimit_module.has_begun and not timelimit_module.has_ended: - location = timelimit_module.location - # determine where to go when the timer expires: - if timelimit_descriptor.time_expired_redirect_url is None: - raise Http404("no time_expired_redirect_url specified at this location: {} ".format(timelimit_module.location)) - context['time_expired_redirect_url'] = timelimit_descriptor.time_expired_redirect_url - # Fetch the remaining time relative to the end time as stored in the module when it was started. - # This value should be in milliseconds. - remaining_time = timelimit_module.get_remaining_time_in_ms() - context['timer_expiration_duration'] = remaining_time - context['suppress_toplevel_navigation'] = timelimit_descriptor.suppress_toplevel_navigation - return_url = reverse('jump_to', kwargs={'course_id': course_id, 'location': location}) - context['timer_navigation_return_url'] = return_url - return context - - -def update_timelimit_module(user, course_id, field_data_cache, timelimit_descriptor, timelimit_module): - """ - Updates the state of the provided timing module, starting it if it hasn't begun. - Returns dict with timer-related values to enable display of time remaining. - Returns 'timer_expiration_duration' in dict if timer is still active, and not if timer has expired. - """ - context = {} - # determine where to go when the exam ends: - if timelimit_descriptor.time_expired_redirect_url is None: - raise Http404("No time_expired_redirect_url specified at this location: {} ".format(timelimit_module.location)) - context['time_expired_redirect_url'] = timelimit_descriptor.time_expired_redirect_url - - if not timelimit_module.has_ended: - if not timelimit_module.has_begun: - # user has not started the exam, so start it now. - if timelimit_descriptor.duration is None: - raise Http404("No duration specified at this location: {} ".format(timelimit_module.location)) - # The user may have an accommodation that has been granted to them. - # This accommodation information should already be stored in the module's state. - timelimit_module.begin(timelimit_descriptor.duration) - - # the exam has been started, either because the student is returning to the - # exam page, or because they have just visited it. Fetch the remaining time relative to the - # end time as stored in the module when it was started. - context['timer_expiration_duration'] = timelimit_module.get_remaining_time_in_ms() - # also use the timed module to determine whether top-level navigation is visible: - context['suppress_toplevel_navigation'] = timelimit_descriptor.suppress_toplevel_navigation - return context - - def chat_settings(course, user): """ Returns a dict containing the settings required to connect to a @@ -390,22 +325,8 @@ def index(request, course_id, chapter=None, section=None, # Save where we are in the chapter save_child_position(chapter_module, section) - - # check here if this section *is* a timed module. - if section_module.category == 'timelimit': - timer_context = update_timelimit_module(user, course_id, section_field_data_cache, - section_descriptor, section_module) - if 'timer_expiration_duration' in timer_context: - context.update(timer_context) - else: - # if there is no expiration defined, then we know the timer has expired: - return HttpResponseRedirect(timer_context['time_expired_redirect_url']) - else: - # check here if this page is within a course that has an active timed module running. If so, then - # add in the appropriate timer information to the rendering context: - context.update(check_for_active_timelimit_module(request, course_id, course)) - context['fragment'] = section_module.render('student_view') + else: # section is none, so display a message prev_section = get_current_child(chapter_module) diff --git a/lms/djangoapps/shoppingcart/admin.py b/lms/djangoapps/shoppingcart/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..199a39d7c03dc3f175431d60d3654dee3c9a5889 --- /dev/null +++ b/lms/djangoapps/shoppingcart/admin.py @@ -0,0 +1,7 @@ +""" +Allows django admin site to add PaidCourseRegistrationAnnotations +""" +from ratelimitbackend import admin +from shoppingcart.models import PaidCourseRegistrationAnnotation + +admin.site.register(PaidCourseRegistrationAnnotation) diff --git a/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py new file mode 100644 index 0000000000000000000000000000000000000000..04d37c730a301dc28f16e01f76576bd5be451002 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'PaidCourseRegistrationAnnotation' + db.create_table('shoppingcart_paidcourseregistrationannotation', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=128, db_index=True)), + ('annotation', self.gf('django.db.models.fields.TextField')(null=True)), + )) + db.send_create_signal('shoppingcart', ['PaidCourseRegistrationAnnotation']) + + # Adding field 'OrderItem.report_comments' + db.add_column('shoppingcart_orderitem', 'report_comments', + self.gf('django.db.models.fields.TextField')(default=''), + keep_default=False) + + + def backwards(self, orm): + # Deleting model 'PaidCourseRegistrationAnnotation' + db.delete_table('shoppingcart_paidcourseregistrationannotation') + + # Deleting field 'OrderItem.report_comments' + db.delete_column('shoppingcart_orderitem', 'report_comments') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + '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']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py b/lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py new file mode 100644 index 0000000000000000000000000000000000000000..13e001cfd0cee1ebb58b318ba1197b5c86d53907 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py @@ -0,0 +1,131 @@ +# -*- 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 'Order.refunded_time' + db.add_column('shoppingcart_order', 'refunded_time', + self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), + keep_default=False) + + # Adding field 'OrderItem.refund_requested_time' + db.add_column('shoppingcart_orderitem', 'refund_requested_time', + self.gf('django.db.models.fields.DateTimeField')(null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Order.refunded_time' + db.delete_column('shoppingcart_order', 'refunded_time') + + # Deleting field 'OrderItem.refund_requested_time' + db.delete_column('shoppingcart_orderitem', 'refund_requested_time') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + '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']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 8d3ecfa32f6a3368f4255f5f445286cea90b3c9a..fa77efb50f93c587c741fb179767f6a97d8b3958 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -2,6 +2,7 @@ from datetime import datetime import pytz import logging import smtplib +import unicodecsv from model_utils.managers import InheritanceManager from collections import namedtuple @@ -53,6 +54,7 @@ class Order(models.Model): currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) purchase_time = models.DateTimeField(null=True, blank=True) + refunded_time = models.DateTimeField(null=True, blank=True) # Now we store data needed to generate a reasonable receipt # These fields only make sense after the purchase bill_to_first = models.CharField(max_length=64, blank=True) @@ -207,6 +209,9 @@ class OrderItem(models.Model): line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes fulfilled_time = models.DateTimeField(null=True) + refund_requested_time = models.DateTimeField(null=True) + # general purpose field, not user-visible. Used for reporting + report_comments = models.TextField(default="") @property def line_cost(self): @@ -254,6 +259,66 @@ class OrderItem(models.Model): """ return self.pk_with_subclass, set([]) + @classmethod + def purchased_items_btw_dates(cls, start_date, end_date): + """ + Returns a QuerySet of the purchased items between start_date and end_date inclusive. + """ + return cls.objects.filter( + status="purchased", + fulfilled_time__gte=start_date, + fulfilled_time__lt=end_date, + ) + + @classmethod + def csv_purchase_report_btw_dates(cls, filelike, start_date, end_date): + """ + Outputs a CSV report into "filelike" (a file-like python object, such as an actual file, an HttpRequest, + or sys.stdout) of purchased items between start_date and end_date inclusive. + Opening and closing filelike (if applicable) should be taken care of by the caller + """ + items = cls.purchased_items_btw_dates(start_date, end_date).order_by("fulfilled_time") + + writer = unicodecsv.writer(filelike, encoding="utf-8") + writer.writerow(OrderItem.csv_report_header_row()) + + for item in items: + writer.writerow(item.csv_report_row) + + @classmethod + def csv_report_header_row(cls): + """ + Returns the "header" row for a csv report of purchases + """ + return [ + "Purchase Time", + "Order ID", + "Status", + "Quantity", + "Unit Cost", + "Total Cost", + "Currency", + "Description", + "Comments" + ] + + @property + def csv_report_row(self): + """ + Returns an array which can be fed into csv.writer to write out one csv row + """ + return [ + self.fulfilled_time, + self.order_id, # pylint: disable=no-member + self.status, + self.qty, + self.unit_cost, + self.line_cost, + self.currency, + self.line_desc, + self.report_comments, + ] + @property def pk_with_subclass(self): """ @@ -345,13 +410,13 @@ class PaidCourseRegistration(OrderItem): item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status - item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost item.line_desc = 'Registration for Course: {0}'.format(course.display_name_with_default) item.currency = currency order.currency = currency + item.report_comments = item.csv_report_comments order.save() item.save() log.info("User {} added course registration {} to cart: order {}" @@ -391,6 +456,31 @@ class PaidCourseRegistration(OrderItem): return self.pk_with_subclass, set([notification]) + @property + def csv_report_comments(self): + """ + Tries to fetch an annotation associated with the course_id from the database. If not found, returns u"". + Otherwise returns the annotation + """ + try: + return PaidCourseRegistrationAnnotation.objects.get(course_id=self.course_id).annotation + except PaidCourseRegistrationAnnotation.DoesNotExist: + return u"" + + +class PaidCourseRegistrationAnnotation(models.Model): + """ + A model that maps course_id to an additional annotation. This is specifically needed because when Stanford + generates report for the paid courses, each report item must contain the payment account associated with a course. + And unfortunately we didn't have the concept of a "SKU" or stock item where we could keep this association, + so this is to retrofit it. + """ + course_id = models.CharField(unique=True, max_length=128, db_index=True) + annotation = models.TextField(null=True) + + def __unicode__(self): + return u"{} : {}".format(self.course_id, self.annotation) + class CertificateItem(OrderItem): """ @@ -421,7 +511,10 @@ class CertificateItem(OrderItem): log.error("Matching CertificateItem not found while trying to refund. User %s, Course %s", course_enrollment.user, course_enrollment.course_id) return target_cert.status = 'refunded' + target_cert.refund_requested_time = datetime.now(pytz.utc) target_cert.save() + target_cert.order.status = 'refunded' + target_cert.order.save() order_number = target_cert.order_id # send billing an email so they can handle refunding diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index ecb76ac941609e8e9c90ee90eee814e07ff14765..a0161cbe0f41258de766f4c7c1c7075d4e5a6ec5 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -2,6 +2,8 @@ Tests for the Shopping Cart Models """ import smtplib +import StringIO +from textwrap import dedent from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors from mock import patch, MagicMock @@ -15,7 +17,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration, - OrderItemSubclassPK) + OrderItemSubclassPK, PaidCourseRegistrationAnnotation) from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode @@ -321,6 +323,87 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class PurchaseReportTest(ModuleStoreTestCase): + + FIVE_MINS = datetime.timedelta(minutes=5) + TEST_ANNOTATION = u'Ba\xfc\u5305' + + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name=u'Robot Super Course') + course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + course_mode.save() + course_mode2 = CourseMode(course_id=self.course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost) + course_mode2.save() + self.annotation = PaidCourseRegistrationAnnotation(course_id=self.course_id, annotation=self.TEST_ANNOTATION) + self.annotation.save() + self.cart = Order.get_cart_for_user(self.user) + self.reg = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cert_item = CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified') + self.cart.purchase() + self.now = datetime.datetime.now(pytz.UTC) + + def test_purchased_items_btw_dates(self): + purchases = OrderItem.purchased_items_btw_dates(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) + self.assertEqual(len(purchases), 2) + self.assertIn(self.reg.orderitem_ptr, purchases) + self.assertIn(self.cert_item.orderitem_ptr, purchases) + no_purchases = OrderItem.purchased_items_btw_dates(self.now + self.FIVE_MINS, + self.now + self.FIVE_MINS + self.FIVE_MINS) + self.assertFalse(no_purchases) + + test_time = datetime.datetime.now(pytz.UTC) + + CORRECT_CSV = dedent(""" + Purchase Time,Order ID,Status,Quantity,Unit Cost,Total Cost,Currency,Description,Comments + {time_str},1,purchased,1,40,40,usd,Registration for Course: Robot Super Course,Ba\xc3\xbc\xe5\x8c\x85 + {time_str},1,purchased,1,40,40,usd,"Certificate of Achievement, verified cert for course Robot Super Course", + """.format(time_str=str(test_time))) + + def test_purchased_csv(self): + """ + Tests that a generated purchase report CSV is as we expect + """ + # coerce the purchase times to self.test_time so that the test can match. + # It's pretty hard to patch datetime.datetime b/c it's a python built-in, which is immutable, so we + # make the times match this way + for item in OrderItem.purchased_items_btw_dates(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS): + item.fulfilled_time = self.test_time + item.save() + + # add annotation to the + csv_file = StringIO.StringIO() + OrderItem.csv_purchase_report_btw_dates(csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) + csv = csv_file.getvalue() + csv_file.close() + # Using excel mode csv, which automatically ends lines with \r\n, so need to convert to \n + self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_CSV.strip()) + + def test_csv_report_no_annotation(self): + """ + Fill in gap in test coverage. csv_report_comments for PaidCourseRegistration instance with no + matching annotation + """ + # delete the matching annotation + self.annotation.delete() + self.assertEqual(u"", self.reg.csv_report_comments) + + def test_paidcourseregistrationannotation_unicode(self): + """ + Fill in gap in test coverage. __unicode__ method of PaidCourseRegistrationAnnotation + """ + self.assertEqual(unicode(self.annotation), u'{} : {}'.format(self.course_id, self.TEST_ANNOTATION)) + + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class CertificateItemTest(ModuleStoreTestCase): """ @@ -373,6 +456,8 @@ class CertificateItemTest(ModuleStoreTestCase): CourseEnrollment.unenroll(self.user, self.course_id) target_certs = CertificateItem.objects.filter(course_id=self.course_id, user_id=self.user, status='refunded', mode='verified') self.assertTrue(target_certs[0]) + self.assertTrue(target_certs[0].refund_requested_time) + self.assertEquals(target_certs[0].order.status, 'refunded') def test_refund_cert_callback_before_expiration(self): # If the expiration date has not yet passed on a verified mode, the user can be refunded @@ -395,6 +480,8 @@ class CertificateItemTest(ModuleStoreTestCase): CourseEnrollment.unenroll(self.user, course_id) target_certs = CertificateItem.objects.filter(course_id=course_id, user_id=self.user, status='refunded', mode='verified') self.assertTrue(target_certs[0]) + self.assertTrue(target_certs[0].refund_requested_time) + self.assertEquals(target_certs[0].order.status, 'refunded') @patch('shoppingcart.models.log.error') def test_refund_cert_callback_before_expiration_email_error(self, error_logger): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index d60cab78d90e3641ac3d1fe26d58b5a36d5dc0f1..0451277ce235bf597881c6d6284011b51aa913d2 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -3,23 +3,23 @@ Tests for Shopping Cart views """ from urlparse import urlparse +from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ +from django.contrib.auth.models import Group from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.exceptions import ItemNotFoundError from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE -from shoppingcart.views import add_course_to_cart -from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration +from shoppingcart.views import _can_download_report, _get_date_from_str +from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, OrderItem from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from ..exceptions import PurchasedCallbackException from mitxmako.shortcuts import render_to_response -from shoppingcart.processors import render_purchase_form_html, process_postpay_callback +from shoppingcart.processors import render_purchase_form_html from mock import patch, Mock @@ -232,3 +232,143 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) ((template, _context), _tmp) = render_mock.call_args self.assertEqual(template, cert_item.single_item_receipt_template) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class CSVReportViewsTest(ModuleStoreTestCase): + """ + Test suite for CSV Purchase Reporting + """ + def setUp(self): + self.user = UserFactory.create() + self.user.set_password('password') + self.user.save() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.verified_course_id = 'org/test/Test_Course' + CourseFactory.create(org='org', number='test', run='course1', display_name='Test Course') + self.cart = Order.get_cart_for_user(self.user) + self.dl_grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) + self.dl_grp.save() + + def login_user(self): + """ + Helper fn to login self.user + """ + self.client.login(username=self.user.username, password="password") + + def add_to_download_group(self, user): + """ + Helper fn to add self.user to group that's allowed to download report CSV + """ + user.groups.add(self.dl_grp) + + def test_report_csv_no_access(self): + self.login_user() + response = self.client.get(reverse('payment_csv_report')) + self.assertEqual(response.status_code, 403) + + def test_report_csv_bad_method(self): + self.login_user() + self.add_to_download_group(self.user) + response = self.client.put(reverse('payment_csv_report')) + self.assertEqual(response.status_code, 400) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_report_csv_get(self): + self.login_user() + self.add_to_download_group(self.user) + response = self.client.get(reverse('payment_csv_report')) + + ((template, context), unused_kwargs) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/download_report.html') + self.assertFalse(context['total_count_error']) + self.assertFalse(context['date_fmt_error']) + self.assertIn(_("Download Purchase Report"), response.content) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_report_csv_bad_date(self): + self.login_user() + self.add_to_download_group(self.user) + response = self.client.post(reverse('payment_csv_report'), {'start_date': 'BAD', 'end_date': 'BAD'}) + + ((template, context), unused_kwargs) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/download_report.html') + self.assertFalse(context['total_count_error']) + self.assertTrue(context['date_fmt_error']) + self.assertIn(_("There was an error in your date input. It should be formatted as YYYY-MM-DD"), + response.content) + + @patch('shoppingcart.views.render_to_response', render_mock) + @override_settings(PAYMENT_REPORT_MAX_ITEMS=0) + def test_report_csv_too_long(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cart.purchase() + self.login_user() + self.add_to_download_group(self.user) + response = self.client.post(reverse('payment_csv_report'), {'start_date': '1970-01-01', + 'end_date': '2100-01-01'}) + + ((template, context), unused_kwargs) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/download_report.html') + self.assertTrue(context['total_count_error']) + self.assertFalse(context['date_fmt_error']) + self.assertIn(_("There are too many results in your report.") + " (>0)", response.content) + + # just going to ignored the date in this test, since we already deal with date testing + # in test_models.py + CORRECT_CSV_NO_DATE = ",1,purchased,1,40,40,usd,Registration for Course: Robot Super Course," + + def test_report_csv(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cart.purchase() + self.login_user() + self.add_to_download_group(self.user) + response = self.client.post(reverse('payment_csv_report'), {'start_date': '1970-01-01', + 'end_date': '2100-01-01'}) + self.assertEqual(response['Content-Type'], 'text/csv') + self.assertIn(",".join(OrderItem.csv_report_header_row()), response.content) + self.assertIn(self.CORRECT_CSV_NO_DATE, response.content) + + +class UtilFnsTest(TestCase): + """ + Tests for utility functions in views.py + """ + def setUp(self): + self.user = UserFactory.create() + + def test_can_download_report_no_group(self): + """ + Group controlling perms is not present + """ + self.assertFalse(_can_download_report(self.user)) + + def test_can_download_report_not_member(self): + """ + User is not part of group controlling perms + """ + Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP).save() + self.assertFalse(_can_download_report(self.user)) + + def test_can_download_report(self): + """ + User is part of group controlling perms + """ + grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) + grp.save() + self.user.groups.add(grp) + self.assertTrue(_can_download_report(self.user)) + + def test_get_date_from_str(self): + test_str = "2013-10-01" + date = _get_date_from_str(test_str) + self.assertEqual(2013, date.year) + self.assertEqual(10, date.month) + self.assertEqual(1, date.day) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 9522d15298cc21f77a766b52787a863c5bf59d14..3653c9152469dac8693366e518cd2cb36f6ee3f7 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -12,6 +12,7 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^add/course/(?P<course_id>[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), + url(r'^csv_report/$', 'csv_report', name='payment_csv_report'), ) if settings.MITX_FEATURES.get('ENABLE_PAYMENT_FAKE'): diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8c6d61d532e530afaa3d44c897eab2308bd0b0ed..ad7ef6b0800a2581eca23c4a1cbdad32bc94929c 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,4 +1,8 @@ import logging +import datetime +import pytz +from django.conf import settings +from django.contrib.auth.models import Group from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseBadRequest, HttpResponseForbidden, Http404) from django.utils.translation import ugettext as _ @@ -121,3 +125,73 @@ def show_receipt(request, ordernum): context.update(order_items[0].single_item_receipt_context) return render_to_response(receipt_template, context) + + +def _can_download_report(user): + """ + Tests if the user can download the payments report, based on membership in a group whose name is determined + in settings. If the group does not exist, denies all access + """ + try: + access_group = Group.objects.get(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) + except Group.DoesNotExist: + return False + return access_group in user.groups.all() + + +def _get_date_from_str(date_input): + """ + Gets date from the date input string. Lets the ValueError raised by invalid strings be processed by the caller + """ + return datetime.datetime.strptime(date_input.strip(), "%Y-%m-%d").replace(tzinfo=pytz.UTC) + + +def _render_report_form(start_str, end_str, total_count_error=False, date_fmt_error=False): + """ + Helper function that renders the purchase form. Reduces repetition + """ + context = { + 'total_count_error': total_count_error, + 'date_fmt_error': date_fmt_error, + 'start_date': start_str, + 'end_date': end_str, + } + return render_to_response('shoppingcart/download_report.html', context) + + +@login_required +def csv_report(request): + """ + Downloads csv reporting of orderitems + """ + if not _can_download_report(request.user): + return HttpResponseForbidden(_('You do not have permission to view this page.')) + + if request.method == 'POST': + start_str = request.POST.get('start_date', '') + end_str = request.POST.get('end_date', '') + try: + start_date = _get_date_from_str(start_str) + end_date = _get_date_from_str(end_str) + datetime.timedelta(days=1) + except ValueError: + # Error case: there was a badly formatted user-input date string + return _render_report_form(start_str, end_str, date_fmt_error=True) + + items = OrderItem.purchased_items_btw_dates(start_date, end_date) + if items.count() > settings.PAYMENT_REPORT_MAX_ITEMS: + # Error case: too many items would be generated in the report and we're at risk of timeout + return _render_report_form(start_str, end_str, total_count_error=True) + + response = HttpResponse(mimetype='text/csv') + filename = "purchases_report_{}.csv".format(datetime.datetime.now(pytz.UTC).strftime("%Y-%m-%d-%H-%M-%S")) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + OrderItem.csv_purchase_report_btw_dates(response, start_date, end_date) + return response + + elif request.method == 'GET': + end_date = datetime.datetime.now(pytz.UTC) + start_date = end_date - datetime.timedelta(days=30) + return _render_report_form(start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")) + + else: + return HttpResponseBadRequest("HTTP Method Not Supported") diff --git a/lms/envs/aws.py b/lms/envs/aws.py index e9eebb87817370c94eb0e19ed26121201540917a..2e19887bf9c0f343d870de6bcf69548aee8041ef 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -166,6 +166,10 @@ PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_ PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY', PAID_COURSE_REGISTRATION_CURRENCY) +# Payment Report Settings +PAYMENT_REPORT_GENERATOR_GROUP = ENV_TOKENS.get('PAYMENT_REPORT_GENERATOR_GROUP', PAYMENT_REPORT_GENERATOR_GROUP) +PAYMENT_REPORT_MAX_ITEMS = ENV_TOKENS.get('PAYMENT_REPORT_MAX_ITEMS', PAYMENT_REPORT_MAX_ITEMS) + # Bulk Email overrides BULK_EMAIL_DEFAULT_FROM_EMAIL = ENV_TOKENS.get('BULK_EMAIL_DEFAULT_FROM_EMAIL', BULK_EMAIL_DEFAULT_FROM_EMAIL) BULK_EMAIL_EMAILS_PER_TASK = ENV_TOKENS.get('BULK_EMAIL_EMAILS_PER_TASK', BULK_EMAIL_EMAILS_PER_TASK) @@ -261,7 +265,13 @@ CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] +if AWS_ACCESS_KEY_ID == "": + AWS_ACCESS_KEY_ID = None + AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] +if AWS_SECRET_ACCESS_KEY == "": + AWS_SECRET_ACCESS_KEY = None + AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads') DATABASES = AUTH_TOKENS['DATABASES'] @@ -280,12 +290,6 @@ OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is '' EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is '' -PEARSON_TEST_USER = "pearsontest" -PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") - -# Pearson hash for import/export -PEARSON = AUTH_TOKENS.get("PEARSON") - # Datadog for events! DATADOG = AUTH_TOKENS.get("DATADOG", {}) DATADOG.update(ENV_TOKENS.get("DATADOG", {})) diff --git a/lms/envs/common.py b/lms/envs/common.py index 2fdfe3325ddc1bdde424af64107df1c501ceff25..46c9df1a6ecbefebd104114a0553c3c75a8c7acd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -523,11 +523,6 @@ WIKI_USE_BOOTSTRAP_SELECT_WIDGET = False WIKI_LINK_LIVE_LOOKUPS = False WIKI_LINK_DEFAULT_LEVEL = 2 -################################# Pearson TestCenter config ################ - -PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" -# TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@example.com" - ##### Feedback submission mechanism ##### FEEDBACK_SUBMISSION_EMAIL = None @@ -550,6 +545,12 @@ CC_PROCESSOR = { } # Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$'] + +# Members of this group are allowed to generate payment reports +PAYMENT_REPORT_GENERATOR_GROUP = 'shoppingcart_report_access' +# Maximum number of rows the report can contain +PAYMENT_REPORT_MAX_ITEMS = 10000 + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, @@ -909,6 +910,8 @@ BULK_EMAIL_LOG_SENT_EMAILS = False # parallel, and what the SES rate is. BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02 + + ################################### APPS ###################################### INSTALLED_APPS = ( # Standard ones that are always installed... diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 8fe5e7a19c66733cb42daac766903144b514a49a..7ab58b008f0440eeee9bef208f49bce97295e792 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -254,9 +254,6 @@ MITX_FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) -########################## PEARSON TESTING ########################### -MITX_FEATURES['ENABLE_PEARSON_LOGIN'] = False - ########################## ANALYTICS TESTING ######################## ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/" diff --git a/lms/static/coffee/fixtures/calculator.html b/lms/static/coffee/fixtures/calculator.html index d0f7d3f652224f200bd895d75b4efc2e9d1a39b9..17d163eb67159094948383e96f36e6e66f43a6a4 100644 --- a/lms/static/coffee/fixtures/calculator.html +++ b/lms/static/coffee/fixtures/calculator.html @@ -7,7 +7,7 @@ <input type="text" id="calculator_input" tabindex="-1" /> <div class="help-wrapper"> <a id="calculator_hint" href="#" role="button" aria-haspopup="true" aria-controls="calculator_input_help" aria-expanded="false" tabindex="-1">Hints</a> - <dl id="calculator_input_help" class="help"></dl> + <div id="calculator_input_help" class="help" role="tooltip" aria-hidden="true"></div> </div> </div> <input id="calculator_button" type="submit" title="Calculate" arial-label="Calculate" value="=" tabindex="-1" /> diff --git a/lms/static/coffee/spec/calculator_spec.coffee b/lms/static/coffee/spec/calculator_spec.coffee index 868e3f605d07e3aceae52fe4d292af19035ae827..8e41ebcb3b78a572012d31e075ae8ae80ce6ee0a 100644 --- a/lms/static/coffee/spec/calculator_spec.coffee +++ b/lms/static/coffee/spec/calculator_spec.coffee @@ -55,11 +55,26 @@ describe 'Calculator', -> it 'show the help overlay', -> @calculator.helpShow() expect($('.help')).toHaveClass('shown') + expect($('.help')).toHaveAttr('aria-hidden', 'false') describe 'helpHide', -> it 'show the help overlay', -> @calculator.helpHide() expect($('.help')).not.toHaveClass('shown') + expect($('.help')).toHaveAttr('aria-hidden', 'true') + + describe 'handleKeyDown', -> + it 'on pressing Esc the hint becomes hidden', -> + @calculator.helpShow() + e = jQuery.Event('keydown', { which: 27 } ); + $(document).trigger(e); + expect($('.help')).not.toHaveClass 'shown' + + it 'On pressing other buttons the hint continue to show', -> + @calculator.helpShow() + e = jQuery.Event('keydown', { which: 32 } ); + $(document).trigger(e); + expect($('.help')).toHaveClass 'shown' describe 'calculate', -> beforeEach -> diff --git a/lms/static/coffee/src/calculator.coffee b/lms/static/coffee/src/calculator.coffee index e8cfea63f3c85aa904aa16fb86503ce135535f8b..c54a235581bf55d037d0d19d556bc521c0ef31f4 100644 --- a/lms/static/coffee/src/calculator.coffee +++ b/lms/static/coffee/src/calculator.coffee @@ -10,6 +10,9 @@ class @Calculator ) .click (e) -> e.preventDefault() + + $(document).keydown $.proxy(@handleKeyDown, @) + $('div.help-wrapper') .focusin($.proxy @helpOnFocus, @) .focusout($.proxy @helpOnBlur, @) @@ -24,14 +27,14 @@ class @Calculator $('div.calc-main').toggleClass 'open' if $calc.hasClass('closed') $calcWrapper - .find('input, a, dt, dd') + .find('input, a') .attr 'tabindex', -1 else text = gettext('Close Calculator') isExpanded = true $calcWrapper - .find('input, a, dt, dd') + .find('input, a,') .attr 'tabindex', 0 # TODO: Investigate why doing this without the timeout causes it to jump # down to the bottom of the page. I suspect it's because it's putting the @@ -57,13 +60,21 @@ class @Calculator @helpHide() helpShow: -> - $('.help').addClass 'shown' - $('#calculator_hint').attr 'aria-expanded', true + $('.help') + .addClass('shown') + .attr('aria-hidden', false) helpHide: -> if not @isFocusedHelp - $('.help').removeClass 'shown' - $('#calculator_hint').attr 'aria-expanded', false + $('.help') + .removeClass('shown') + .attr('aria-hidden', true) + + handleKeyDown: (e) -> + ESC = 27 + if e.which is ESC and $('.help').hasClass 'shown' + @isFocusedHelp = false + @helpHide() calculate: -> $.getWithPrefix '/calculate', { equation: $('#calculator_input').val() }, (data) -> diff --git a/lms/static/js/toggle_login_modal.js b/lms/static/js/toggle_login_modal.js index 063da6998e02435047597d5caf8b5fa45cbfb5dd..28bec089881abe5fae1c8580939ed22efa4b197f 100644 --- a/lms/static/js/toggle_login_modal.js +++ b/lms/static/js/toggle_login_modal.js @@ -1,5 +1,15 @@ (function($){ $.fn.extend({ + /* + * leanModal prepares an element to be a modal dialog. Call it once on the + * element that launches the dialog, when the page is ready. This function + * will add a .click() handler that properly opens the dialog. + * + * The launching element must: + * - be an <a> element, not a button, + * - have an href= attribute identifying the id of the dialog element, + * - have rel='leanModal'. + */ leanModal: function(options) { var defaults = { top: 100, @@ -13,7 +23,7 @@ $("body").append(overlay); } - options = $.extend(defaults, options); + options = $.extend(defaults, options); return this.each(function() { var o = options; @@ -23,7 +33,7 @@ $(".modal").hide(); var modal_id = $(this).attr("href"); - + if ($(modal_id).hasClass("video-modal")) { //Video modals need to be cloned before being presented as a modal //This is because actions on the video get recorded in the history. @@ -34,13 +44,12 @@ modal_id = '#modal_clone'; } - - $("#lean_overlay").click(function() { - close_modal(modal_id); + $("#lean_overlay").click(function(e) { + close_modal(modal_id, e); }); - $(o.closeButton).click(function() { - close_modal(modal_id); + $(o.closeButton).click(function(e) { + close_modal(modal_id, e); }); var modal_height = $(modal_id).outerHeight(); @@ -72,34 +81,30 @@ } window.scrollTo(0, 0); e.preventDefault(); - }); }); - function close_modal(modal_id){ + function close_modal(modal_id, e) { $("#lean_overlay").fadeOut(200); $('iframe', modal_id).attr('src', ''); $(modal_id).css({ 'display' : 'none' }); if (modal_id == '#modal_clone') { $(modal_id).remove(); } + e.preventDefault(); } } }); - $(document).ready(function($) { - $("a[rel*=leanModal]").each(function(){ - $(this).leanModal({ top : 120, overlay: 1, closeButton: ".close-modal", position: 'absolute' }); - embed = $($(this).attr('href')).find('iframe') - if(embed.length > 0) { - if(embed.attr('src').indexOf("?") > 0) { - embed.data('src', embed.attr('src') + '&autoplay=1&rel=0'); - embed.attr('src', ''); - } else { - embed.data('src', embed.attr('src') + '?autoplay=1&rel=0'); - embed.attr('src', ''); - } - } - }); + $(document).ready(function ($) { + $("a[rel*=leanModal]").each(function () { + $(this).leanModal({ top : 120, overlay: 1, closeButton: ".close-modal", position: 'absolute' }); + embed = $($(this).attr('href')).find('iframe') + if (embed.length > 0 && embed.attr('src')) { + var sep = (embed.attr('src').indexOf("?") > 0) ? '&' : '?'; + embed.data('src', embed.attr('src') + sep + 'autoplay=1&rel=0'); + embed.attr('src', ''); + } + }); }); })(jQuery); diff --git a/lms/static/js/wiki/cheatsheet.js b/lms/static/js/wiki/cheatsheet.js deleted file mode 100644 index bbf8377a3278424acb2bd34e74358d5de7487cc8..0000000000000000000000000000000000000000 --- a/lms/static/js/wiki/cheatsheet.js +++ /dev/null @@ -1,6 +0,0 @@ -$(document).ready(function () { - $('#cheatsheetLink').click(function() { - $('#cheatsheetModal').leanModal(); - }); - accessible_modal("#cheatsheetLink", "#cheatsheetModal .close-modal", "#cheatsheetModal", ".content-wrapper"); -}); diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako index 86a442301dd004789a5e41d97e430307a8af6de5..4ffdd972ba1bde926a965d6c652f3643b6be240a 100644 --- a/lms/static/sass/application-extend1.scss.mako +++ b/lms/static/sass/application-extend1.scss.mako @@ -46,7 +46,6 @@ @import 'multicourse/home'; @import 'multicourse/dashboard'; @import 'multicourse/account'; -@import 'multicourse/testcenter-register'; @import 'multicourse/courses'; @import 'multicourse/course_about'; @import 'multicourse/jobs'; diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/layout/_calculator.scss index 02e32ffd4f063d320f5e8cd70e89505211d1d826..274d8a00c6150a8c3555ad18ad16c563ef683050 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/layout/_calculator.scss @@ -120,39 +120,48 @@ div.calc-main { display: block; } - dl { + .help { background: #fff; border-radius: 3px; box-shadow: 0 0 3px #999; color: #333; - opacity: 0; padding: 10px; position: absolute; right: -40px; - top: -122px; + bottom: 57px; @include transition(none); width: 600px; - height: 0; overflow: hidden; pointer-events: none; + display: none; &.shown { - height: auto; - opacity: 1; - overflow: visible; + display: block; pointer-events: auto; } - dt { - clear: both; - float: left; + .bold { font-weight: bold; - padding-right: 12px; } - dd { - float: left; - margin-left: 0; + p, p+p { + margin: 0; + } + + table { + margin: 10px 0; + + td, th { + padding: 2px 5px; + } + } + + .calc-postfixes { + margin: 10px auto; + + td, th { + padding: 2px 15px; + } } } } diff --git a/lms/static/sass/course/wiki/_wiki.scss b/lms/static/sass/course/wiki/_wiki.scss index 76da4ed7a1509b2ccff018e13565b5dc4e6bdb2f..8a512223d7afa80d8763a328e8d6026819bca3a3 100644 --- a/lms/static/sass/course/wiki/_wiki.scss +++ b/lms/static/sass/course/wiki/_wiki.scss @@ -536,8 +536,12 @@ section.wiki { } .modal-header { + border: 1px solid $danger-red; + padding: ($baseline/2) ($baseline/2) 0 ($baseline/2); + margin-bottom: ($baseline/2); + h1, p { - color: #fff; + color: $base-font-color; } h1 { diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 0b4f33efa2b2c5f3cee83f96a5d09abd66f6d8e0..8254654b341ae7421bde6eae0fdec51ccd84382f 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -523,7 +523,7 @@ .message-copy, .message-copy .copy { @extend %t-copy-sub1; - margin: 0; + margin: 2px 0 0 0; } // CASE: expandable @@ -532,15 +532,18 @@ .wrapper-tip { .message-title, .message-copy { - @include transition(color 0.25s ease-in-out 0); margin-bottom: 0; } + .message-title .value, .message-copy { + @include transition(color 0.25s ease-in-out 0s); + } + // STATE: hover &:hover { cursor: pointer; - .message-title, .message-copy { + .message-title .value, .message-copy, .ui-toggle-expansion { color: $link-color; } } @@ -555,6 +558,11 @@ // STATE: is expanded &.is-expanded { + .ui-toggle-expansion { + @include transform(rotate(0)); + @include transform-origin(50% 50%); + } + .wrapper-extended { display: block; opacity: 1.0; @@ -573,6 +581,16 @@ float: left; } + .ui-toggle-expansion { + @include transition(all 0.25s ease-in-out 0s); + @include transform(rotate(-90deg)); + @include transform-origin(50% 50%); + @include font-size(21); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + .message-copy { float: right; } diff --git a/lms/static/sass/multicourse/_testcenter-register.scss b/lms/static/sass/multicourse/_testcenter-register.scss deleted file mode 100644 index e39c25b3873012a953d75cfc0ac511435dd3d70d..0000000000000000000000000000000000000000 --- a/lms/static/sass/multicourse/_testcenter-register.scss +++ /dev/null @@ -1,790 +0,0 @@ -// Pearson VUE Test Center Registration -// ===== - -.testcenter-register { - @include clearfix; - padding: 60px 0px 120px; - - // reset - horrible, but necessary - p, a, h1, h2, h3, h4, h5, h6 { - font-family: $sans-serif !important; - } - - // basic layout - .introduction { - width: flex-grid(12); - } - - .message-status-registration { - width: flex-grid(12); - } - - .content, aside { - @include box-sizing(border-box); - } - - .content { - margin-right: flex-gutter(); - width: flex-grid(8); - float: left; - } - - aside { - margin: 0; - width: flex-grid(4); - float: left; - } - - // introduction - .introduction { - - header { - - h2 { - margin: 0; - font-family: $sans-serif; - font-size: 16px; - color: $lighter-base-font-color; - } - - h1 { - font-family: $sans-serif; - font-size: 34px; - text-align: left; - } - } - } - - // content - .content { - background: rgb(255,255,255); - } - - // form - .form-fields-primary, .form-fields-secondary { - border-bottom: 1px solid rgba(0,0,0,0.25); - box-shadow: 0 1px 2px 0 rgba(0,0,0, 0.1); - } - - form { - border: 1px solid rgb(216, 223, 230); - border-radius: 3px; - box-shadow: 0 1px 2px 0 rgba(0,0,0, 0.2); - - .instructions, .note { - margin: 0; - padding: ($baseline*1.5) ($baseline*1.5) 0 ($baseline*1.5); - font-size: 14px; - color: tint($base-font-color, 20%); - - strong { - font-weight: normal; - } - - .title, .indicator { - color: $base-font-color; - font-weight: 700; - } - } - - fieldset { - border-bottom: 1px solid rgba(216, 223, 230, 0.50); - padding: ($baseline*1.5); - } - - .form-actions { - @include clearfix(); - padding: ($baseline*1.5); - - button[type="submit"] { - display: block; - @include button(simple, $blue); - @include box-sizing(border-box); - border-radius: 3px; - font: bold 15px/1.6rem $sans-serif; - letter-spacing: 0; - padding: ($baseline*0.75) $baseline; - text-align: center; - - - &:disabled { - opacity: 0.3; - } - } - - .action-primary { - float: left; - width: flex-grid(5,8); - margin-right: flex-gutter(2); - } - - .action-secondary { - display: block; - float: left; - width: flex-grid(2,8); - margin-top: $baseline; - padding: ($baseline/4); - font-size: 13px; - text-align: right; - text-transform: uppercase; - } - - &.error { - - } - } - - .list-input { - margin: 0; - padding: 0; - list-style: none; - - .field { - border-bottom: 1px dotted rgba(216, 223, 230, 0.5); - margin: 0 0 $baseline 0; - padding: 0 0 $baseline 0; - - &:last-child { - border: none; - margin-bottom: 0; - padding-bottom: 0; - } - - &.disabled, &.submitted { - color: rgba(0,0,0,.25); - - label { - cursor: text; - - &:after { - margin-left: ($baseline/4); - } - } - - textarea, input { - background: rgb(255,255,255); - color: rgba(0,0,0,.25); - } - } - - &.disabled { - label:after { - color: rgba(0,0,0,.35); - content: "(Disabled Currently)"; - } - } - - &.submitted { - - label:after { - content: "(Previously Submitted and Not Editable)"; - } - - .value { - border-radius: 3px; - border: 1px solid #C8C8C8; - padding: $baseline ($baseline*0.75); - background: #FAFAFA; - } - } - - &.error { - - label { - color: $red; - } - - input, textarea { - border-color: tint($red,50%); - } - } - - &.required { - - label { - font-weight: bold; - } - - label:after { - margin-left: ($baseline/4); - content: "*"; - } - } - - label, input, textarea { - display: block; - font-family: $sans-serif; - font-style: normal; - } - - label { - margin: 0 0 ($baseline/4) 0; - @include transition(color 0.15s ease-in-out 0s); - - &.is-focused { - color: $blue; - } - } - - input, textarea { - height: 100%; - width: 100%; - padding: ($baseline/2); - - &.long { - width: 100%; - } - - &.short { - width: 25%; - } - } - - textarea.long { - height: ($baseline*5); - } - } - - .field-group { - @include clearfix(); - border-bottom: 1px dotted rgba(216, 223, 230, 0.5); - margin: 0 0 $baseline 0; - padding: 0 0 $baseline 0; - - .field { - display: block; - float: left; - border-bottom: none; - margin: 0 $baseline ($baseline/2) 0; - padding-bottom: 0; - - input, textarea { - width: 100%; - } - } - - &.addresses { - - .field { - width: 45%; - } - } - - &.postal-2 { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; - - } - - &.phoneinfo { - - } - } - } - - &.disabled { - - > .instructions { - display: none; - } - - .field { - opacity: 0.6; - - .label, label { - cursor: auto; - } - } - - .form-actions { - display: none; - } - } - } - - // form - specifics - .form-fields-secondary { - display: none; - - &.is-shown { - display: block; - } - - &.disabled { - - fieldset { - opacity: 0.5; - } - } - } - - .form-fields-secondary-visibility { - display: block; - margin: 0; - padding: $baseline ($baseline*1.5) 0 ($baseline*1.5); - font-size: 13px; - - &.is-hidden { - display: none; - } - } - - - // aside - aside { - padding-left: $baseline; - - .message-status { - border-radius: 3px; - margin: 0 0 ($baseline*2) 0; - border: 1px solid #ccc; - padding: 0; - background: tint($yellow,90%); - - > * { - padding: $baseline; - } - - p { - margin: 0 0 ($baseline/4) 0; - padding: 0; - font-size: 13px; - } - - .label, .value { - display: block; - } - - h3, h4, h5 { - font-family: $sans-serif; - } - - h3 { - border-bottom: 1px solid tint(rgb(0,0,0), 90%); - padding-bottom: ($baseline*0.75); - } - - h4 { - margin-bottom: ($baseline/4); - } - - .status-list { - list-style: none; - margin: 0; - padding: $baseline; - - > .item { - @include clearfix(); - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid tint(rgb(0,0,0), 95%); - padding: 0 0 ($baseline/2) 0; - - &:last-child { - margin-bottom: 0; - border-bottom: none; - padding-bottom: 0; - } - - .title { - margin-bottom: ($baseline/4); - position: relative; - font-weight: bold; - font-size: 14px; - - &:after { - position: absolute; - top: 0; - right: $baseline; - margin-left: $baseline; - content: "not started"; - text-transform: uppercase; - font-size: 11px; - font-weight: normal; - opacity: 0.5; - } - } - - .details, .item, .instructions { - @include transition(opacity 0.10s ease-in-out 0s); - font-size: 13px; - opacity: 0.65; - } - - &:before { - border-radius: $baseline; - position: relative; - top: 3px; - display: block; - float: left; - width: ($baseline/2); - height: ($baseline/2); - margin: 0 ($baseline/2) 0 0; - background: $dark-gray; - content: ""; - } - - // specific states - &.status-processed { - - &:before { - background: green; - } - - .title:after { - color: green; - content: "processed"; - } - - &.status-registration { - .exam-link { - font-weight: 600 !important; - } - } - } - - &.status-pending { - - &:before { - background: transparent; - border: 1px dotted gray; - } - - .title:after { - color: gray; - content: "pending"; - } - } - - &.status-rejected { - - &:before { - background: $red; - } - - .title:after { - color: red; - content: "rejected"; - } - - .call-link { - font-weight: bold; - } - } - - &.status-initial { - - &:before { - background: transparent; - border: 1px dotted gray; - } - - .title:after { - color: gray; - } - } - - &:hover, &:focus { - - .details, .item, .instructions { - opacity: 1.0; - } - } - } - - // sub menus - .accommodations-list, .error-list { - list-style: none; - margin: ($baseline/2) 0; - padding: 0; - font-size: 13px; - - .item { - margin: 0 0 ($baseline/4) 0; - padding: 0; - } - } - } - - // actions - .contact-link { - font-weight: 600; - } - - .actions { - box-shadow: inset 0 1px 1px 0px rgba(0,0,0,0.2); - border-top: 1px solid tint(rgb(0,0,0), 90%); - padding-top: ($baseline*0.75); - background: tint($yellow,70%); - font-size: 14px; - - .title { - font-size: 14px; - } - - .label, .value { - display: inline-block; - } - - .label { - margin-right: ($baseline/4); - } - - .value { - font-weight: bold; - } - - .message-copy { - font-size: 13px; - } - - .exam-button { - @include button(simple, $pink); - display: block; - margin: ($baseline/2) 0 0 0; - padding: ($baseline/2) $baseline; - font-size: 13px; - font-weight: bold; - - &:hover, &:focus { - text-decoration: none; - } - } - } - - .registration-number { - - .label { - text-transform: none; - letter-spacing: 0; - } - - - } - - .registration-processed { - - .message-copy { - margin: 0 0 ($baseline/2) 0; - } - } - } - - > .details { - border-bottom: 1px solid rgba(216, 223, 230, 0.5); - margin: 0 0 $baseline 0; - padding: 0 $baseline $baseline $baseline; - font-family: $sans-serif; - font-size: 14px; - - &:last-child { - border: none; - margin-bottom: 0; - padding-bottom: 0; - } - - h4 { - margin: 0 0 ($baseline/2) 0; - font-family: $sans-serif; - font-size: 14px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #ccc; - } - - .label, .value { - display: inline-block; - } - - .label { - color: rgba(0,0,0,.65); - margin-right: ($baseline/2); - } - - .value { - color: rgb(0,0,0); - font-weight: 600; - } - } - - .details-course { - - } - - .details-registration { - - ul { - margin: 0; - padding: 0; - list-style: none; - - li { - margin: 0 0 ($baseline/4) 0; - } - } - } - } - - // status messages - .message { - border-radius: 3px; - display: none; - margin: $baseline 0; - padding: ($baseline/2) $baseline; - - &.is-shown { - display: block; - } - - .message-copy { - font-size: 14px; - } - - // registration status - &.message-flash { - border-radius: 3px; - position: relative; - margin: 0 0 ($baseline*2) 0; - border: 1px solid #ccc; - padding-top: ($baseline*0.75); - background: tint($yellow,70%); - font-size: 14px; - - .message-title, .message-copy { - } - - .message-title { - font-weight: bold; - font-size: 16px; - margin: 0 0 ($baseline/4) 0; - } - - .message-copy { - font-size: 14px; - } - - .contact-button { - @include button(simple, $blue); - } - - .exam-button { - @include button(simple, $pink); - } - - .button { - position: absolute; - top: ($baseline/4); - right: $baseline; - margin: ($baseline/2) 0 0 0; - padding: ($baseline/2) $baseline; - font-size: 13px; - font-weight: bold; - letter-spacing: 0; - - &:hover, &:focus { - text-decoration: none; - } - } - - &.message-action { - - .message-title, .message-copy { - width: 65%; - } - } - } - - // submission error - &.submission-error { - @include box-sizing(border-box); - float: left; - width: flex-grid(8,8); - border: 1px solid tint($red,85%); - background: tint($red,95%); - font-size: 14px; - - #submission-error-heading { - margin-bottom: ($baseline/2); - border-bottom: 1px solid tint($red, 85%); - padding-bottom: ($baseline/2); - font-weight: bold; - } - - .field-name, .field-error { - display: inline-block; - } - - .field-name { - margin-right: ($baseline/4); - } - - .field-error { - color: tint($red, 55%); - } - - p { - color: $red; - } - - ul { - margin: 0 0 ($baseline/2) 0; - padding: 0; - list-style: none; - - li { - margin-bottom: ($baseline/2); - padding: 0; - - span { - color: $red; - } - - a { - color: $red; - text-decoration: none; - - &:hover, &:active, &:focus { - text-decoration: underline; - } - } - } - } - } - - // submission success - &.submission-saved { - border: 1px solid tint($blue,85%); - background: tint($blue,95%); - - .message-copy { - color: $blue; - } - } - - // specific - registration closed - &.registration-closed { - @include border-bottom-radius(0); - margin-top: 0; - border-bottom: 1px solid $light-gray; - padding: $baseline; - background: tint($light-gray,50%); - - .message-title { - font-weight: bold; - } - - .message-copy { - - } - } - } - - .is-shown { - display: block; - } - - // hidden - .is-hidden { - display: none; - } -} diff --git a/lms/static/sass/shared/_modal.scss b/lms/static/sass/shared/_modal.scss index 07c9f3c61c287ee1efaa2cdb2db9ed2324d39477..f9c3027ddfa01f32c722d916b398f40e2237f817 100644 --- a/lms/static/sass/shared/_modal.scss +++ b/lms/static/sass/shared/_modal.scss @@ -59,7 +59,7 @@ overflow: hidden; padding-left: 10px; padding-right: 10px; - padding-bottom: 30px; + padding-bottom: 10px; position: relative; z-index: 2; @@ -136,7 +136,7 @@ form { margin-bottom: 12px; - padding: 0px 40px; + padding: 0px 40px 20px; position: relative; z-index: 2; diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index d6861fb45630036c1518b2e08c250dbf1a8d7803..1b3da66893dfea5ac3303d5afab73460c4e38862 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -5,6 +5,14 @@ padding: 30px 30px 0 30px; } +.error_msg { + margin: 20px; + padding: 5px; + color: $red; + border: 1px solid $red; + +} + .cart-list { padding: 30px; margin-top: 40px; diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index 8cd5368ad05f01a55c735b31030dfe93ab37ad3d..0be247ed899cd037e7aa0c5520c8782d40d00d93 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -25,7 +25,7 @@ def url_class(is_active): <li> % endif <a href="${tab.link | h}" class="${url_class(tab.is_active)}"> - ${tab.name | h} + ${_(tab.name) | h} % if tab.is_active == True: <span class="sr">, current location</span> %endif diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 9e3b19076b6886660aedaf1b01192742897e6c27..80b52b9d3648cdc8d144b09a8a782f982319fc97 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -218,24 +218,113 @@ ${fragment.foot_html()} <input type="text" id="calculator_input" title="${_('Calculator Input Field')}" tabindex="-1" /> <div class="help-wrapper"> - <a id="calculator_hint" href="#" role="button" aria-haspopup="true" aria-controls="calculator_input_help" aria-expanded="false" tabindex="-1">${_("Hints")}</a> - <dl id="calculator_input_help" class="help"> - <dt tabindex="-1">${_("Suffixes:")}</dt> - <dd tabindex="-1"> %kMGTcmunp</dd> - <dt tabindex="-1">${_("Operations:")}</dt> - <dd tabindex="-1">^ * / + - ()</dd> - <dt tabindex="-1">${_("Functions:")}</dt> - <dd tabindex="-1">sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs </dd> - <dt tabindex="-1">${_("Constants")}</dt> - <dd tabindex="-1">e, pi</dd> - - <!-- Students won't know what parallel means at this time. Complex numbers aren't well tested in the courseware, so we would prefer to not expose them. If you read the comments in the source, feel free to use them. If you run into a bug, please let us know. But we can't officially support them right now. - - <dt>Unsupported:</dt> <dd>||, j </dd> --> - </dl> + <a id="calculator_hint" href="#" role="button" aria-describedby="calculator_input_help" tabindex="-1">${_("Hints")}</a> + <div id="calculator_input_help" class="help" role="tooltip" aria-hidden="true"> + <p><span class="bold">${_("Integers")}:</span> 2520</p> + <p><span class="bold">${_("Decimals")}:</span> 3.14 or .98</p> + <p><span class="bold">${_("Scientific notation")}:</span> 1.2e-2 (=0.012), -4.4e+5 = -4.4e5 (=-440,000)</p> + <p><span class="bold">${_("Appending SI postfixes")}:</span> 2.25k (=2,250)</p> + <p><span class="bold">${_("Supported SI postfixes")}:</span></p> + <table class="calc-postfixes"> + <tbody> + <tr> + <td>%</td> + <td>percent</td> + <td>0.01 = 1e-2</td> + </tr> + <tr> + <td>T</td> + <td>tera</td> + <td>1e12</td> + </tr> + <tr> + <td>G</td> + <td>giga</td> + <td>1e9</td> + </tr> + <tr> + <td>M</td> + <td>mega</td> + <td>1e6</td> + </tr> + <tr> + <td>k</td> + <td>kilo</td> + <td>1000 = 1e3</td> + </tr> + <tr> + <td>c</td> + <td>centi</td> + <td>0.01 = 1e-2</td> + </tr> + <tr> + <td>m</td> + <td>milli</td> + <td>0.001 = 1e-3</td> + </tr> + <tr> + <td>u</td> + <td>micro</td> + <td>1e-6</td> + </tr> + <tr> + <td>n</td> + <td>nano</td> + <td>1e-9</td> + </tr> + <tr> + <td>p</td> + <td>pico</td> + <td>1e-12</td> + </tr> + </tbody> + </table> + <p><span class="bold">${_("Operators")}:</span> + - * / ^ and || (${_("parallel resistors function")})</p> + <p><span class="bold">${_("Functions")}:</span> sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs, fact/factorial</p> + <p><span class="bold">${_("Constants")}:</span></p> + <table> + <tbody> + <tr> + <td>j</td> + <td>=</td> + <td>sqrt(-1)</td> + </tr> + <tr> + <td>e</td> + <td>=</td> + <td>${_("Euler's number")}</td> + </tr> + <tr> + <td>pi</td> + <td>=</td> + <td>${_("ratio of a circle's circumference to it's diameter")}</td> + </tr> + <tr> + <td>k</td> + <td>=</td> + <td>${_("Boltzmann constant")}</td> + </tr> + <tr> + <td>c</td> + <td>=</td> + <td>${_("speed of light")}</td> + </tr> + <tr> + <td>T</td> + <td>=</td> + <td>${_("freezing point of water in degrees Kelvin")}</td> + </tr> + <tr> + <td>q</td> + <td>=</td> + <td>${_("fundamental charge")}</td> + </tr> + </tbody> + </table> + </div> </div> </div> - <input id="calculator_button" type="submit" title="${_('Calculate')}" value="=" arial-label="${_('Calculate')}" value="=" tabindex="-1" /> + <input id="calculator_button" type="submit" title="${_('Calculate')}" value="=" aria-label="${_('Calculate')}" value="=" tabindex="-1" /> <input type="text" id="calculator_output" title="${_('Calculator Output Field')}" readonly tabindex="-1" /> </form> diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index ea5171c0ef9e9722dc0e1f04f84d04a2dbb92de2..3222b6aae854ca22e0cbadfb0f85b2f7c1e7fbc5 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -19,7 +19,7 @@ else: % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'): <p class="message-copy">${_("Your final grade:")} <span class="grade-value">${"{0:.0f}%".format(float(cert_status['grade'])*100)}</span>. - % if cert_status['status'] == 'notpassing': + % if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit': ${_("Grade required for a certificate:")} <span class="grade-value"> ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}</span>. % elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified': @@ -44,6 +44,12 @@ else: <a class="btn" href="${cert_status['download_url']}" title="${_('This link will open/download a PDF document')}"> ${_("Download Your Certificate (PDF)")}</a></li> + % elif cert_status['show_download_url'] and enrollment.mode == 'verified' and cert_status['mode'] == 'honor': + <li class="action"> + <p>${_('Since we did not have a valid set of verification photos from you when certificates were generated, we could not grant you a verified certificate. An honor code certificate has been granted instead.')}</p> + <a class="btn" href="${cert_status['download_url']}" + title="${_('This link will open/download a PDF document')}"> + ${_("Download Your Certificate (PDF)")}</a></li> % elif cert_status['show_download_url'] and enrollment.mode == 'verified': <li class="action"> <a class="btn" href="${cert_status['download_url']}" diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index db8cfbd10ed8087e7c1ce623d30c1c6777347d9b..aa7b1b84b05e6e5f5da3e5201d560845ca7f8e6c 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -65,7 +65,10 @@ <div class="message message-upsell has-actions is-expandable is-shown"> <div class="wrapper-tip"> - <h4 class="message-title">${_("Challenge Yourself!")}</h4> + <h4 class="message-title"> + <i class="icon-caret-down ui-toggle-expansion"></i> + <span class="value">${_("Challenge Yourself!")}</span> + </h4> <p class="message-copy">${_("Take this course as an ID-verified student.")}</p> </div> diff --git a/lms/templates/shoppingcart/download_report.html b/lms/templates/shoppingcart/download_report.html new file mode 100644 index 0000000000000000000000000000000000000000..838b07f145ee0bbd99493cbd12d3aba6ea018c1e --- /dev/null +++ b/lms/templates/shoppingcart/download_report.html @@ -0,0 +1,29 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="title"><title>${_("Download Purchase Report")}</title></%block> + + +<section class="container"> + <h2>${_("Download CSV of purchase data")}</h2> + % if date_fmt_error: + <section class="error_msg"> + ${_("There was an error in your date input. It should be formatted as YYYY-MM-DD")} + </section> + % endif + % if total_count_error: + <section class="error_msg"> + ${_("There are too many results in your report.")} (>${settings.PAYMENT_REPORT_MAX_ITEMS}). + ${_("Try making the date range smaller.")} + </section> + % endif + <form method="post"> + <label for="start_date">${_("Start Date: ")}</label> + <input id="start_date" type="text" value="${start_date}" name="start_date"/> + <label for="end_date">${_("End Date: ")}</label> + <input id="end_date" type="text" value="${end_date}" name="end_date"/> + <input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}" /> + <input type="submit" /> + </form> +</section> diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html deleted file mode 100644 index c35acf9914392bf6e8d805295f73086ae411ceb5..0000000000000000000000000000000000000000 --- a/lms/templates/test_center_register.html +++ /dev/null @@ -1,482 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -<%! - from django.core.urlresolvers import reverse - from courseware.courses import course_image_url, get_course_about_section - from courseware.access import has_access - from certificates.models import CertificateStatuses -%> -<%inherit file="main.html" /> - -<%namespace name='static' file='static_content.html'/> - -<%block name="title"><title>${_('Pearson VUE Test Center Proctoring - Registration')}</title></%block> - -<%block name="js_extra"> - <script type="text/javascript"> - (function() { - - // if form is disabled or registration is closed - $('#testcenter_register_form.disabled').find('input, textarea, button').attr('disabled','disabled'); - - // toggling accommodations elements - $('.form-fields-secondary-visibility').click(function(e){ - e.preventDefault(); - $(this).addClass("is-hidden"); - - $('.form-fields-secondary').addClass("is-shown"); - }); - - - $(document).on('ajax:success', '#testcenter_register_form', function(data, json, xhr) { - if(json.success) { - // when a form is successfully filled out, return back to the dashboard. - location.href="${reverse('dashboard')}"; - } else { - // This is performed by the following code that parses the errors returned as json by the - // registration form validation. - var field_errors = json.field_errors; - var non_field_errors = json.non_field_errors; - var fieldname; - var field_errorlist; - var field_error; - var error_msg; - var num_errors = 0; - var error_html = ''; - // first get rid of any errors that are already present: - $(".field.error", ".form-actions").removeClass('error'); - $("#submission-error-list").html(error_html); - // start to display new errors: - $(".form-actions").addClass("error"); - $(".submission-error").addClass("is-shown"); - for (fieldname in field_errors) { - // to inform a user of what field the error occurred on, add a class of .error to the .field element. - // for convenience, use the "id" attribute to identify the one matching the errant field's name. - var field_id = "field-" + fieldname; - var field_label = $("[id='"+field_id+"'] label").text(); - - $("[id='"+field_id+"']").addClass('error'); - - field_errorlist = field_errors[fieldname]; - for (i=0; i < field_errorlist.length; i+= 1) { - field_error = field_errorlist[i]; - error_msg = '<span class="field-name">' + field_label + ':</span>' + '<span class="field-error">' + field_error + '</span>'; - error_html = error_html + '<li>' + '<a href="#field-' + fieldname + '">' + error_msg + '</a></li>'; - num_errors += 1; - } - } - for (i=0; i < non_field_errors.length; i+= 1) { - error_msg = non_field_errors[i]; - error_html = error_html + '<li class="to-be-determined">' + error_msg + '</li>'; - num_errors += 1; - } - if (num_errors == 1) { - error_msg = 'was an error'; - } else { - error_msg = 'were ' + num_errors + ' errors'; - } - $('#submission-error-heading').text("We're sorry, but there " + error_msg + " in the information you provided below:") - $("#submission-error-list").html(error_html); - } - }); - - $("form :input").focus(function() { - $("label[for='" + this.id + "']").addClass("is-focused"); - }).blur(function() { - $("label").removeClass("is-focused"); - }); - - })(this) - </script> -</%block> - -<section class="testcenter-register container"> - - <section class="introduction"> - <header> - <hgroup> - <h2><a href="${reverse('dashboard')}">${get_course_about_section(course, 'university')} ${course.display_number_with_default | h} ${course.display_name_with_default | h}</a></h2> - - % if registration: - <h1>${_('Your Pearson VUE Proctored Exam Registration')}</h1> - % else: - <h1>${_('Register for a Pearson VUE Proctored Exam')}</h1> - % endif - </hgroup> - </header> - </section> - - <% - exam_help_href = "mailto:exam-help@edx.org?subject=Pearson VUE Exam - " + get_course_about_section(course, 'university') + " - " + course.number - %> - - % if registration: - <!-- select one of four registration status banners to display --> - % if registration.is_accepted: - <section class="status message message-flash registration-processed message-action is-shown"> - <h3 class="message-title">${_('Your registration for the Pearson exam has been processed')}</h3> - <p class="message-copy">${_("Your registration number is <strong>{reg_number}</strong>. " - "(Write this down! You\'ll need it to schedule your exam.)").format(reg_number=registration.client_candidate_id)}</p> - <a href="${registration.registration_signup_url}" class="button exam-button">${_('Schedule Pearson exam')}</a> - </section> - % endif - - % if registration.demographics_is_rejected: - <section class="status message message-flash demographics-rejected message-action is-shown"> - <h3 class="message-title">${_('Your demographic information contained an error and was rejected')}</h3> - <p class="message-copy">${_('Please check the information you provided, and correct the errors noted below.')} - </section> - % endif - - % if registration.registration_is_rejected: - <section class="status message message-flash registration-rejected message-action is-shown"> - <h3 class="message-title">${_('Your registration for the Pearson exam has been rejected')}</h3> - <p class="message-copy">${_('Please see your <strong>registration status</strong> details for more information.')}</p> - </section> - % endif - - % if registration.is_pending: - <section class="status message message-flash registration-pending is-shown"> - <h3 class="message-title">${_('Your registration for the Pearson exam is pending')}</h3> - <p class="message-copy">${_('Once your information is processed, it will be forwarded to Pearson and you will be able to schedule an exam.')}</p> - </section> - % endif - - % endif - - <section class="content"> - <header> - <h3 class="is-hidden">${_('Registration Form')}</h3> - </header> - % if exam_info.is_registering(): - <form id="testcenter_register_form" method="post" data-remote="true" action="/create_exam_registration"> - % else: - <form id="testcenter_register_form" method="post" data-remote="true" action="/create_exam_registration" class="disabled"> - <!-- registration closed, so tell them they can't do anything: --> - <div class="status message registration-closed is-shown"> - <h3 class="message-title">${_('Registration for this Pearson exam is closed')}</h3> - <p class="message-copy">${_('Your previous information is available below, however you may not edit any of the information.')} - </div> - % endif - - % if registration: - <p class="instructions"> - ${_('Please use the following form if you need to update your demographic information used in your Pearson VUE Proctored Exam. Required fields are noted by <strong class="indicator">bold text and an asterisk (*)')}</strong>. - </p> - % else: - <p class="instructions"> - ${_('Please provide the following demographic information to register for a Pearson VUE Proctored Exam. Required fields are noted by <strong class="indicator">bold text and an asterisk (*)')}</strong>. - </p> - % endif - - <!-- include these as pass-throughs --> - <input id="id_email" type="hidden" name="email" value="${user.email}" /> - <input id="id_username" type="hidden" name="username" value="${user.username}" /> - <input id="id_course_id" type="hidden" name="course_id" value="${course.id}" /> - <input id="id_exam_series_code" type="hidden" name="exam_series_code" value="${exam_info.exam_series_code}" /> - - <div class="form-fields-primary"> - <fieldset class="group group-form group-form-personalinformation"> - <legend class="is-hidden">${_('Personal Information')}</legend> - - <ol class="list-input"> - <li class="field" id="field-salutation"> - <label for="salutation">${_('Salutation')}</label> - <input class="short" id="salutation" type="text" name="salutation" value="${testcenteruser.salutation}" placeholder="${_('e.g. Mr., Ms., Mrs., Dr.')}" /> - </li> - <li class="field required" id="field-first_name"> - <label for="first_name">${_('First Name')} </label> - <input id="first_name" type="text" name="first_name" value="${testcenteruser.first_name}" placeholder="${_('e.g. Albert')}" /> - </li> - <li class="field" id="field-middle_name"> - <label for="middle_name">${_("Middle Name")}</label> - <input id="middle_name" type="text" name="middle_name" value="${testcenteruser.middle_name}" placeholder="" /> - </li> - <li class="field required" id="field-last_name"> - <label for="last_name">${_('Last Name')}</label> - <input id="last_name" type="text" name="last_name" value="${testcenteruser.last_name}" placeholder="${_('e.g. Einstein')}" /> - </li> - <li class="field" id="field-suffix"> - <label for="suffix">${_('Suffix')}</label> - <input class="short" id="suffix" type="text" name="suffix" value="${testcenteruser.suffix}" placeholder="${_('e.g. Jr., Sr. ')}" /> - </li> - </ol> - </fieldset> - - <fieldset class="group group-form group-form-mailingaddress"> - <legend class="is-hidden">${_('Mailing Address')}</legend> - - <ol class="list-input"> - <li class="field required" id="field-address_1"> - <label class="long" for="address_1">${_('Address Line #1')}</label> - <input id="address_1" type="text" name="address_1" value="${testcenteruser.address_1}" placeholder="${_('e.g. 112 Mercer Street')}" /> - </li> - <li class="field-group addresses"> - <div class="field" id="field-address_2"> - <label for="address_2">${_('Address Line #2')}</label> - <input id="address_2" class="long" type="text" name="address_2" value="${testcenteruser.address_2}" placeholder="${_('e.g. Apartment 123')}" /> - </div> - <div class="field" id="field-address_3"> - <label for="address_3">${_('Address Line #3')}</label> - <input id="address_3" class="long" type="text" name="address_3" value="${testcenteruser.address_3}" placeholder="${_('e.g. Attention: Albert Einstein')}" /> - </div> - </li> - <li class="field required postal-1" id="field-city"> - <label for="city">${_('City')}</label> - <input id="city" class="long" type="text" name="city" value="${testcenteruser.city}" placeholder="${_('e.g. Newark')}" /> - </li> - <li class="field-group postal-2"> - <div class="field" id="field-state"> - <label for="state">${_('State/Province')}</label> - <input id="state" class="short" type="text" name="state" value="${testcenteruser.state}" placeholder="${_('e.g. NJ')}" /> - </div> - <div class="field" id="field-postal_code"> - <label for="postal_code">${_('Postal Code')}</label> - <input id="postal_code" class="short" type="text" name="postal_code" value="${testcenteruser.postal_code}" placeholder="${_('e.g. 08540')}" /> - </div> - <div class="field required" id="field-country"> - <label class="short" for="country">${_('Country Code')}</label> - <input id="country" class="short" type="text" name="country" value="${testcenteruser.country}" placeholder="${_('e.g. USA')}" /> - </div> - </li> - </ol> - </fieldset> - - <fieldset class="group group-form group-form-contactinformation"> - <legend class="is-hidden">${_('Contact & Other Information')}</legend> - - <ol class="list-input"> - <li class="field-group phoneinfo"> - <div class="field required" id="field-phone"> - <label for="phone">${_('Phone Number')}</label> - <input id="phone" type="tel" name="phone" value="${testcenteruser.phone}" /> - </div> - <div class="field" id="field-extension"> - <label for="extension">${_('Extension')}</label> - <input id="extension" class="short" type="tel" name="extension" value="${testcenteruser.extension}" /> - </div> - <div class="field required" id="field-phone_country_code"> - <label for="phone_country_code">${_('Phone Country Code')}</label> - <input id="phone_country_code" class="short" type="text" name="phone_country_code" value="${testcenteruser.phone_country_code}" /> - </div> - </li> - <li class="field-group faxinfo"> - <div class="field" id="field-fax"> - <label for="fax">${_('Fax Number')}</label> - <input id="fax" type="tel" class="short" name="fax" value="${testcenteruser.fax}" /> - </div> - <div class="field" id="field-fax_country_code"> - <label for="fax_country_code">${_('Fax Country Code')}</label> - <input id="fax_country_code" class="short" type="text" name="fax_country_code" value="${testcenteruser.fax_country_code}" /> - </div> - </li> - <li class="field" id="field-company_name"> - <label for="company_name">${_('Company')}</label> - <input id="company_name" type="text" name="company_name" value="${testcenteruser.company_name}" placeholder="${_('e.g. American Association of University Professors')}" /> - </li> - </ol> - </fieldset> - </div> - - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: - <div class="form-fields-secondary is-shown disabled" id="form-fields-secondary"> - % endif - % else: - <div class="form-fields-secondary" id="form-fields-secondary"> - % endif - - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: - <p class="note">${_('<span class="title">Note</span>: Your previous accommodation request below needs to be reviewed in detail <strong>and will add a significant delay to your registration process</strong>.')}</p> - % endif - % else: - <p class="note">${_('<span class="title">Note</span>: Accommodation requests are not part of your demographic information, <strong>and cannot be changed once submitted</strong>. Accommodation requests, which are reviewed on a case-by-case basis, <strong>will add significant delay to the registration process</strong>.')}</p> - % endif - - <fieldset class="group group-form group-form-optional"> - <legend class="is-hidden">${_('Optional Information')}</legend> - - <ol class="list-input"> - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: - <li class="field submitted" id="field-accommodation_request"> - <label for="accommodation_request">${_('Accommodations Requested')}</label> - <p class="value" id="accommodations">${registration.accommodation_request}</p> - </li> - % endif - % else: - <li class="field" id="field-accommodation_request"> - <label for="accommodation_request">${_('Accommodations Requested')}</label> - <textarea class="long" id="accommodation_request" name="accommodation_request" value="" placeholder=""></textarea> - </li> - % endif - </ol> - </fieldset> - </div> - - <div class="form-actions"> - % if registration: - <button type="submit" id="submit" class="action action-primary action-update">${_('Update Demographics')}</button> - <a href="${reverse('dashboard')}" class="action action-secondary action-cancel">${_('Cancel Update')}</a> - % else: - <button type="submit" id="submit" class="action action-primary action-register">${_('Register for Pearson VUE Test')}</button> - <a href="${reverse('dashboard')}" class="action action-secondary action-cancel">${_('Cancel Registration')}</a> - % endif - - <div class="message message-status submission-error"> - <p id="submission-error-heading" class="message-copy"></p> - <ul id="submission-error-list"></ul> - </div> - </div> - </form> - - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: - <a class="actions form-fields-secondary-visibility is-hidden" href="#form-fields-secondary">${_('Special (<abbr title="Americans with Disabilities Act">ADA</abbr>) Accommodations')}</a> - % endif - % else: - <a class="actions form-fields-secondary-visibility" href="#form-fields-secondary">${_('Special (<abbr title="Americans with Disabilities Act">ADA</abbr>) Accommodations')}</a> - % endif - </section> - - <aside> - % if registration: - - % if registration.is_accepted: - <div class="message message-status registration-processed is-shown"> - % endif - % if registration.is_rejected: - <div class="message message-status registration-rejected is-shown"> - % endif - % if registration.is_pending: - <div class="message message-status registration-pending is-shown"> - % endif - <h3>${_('Pearson Exam Registration Status')}</h3> - - <ol class="status-list"> - <!-- first provide status of demographics --> - % if registration.demographics_is_pending: - <li class="item status status-pending status-demographics"> - <h4 class="title">${_('Demographic Information')}</h4> - <p class="details">${_('The demographic information you most recently provided is pending. You may edit this information at any point before exam registration closes on <strong>{end_date}</strong>').format(end_date=exam_info.registration_end_date_text)}</p> - </li> - % endif - % if registration.demographics_is_accepted: - <li class="item status status-processed status-demographics"> - <h4 class="title">${_('Demographic Information')}</h4> - <p class="details">${_('The demographic information you most recently provided has been processed. You may edit this information at any point before exam registration closes on <strong>{end_date}</strong>').format(end_date=exam_info.registration_end_date_text)}</p> - </li> - % endif - % if registration.demographics_is_rejected: - <li class="item status status-rejected status-demographics"> - <h4 class="title">${_('Demographic Information')}</h4> - <p class="details">${_('The demographic information you most recently provided has been rejected by Pearson. You can correct and submit it again before the exam registration closes on <strong>{end_date}</strong>. The error message is:').format(end_date=exam_info.registration_end_date_text)}</p> - - <ul class="error-list"> - <li class="item">${registration.testcenter_user.upload_error_message}.</li> - </ul> - - <p class="action">${_('If the error is not correctable by revising your demographic information, please {contact_link_start}contact edX at exam-help@edx.org{contact_link_end}.').format(contact_link_start='<a class="contact-link" href="{}"'.format(exam_help_href), contact_link_end='</a>')}</p> - </li> - % endif - - <!-- then provide status of accommodations, if any --> - % if registration.accommodation_is_pending: - <li class="item status status-pending status-accommodations"> - <h4 class="title">${_('Accommodations Request')}</h4> - <p class="details">${_('Your requested accommodations are pending. Within a few days, you should see confirmation here of granted accommodations.')}</p> - </li> - % endif - % if registration.accommodation_is_accepted: - <li class="item status status-processed status-accommodations"> - <h4 class="title">${_('Accommodations Request')}</h4> - <p class="details">${_('Your requested accommodations have been reviewed and processed. You are allowed:')}</p> - - <ul class="accommodations-list"> - % for accommodation_name in registration.get_accommodation_names(): - <li class="item">${accommodation_name}</li> - % endfor - </ul> - </li> - % endif - % if registration.accommodation_is_rejected: - <li class="item status status-processed status-accommodations"> - <h4 class="title">${_('Accommodations Request')}</h4> - <p class="details">${_('Your requested accommodations have been reviewed and processed. You are allowed no accommodations.')}</p> - - <p class="action">${_('Please {contact_link_start}contact {edX} at ${exam_help}{contact_link_end}.').format(contact_link_start='<a class="contact-link" href="{}">'.format(exam_help_href), contact_link_end='</a>', edX="edX", exam_help="exam-help@edx.org")}</p> - </li> - % endif - - <!-- finally provide status of registration --> - % if registration.registration_is_pending: - <li class="item status status-pending status-registration"> - <h4 class="title">${_('Registration Request')}</h4> - <p class="details">${_('Your exam registration is pending. Once your information is processed, it will be forwarded to Pearson and you will be able to schedule an exam.')}</p> - </li> - % endif - % if registration.registration_is_accepted: - <li class="item status status-processed status-registration"> - <h4 class="title">${_('Registration Request')}</h4> - <p class="details">${_('Your exam registration has been processed and has been forwarded to Pearson. <strong>You are now able to {exam_link_start}schedule a Pearson exam{exam_link_end}</strong>.').format(exam_link_start='<a href="{}" class="exam-link">'.format(registration.registration_signup_url), exam_link_end='</a>')}</p> - </li> - % endif - % if registration.registration_is_rejected: - <li class="item status status-rejected status-registration"> - <h4 class="title">${_('Registration Request')}</h4> - <p class="details">${_('Your exam registration has been rejected by Pearson. <strong>You currently cannot schedule an exam</strong>. The errors found include:')}</p> - - <ul class="error-list"> - <li class="item">${registration.upload_error_message}</li> - </ul> - - <p class="action">${_('Please {contact_link_start}contact edX at exam-help@edx.org{contact_link_end}.').format(contact_link_start='<a class="contact-link" href="{}"'.format(exam_help_href), contact_link_end='</a>')}</p> - </li> - % endif - </ol> - - </div> - - - % endif - - <div class="details details-course"> - <h4>${_("About {university} {course_number}").format(university=get_course_about_section(course, 'university'), course_number=course.course.display_number_with_default) | h}</h4> - <p> - % if course.has_ended(): - <span class="label">${_('Course Completed:')}</span> <span class="value">${course.end_date_text}</span> - % elif course.has_started(): - <span class="label">${_('Course Started:')}</span> <span class="value">${course.start_date_text}</span> - % else: # hasn't started yet - <span class="label">${_('Course Starts:')}</span> <span class="value">${course.start_date_text}</span> - % endif - </p> - </div> - - <div class="details details-registration"> - <h4>${_('Pearson VUE Test Details')}</h4> - % if exam_info is not None: - <ul> - <li> - <span class="label">${_('Exam Name:')}</span> <span class="value">${exam_info.display_name_with_default}</span> - </li> - <li> - <span class="label">${_('First Eligible Appointment Date:')}</span> <span class="value">${exam_info.first_eligible_appointment_date_text}</span> - </li> - <li> - <span class="label">${_('Last Eligible Appointment Date:')}</span> <span class="value">${exam_info.last_eligible_appointment_date_text}</span> - </li> - <li> - <span class="label">${_('Registration Ends:')}</span> <span class="value">${exam_info.registration_end_date_text}</span> - </li> - </ul> - % endif - </div> - - <div class="details details-contact"> - <h4>${_('Questions')}</h4> - <p>${_('If you have a specific question pertaining to your registration, you may {contact_link_start}contact edX at exam-help@edx.org{contact_link_end}.').format(contact_link_start='<a class="contact-link" href="{}"'.format(exam_help_href), contact_link_end='</a>')}</p> - </div> - </aside> -</section> diff --git a/lms/templates/wiki/edit.html b/lms/templates/wiki/edit.html index 65378da4e4ac511d31bd49c973f84a7b9e3f1195..1f271576204007535e20bfa78fac2f8a72fc32f7 100644 --- a/lms/templates/wiki/edit.html +++ b/lms/templates/wiki/edit.html @@ -1,30 +1,57 @@ {% extends "wiki/article.html" %} -{% load wiki_tags i18n %} +{% load wiki_tags i18n sekizai_tags %} {% load url from future %} {% block pagetitle %}{% trans "Edit" %}: {{ article.current_revision.title }}{% endblock %} {% block wiki_contents_tab %} -<form method="POST" class="form-horizontal" id="article_edit_form" enctype="multipart/form-data"> +{% addtoblock "js" %} +<script type="text/javascript"> + $(document).ready( + function ($) { + accessible_modal("#cheatsheetLink", "#cheatsheetModal .close-modal", "#cheatsheetModal", ".content-wrapper"); + accessible_modal("#previewButton", "#previewModal .close-modal", "#previewModal", ".content-wrapper"); + $("#previewModalBackToEditor").click(function (e) { + $("#previewModal .close-modal").click(); + e.preventDefault(); + }); + } + ); +</script> +{% endaddtoblock %} + +<form method="POST" class="form-horizontal" id="article_edit_form" name="article_edit_form" enctype="multipart/form-data"> {% include "wiki/includes/editor.html" %} <div class="form-actions"> <button type="submit" name="save" value="1" class="btn btn-large btn-primary" onclick="this.form.target=''; this.form.action='{% url 'wiki:edit' path=urlpath.path article_id=article.id %}'"> <span class="icon-ok"></span> {% trans "Save changes" %} </button> - <button type="submit" name="preview" value="1" class="btn btn-large" onclick="$('#previewModal').modal('show'); this.form.target = 'previewWindow'; this.form.action = '{% url 'wiki:preview' path=urlpath.path article_id=article.id %}';"> + <a class="btn btn-large" id="previewButton" href="#previewModal" rel="leanModal" + onclick=" + document.article_edit_form.target='previewWindow'; + document.article_edit_form.action='{% url 'wiki:preview' path=urlpath.path article_id=article.id %}'; + document.article_edit_form.submit();"> <span class="icon-eye-open"></span> {% trans "Preview" %} - </button> - + </a> + <a href="{% url 'wiki:delete' path=urlpath.path article_id=article.id %}" class="pull-right btn btn-danger"> <span class="icon-trash"></span> {% trans "Delete article" %} </a> - + </div> - <div class="modal hide fade" id="previewModal"> + <section id="previewModal" class="modal" aria-hidden="true"> + <div class="inner-wrapper" role="dialog" aria-labelledby="preview-title"> + <button class="close-modal">✕ <span class="sr">{% trans 'Close Modal' %}</span></button> + + <header> + <h2 id="preview-title">{% trans "Wiki Preview" %}<span class="sr">, {% trans "modal open" %}</span></h2> + <hr/> + </header> + <div class="modal-body"> <iframe name="previewWindow" frameborder="0"></iframe> </div> @@ -34,16 +61,13 @@ {% trans "Save changes" %} </button> - <a href="#" class="btn btn-large" data-dismiss="modal"> + <a id="previewModalBackToEditor" href="#" class="btn btn-large"> <span class="icon-circle-arrow-left"></span> {% trans "Back to editor" %} </a> </div> - </div> + </section> {% include "wiki/includes/cheatsheet.html" %} </form> {% endblock %} - - - diff --git a/lms/templates/wiki/history.html b/lms/templates/wiki/history.html index 5488abb97db2c351ecc72dd6ef86b575277c34e8..798b030c0367e95043ba14566dd07ab1f46c0a76 100644 --- a/lms/templates/wiki/history.html +++ b/lms/templates/wiki/history.html @@ -10,7 +10,7 @@ <script type="text/javascript" src="{{ STATIC_URL }}wiki/js/diffview.js"></script> <script type="text/javascript"> - $( document ).ready(function() { + $(document).ready(function() { $('.accordion input[disabled!="disabled"][type="radio"]').first().attr('checked', 'true'); $('a.accordion-toggle').click(function(event) { @@ -41,6 +41,20 @@ } }); }); + + $(".previewRevisionButton").each(function () { + accessible_modal("#"+this.id, "#previewRevisionModal .close-modal", "#previewRevisionModal", ".content-wrapper"); + }); + $("#previewRevisionModalBackToHistory").click(function (e) { + $("#previewRevisionModal .close-modal").click(); + e.preventDefault(); + }); + + accessible_modal("#mergeButton", "#mergeModal .close-modal", "#mergeModal", ".content-wrapper"); + $("#mergeModalBackToHistory").click(function (e) { + $("#mergeModal .close-modal").click(); + e.preventDefault(); + }); }); </script> {% endaddtoblock %} @@ -81,7 +95,7 @@ {% trans "Click each revision to see a list of edited lines. Click the Preview button to see how the article looked at this stage. At the bottom of this page, you can change to a particular revision or merge an old revision with the current one." %} </p> -<form method="GET"> +<form method="GET" name="revisions_form"> <div class="tab-content" style="overflow: visible;"> {% for revision in revisions %} <div class="accordion" id="accordion{{ revision.revision_number }}"> @@ -107,16 +121,29 @@ </div> <div class="pull-right" style="vertical-align: middle; margin: 8px 3px;"> {% if not revision == article.current_revision %} - <button type="submit" class="btn" onclick="$('#previewModal').modal('show'); this.form.target='previewWindow'; this.form.r.value='{{ revision.id }}'; this.form.action='{% url 'wiki:preview_revision' article.id %}'; $('#previewModal .switch-to-revision').attr('href', '{% url 'wiki:change_revision' path=urlpath.path article_id=article.id revision_id=revision.id %}')"> - <span class="icon-eye-open"></span> - {% trans "Preview this revision" %} - </button> - {% endif %} - - {% if article|can_write:user %} - <input type="radio"{% if revision == article.current_revision %} disabled="true"{% endif %} style="margin: 0 10px;" value="{{ revision.id }}" name="revision_id" switch-button-href="{% url 'wiki:change_revision' path=urlpath.path revision_id=revision.id %}" merge-button-href="{% url 'wiki:merge_revision_preview' article_id=article.id revision_id=revision.id %}" merge-button-commit-href="{% url 'wiki:merge_revision' path=urlpath.path article_id=article.id revision_id=revision.id %}" /> + <a class="btn previewRevisionButton" + id="previewRevisionButton{{ revision.revision_number }}" + href="#previewRevisionModal" rel="leanModal" + onclick=" + document.revisions_form.target='previewWindow'; + document.revisions_form.r.value='{{ revision.id }}'; + document.revisions_form.action='{% url 'wiki:preview_revision' article.id %}'; + $('#previewRevisionModal .switch-to-revision').attr('href', '{% url 'wiki:change_revision' path=urlpath.path article_id=article.id revision_id=revision.id %}'); + document.revisions_form.submit();"> + <span class="icon-eye-open"></span> + {% trans "Preview this revision" %} + </a> + + {% if article|can_write:user %} + <input type="radio" style="margin: 0 10px;" value="{{ revision.id }}" name="revision_id" + switch-button-href="{% url 'wiki:change_revision' path=urlpath.path revision_id=revision.id %}" + merge-button-href="{% url 'wiki:merge_revision_preview' article_id=article.id revision_id=revision.id %}" + merge-button-commit-href="{% url 'wiki:merge_revision' path=urlpath.path article_id=article.id revision_id=revision.id %}" + /> + {% endif %} + {% endif %} - + </div> <div style="clear: both"></div> </div> @@ -140,84 +167,103 @@ </div> </div> {% endfor %} - + {% include "wiki/includes/pagination.html" %} - + {% if revisions.count > 1 %} <div class="form-actions"> <div class="pull-right"> {% if article|can_write:user %} - <button type="submit" name="preview" value="1" class="btn btn-large" onclick="$('#mergeModal').modal('show'); this.form.target='mergeWindow'; this.form.action=$('input[type=radio]:checked').attr('merge-button-href'); $('.merge-revision-commit').attr('href', $('input[type=radio]:checked').attr('merge-button-commit-href'))"> + <a class="btn btn-large" id="mergeButton" href="#mergeModal" rel="leanModal" + onclick=" + document.revisions_form.target='mergeWindow'; + document.revisions_form.action=$('input[type=radio]:checked').attr('merge-button-href'); + $('.merge-revision-commit').attr('href', $('input[type=radio]:checked').attr('merge-button-commit-href')); + document.revisions_form.submit();"> <span class="icon-random"></span> {% trans "Merge selected with current..." %} - </button> + </a> {% else %} <button type="submit" disabled="true" name="preview" value="1" class="btn btn-large"> <span class="icon-lock"></span> {% trans "Merge selected with current..." %} </button> {% endif %} - <button type="submit" name="save" value="1" class="btn btn-large btn-primary" onclick="$('#previewModal').modal('show'); this.form.target='previewWindow'; this.form.action=$('input[type=radio]:checked').attr('switch-button-href')"> + <button type="submit" name="save" value="1" class="btn btn-large btn-primary" onclick="this.form.action=$('input[type=radio]:checked').attr('switch-button-href')"> <span class="icon-flag"></span> {% trans "Switch to selected version" %} </button> </div> </div> - {% endif %} - + </div> <input type="hidden" name="r" value="" /> - <div class="modal hide fade" id="previewModal"> - <div class="modal-body"> - <iframe name="previewWindow"></iframe> - </div> - <div class="modal-footer"> - <a href="#" class="btn btn-large" data-dismiss="modal"> + <section id="previewRevisionModal" class="modal" aria-hidden="true"> + <div class="inner-wrapper" role="dialog" aria-labelledby="preview-title"> + <button class="close-modal">✕ <span class="sr">{% trans 'Close Modal' %}</span></button> + + <header> + <h2 id="preview-title">{% trans "Wiki Revision Preview" %}<span class="sr">, {% trans "modal open" %}</span></h2> + <hr/> + </header> + <div class="modal-body"> + <iframe name="previewWindow"></iframe> + </div> + <div class="modal-footer"> + <a id="previewRevisionModalBackToHistory" href="#" class="btn btn-large" data-dismiss="modal"> <span class="icon-circle-arrow-left"></span> {% trans "Back to history view" %} </a> {% if article|can_write:user %} - <a href="#" class="btn btn-large btn-primary switch-to-revision"> - <span class="icon-flag"></span> - {% trans "Switch to this version" %} - </a> + <a href="#" class="btn btn-large btn-primary switch-to-revision"> + <span class="icon-flag"></span> + {% trans "Switch to this version" %} + </a> {% else %} <a href="#" class="btn btn-large btn-primary disabled"> <span class="icon-lock"></span> {% trans "Switch to this version" %} - </a> + </a> {% endif %} + </div> </div> - </div> + </section> - <div class="modal hide fade" id="mergeModal"> - <div class="modal-header"> - <h1>{% trans "Merge with current" %}</h1> - <p class="lead"><span class="icon-info-sign"></span> {% trans "When you merge a revision with the current, all data will be retained from both versions and merged at its approximate location from each revision." %} <strong>{% trans "After this, it's important to do a manual review." %}</strong></p> - </div> - <div class="modal-body"> - <iframe name="mergeWindow"></iframe> - </div> - <div class="modal-footer"> - <a href="#" class="btn btn-large" data-dismiss="modal"> - <span class="icon-circle-arrow-left"></span> - {% trans "Back to history view" %} - </a> - {% if article|can_write:user %} - <a href="#" class="btn btn-large btn-primary merge-revision-commit"> - <span class="icon-file"></span> - {% trans "Create new merged version" %} - </a> - {% else %} - <a href="#" class="btn btn-large btn-primary disabled"> - <span class="icon-lock"></span> + <section id="mergeModal" class="modal" aria-hidden="true"> + <div class="inner-wrapper" role="dialog" aria-labelledby="merge-title"> + <button class="close-modal">✕ <span class="sr">{% trans 'Close Modal' %}</span></button> + + <header> + <h2 id="merge-title">{% trans "Merge Revision" %}<span class="sr">, {% trans "modal open" %}</span></h2> + <hr/> + </header> + <div class="modal-header"> + <h1>{% trans "Merge with current" %}</h1> + <p class="lead"><span class="icon-info-sign"></span> {% trans "When you merge a revision with the current, all data will be retained from both versions and merged at its approximate location from each revision." %} <strong>{% trans "After this, it's important to do a manual review." %}</strong></p> + </div> + <div class="modal-body"> + <iframe name="mergeWindow"></iframe> + </div> + <div class="modal-footer"> + <a id="mergeModalBackToHistory" href="#" class="btn btn-large" data-dismiss="modal"> + <span class="icon-circle-arrow-left"></span> + {% trans "Back to history view" %} + </a> + {% if article|can_write:user %} + <a href="#" class="btn btn-large btn-primary merge-revision-commit"> + <span class="icon-file"></span> {% trans "Create new merged version" %} - </a> - {% endif %} + </a> + {% else %} + <a href="#" class="btn btn-large btn-primary disabled"> + <span class="icon-lock"></span> + {% trans "Create new merged version" %} + </a> + {% endif %} + </div> </div> </div> </form> - -{% endblock %} +{% endblock %} diff --git a/lms/templates/wiki/includes/cheatsheet.html b/lms/templates/wiki/includes/cheatsheet.html index 4214a858a8b25e906e73ebaff18372d04811eb80..6c3f359ef7607270ed32ee00b27c19e0cf61c169 100644 --- a/lms/templates/wiki/includes/cheatsheet.html +++ b/lms/templates/wiki/includes/cheatsheet.html @@ -59,7 +59,7 @@ 1. {% trans "Ordered" %} 2. {% trans "List" %}</pre> <pre> -> {% trans "Quotes" %}</pre> +> {% trans "Quotes" %}</pre> </section> </div> diff --git a/lms/urls.py b/lms/urls.py index 6de00a741103c9b2d69355ffea27df0ccd672d69..913c686f1beac77d509c99ba3b15fa0e0f6ff079 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -41,9 +41,6 @@ urlpatterns = ('', # nopep8 url(r'^create_account$', 'student.views.create_account', name='create_account'), url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name="activate"), - url(r'^begin_exam_registration/(?P<course_id>[^/]+/[^/]+/[^/]+)$', 'student.views.begin_exam_registration', name="begin_exam_registration"), - url(r'^create_exam_registration$', 'student.views.create_exam_registration'), - url(r'^password_reset/$', 'student.views.password_reset', name='password_reset'), ## Obsolete Django views for password resets ## TODO: Replace with Mako-ized views @@ -404,9 +401,6 @@ if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): url(r'^openid/provider/xrds/$', 'external_auth.views.provider_xrds', name='openid-provider-xrds') ) -if settings.MITX_FEATURES.get('ENABLE_PEARSON_LOGIN', False): - urlpatterns += url(r'^testcenter/login$', 'external_auth.views.test_center_login'), - if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += ( url(r'^migrate/modules$', 'lms_migration.migrate.manage_modulestores'), diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a0199378e7f0c4df6486fd1e86b24c80e2f451b6..3bcfe9abe0ea0205b4ae141f24d2853b37c51a9c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -70,6 +70,7 @@ South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 django-ratelimit-backend==0.6 +unicodecsv==0.9.4 # Used for debugging ipython==0.13.1 @@ -89,7 +90,7 @@ Babel==1.3 transifex-client==0.9.1 # Used for testing -coverage==3.6 +coverage==3.7 ddt==0.4.0 factory_boy==2.0.2 mock==1.0.1