From 621777e6ae877a2a4914ad8492fdc969935b20ec Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Wed, 26 Jun 2013 16:01:31 -0400 Subject: [PATCH 01/96] Explicitly test the creation and deletion of every component type The testing of all settings/text editing have been done elsewhere and should be done when testing a new component --- .../contentstore/features/component.feature | 71 ++++++++++ .../contentstore/features/component.py | 126 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 cms/djangoapps/contentstore/features/component.feature create mode 100644 cms/djangoapps/contentstore/features/component.py diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature new file mode 100644 index 0000000000..b3d446fc3b --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.feature @@ -0,0 +1,71 @@ +Feature: Component Adding + As a course author, I want to be able to add a wide variety of components + + Scenario: I can add components + Given I have opened a new course in studio + And I am on a new unit + When I add the following components: + | Component | + | Discussion | + | Announcement | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + Then I see the following components: + | Component | + | Discussion | + | Announcement | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + + + Scenario: I can delete Components + Given I have opened a new course in studio + And I am on a new unit + And I add the following components: + | Component | + | Discussion | + | Announcement | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + When I will confirm all alerts + And I delete all components + Then I see no components diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py new file mode 100644 index 0000000000..7366adf07c --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.py @@ -0,0 +1,126 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step + +data_location = 'i4x://edx/templates' + + +@step(u'I am on a new unit') +def add_unit(step): + section_css = 'a.new-courseware-section-button' + world.css_click(section_css) + save_section_css = 'input.new-section-name-save' + world.css_click(save_section_css) + subsection_css = 'a.new-subsection-item' + world.css_click(subsection_css) + save_subsection_css = 'input.new-subsection-name-save' + world.css_click(save_subsection_css) + expand_css = 'div.section-item a.expand-collapse-icon' + world.css_click(expand_css) + unit_css = 'a.new-unit-item' + world.css_click(unit_css) + + +@step(u'I add the following components:') +def add_components(step): + for component in step.hashes: + #due to the way lettuce stores the dictionary + component = component['Component'] + #from pdb import set_trace; set_trace() + assert component in component_dictionary + how_to_add = component_dictionary[component]['steps'] + for css in how_to_add: + world.css_click(css) + + +@step(u'I see the following components') +def check_components(step): + for component in step.hashes: + component = component['Component'] + assert component in component_dictionary + assert component_dictionary[component]['found']() + + +@step(u'I delete all components') +def delete_all_components(step): + components_num = len(component_dictionary) + for delete in range(0, components_num): + world.css_click('a.delete-button') + + +@step(u'I see no components') +def see_no_components(steps): + assert world.is_css_not_present('li.component') + + +component_dictionary = { + 'Discussion': { + 'steps': ['a[data-type="discussion"]'], + 'found': lambda: world.is_css_present('section.xmodule_DiscussionModule', wait_time=2) + }, + 'Announcement': { + 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Announcement"]' % data_location], + 'found': lambda: world.browser.is_text_present('Heading of document') + }, + 'Blank HTML': { + 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Blank_HTML_Page"]' % data_location], + 'found': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] + }, + 'LaTex': { + 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/E-text_Written_in_LaTeX"]' % data_location], + 'found': lambda: world.browser.is_text_present('EXAMPLE: E-TEXT PAGE', wait_time=2) + }, + 'Blank Problem': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Blank_Common_Problem"]' % data_location], + 'found': lambda: world.browser.is_text_present('BLANK COMMON PROBLEM', wait_time=2) + }, + 'Dropdown': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Dropdown"]' % data_location], + 'found': lambda: world.browser.is_text_present('DROPDOWN', wait_time=2) + }, + 'Multi Choice': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Multiple_Choice"]' % data_location], + 'found': lambda: world.browser.is_text_present('MULTIPLE CHOICE', wait_time=2) + }, + 'Numerical': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Numerical_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('NUMERICAL INPUT', wait_time=2) + }, + 'Text Input': { + 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Text_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('TEXT INPUT', wait_time=2) + }, + 'Advanced': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Blank_Advanced_Problem"]' % data_location], + 'found': lambda: world.browser.is_text_present('BLANK ADVANCED PROBLEM', wait_time=2) + }, + 'Circuit': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Circuit_Schematic_Builder"]' % data_location], + 'found': lambda: world.browser.is_text_present('CIRCUIT SCHEMATIC BUILDER', wait_time=2) + }, + 'Custom Python': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Custom_Python-Evaluated_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('CUSTOM PYTHON-EVALUATED INPUT', wait_time=2) + }, + 'Image Mapped': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Image_Mapped_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('IMAGE MAPPED INPUT', wait_time=2) + }, + 'Math Input': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Math_Expression_Input"]' % data_location], + 'found': lambda: world.browser.is_text_present('MATH EXPRESSION INPUT', wait_time=2) + }, + 'Problem LaTex': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_Written_in_LaTeX"]' % data_location], + 'found': lambda: world.browser.is_text_present('PROBLEM WRITTEN IN LATEX', wait_time=2) + }, + 'Adaptive Hint': { + 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_with_Adaptive_Hint"]' % data_location], + 'found': lambda: world.browser.is_text_present('PROBLEM WITH ADAPTIVE HINT', wait_time=2) + }, + 'Video': { + 'steps': ['a[data-type="video"]'], + 'found': lambda: world.is_css_present('section.xmodule_VideoModule', wait_time=2) + } +} From ea8e5f84acae87ee15eb7796e6d6be34b2902c3d Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Thu, 27 Jun 2013 10:14:43 -0400 Subject: [PATCH 02/96] Cleaned up code --- .../contentstore/features/component.feature | 4 +- .../contentstore/features/component.py | 126 +++++++++--------- 2 files changed, 66 insertions(+), 64 deletions(-) diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature index b3d446fc3b..6a18dfa7ab 100644 --- a/cms/djangoapps/contentstore/features/component.feature +++ b/cms/djangoapps/contentstore/features/component.feature @@ -3,7 +3,7 @@ Feature: Component Adding Scenario: I can add components Given I have opened a new course in studio - And I am on a new unit + And I am editing a new unit When I add the following components: | Component | | Discussion | @@ -46,7 +46,7 @@ Feature: Component Adding Scenario: I can delete Components Given I have opened a new course in studio - And I am on a new unit + And I am editing a new unit And I add the following components: | Component | | Discussion | diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index 7366adf07c..b3775a6cbe 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -3,49 +3,34 @@ from lettuce import world, step -data_location = 'i4x://edx/templates' +DATA_LOCATION = 'i4x://edx/templates' -@step(u'I am on a new unit') +@step(u'I am editing a new unit') def add_unit(step): - section_css = 'a.new-courseware-section-button' - world.css_click(section_css) - save_section_css = 'input.new-section-name-save' - world.css_click(save_section_css) - subsection_css = 'a.new-subsection-item' - world.css_click(subsection_css) - save_subsection_css = 'input.new-subsection-name-save' - world.css_click(save_subsection_css) - expand_css = 'div.section-item a.expand-collapse-icon' - world.css_click(expand_css) - unit_css = 'a.new-unit-item' - world.css_click(unit_css) + css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] + for selector in css_selectors: + world.css_click(selector) @step(u'I add the following components:') def add_components(step): - for component in step.hashes: - #due to the way lettuce stores the dictionary - component = component['Component'] - #from pdb import set_trace; set_trace() - assert component in component_dictionary - how_to_add = component_dictionary[component]['steps'] - for css in how_to_add: + for component in [step_hash['Component'] for step_hash in step.hashes]: + assert component in COMPONENT_DICTIONARY + for css in COMPONENT_DICTIONARY[component]['steps']: world.css_click(css) @step(u'I see the following components') def check_components(step): - for component in step.hashes: - component = component['Component'] - assert component in component_dictionary - assert component_dictionary[component]['found']() + for component in [step_hash['Component'] for step_hash in step.hashes]: + assert component in COMPONENT_DICTIONARY + assert COMPONENT_DICTIONARY[component]['found_func']() @step(u'I delete all components') def delete_all_components(step): - components_num = len(component_dictionary) - for delete in range(0, components_num): + for _ in range(len(COMPONENT_DICTIONARY)): world.css_click('a.delete-button') @@ -54,73 +39,90 @@ def see_no_components(steps): assert world.is_css_not_present('li.component') -component_dictionary = { +def step_selector_list(data_type, path, index=1): + selector_list = ['a[data-type="{}"]'.format(data_type)] + if index != 1: + selector_list.append('a[id="ui-id-{}"]'.format(index)) + if path is not None: + selector_list.append('a[data-location="{}/{}/{}"]'.format(DATA_LOCATION, data_type, path)) + return selector_list + + +def found_text_func(text): + return lambda: world.browser.is_text_present(text) + + +def found_css_func(css): + return lambda: world.is_css_present(css, wait_time=2) + +COMPONENT_DICTIONARY = { 'Discussion': { - 'steps': ['a[data-type="discussion"]'], - 'found': lambda: world.is_css_present('section.xmodule_DiscussionModule', wait_time=2) + 'steps': step_selector_list('discussion', None), + 'found_func': found_css_func('section.xmodule_DiscussionModule') }, 'Announcement': { - 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Announcement"]' % data_location], - 'found': lambda: world.browser.is_text_present('Heading of document') + 'steps': step_selector_list('html', 'Announcement'), + 'found_func': found_text_func('Heading of document') }, 'Blank HTML': { - 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/Blank_HTML_Page"]' % data_location], - 'found': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] + 'steps': step_selector_list('html', 'Blank_HTML_Page'), + #this one is a blank html so a more refined search is being done + 'found_func': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] }, 'LaTex': { - 'steps': ['a[data-type="html"]', 'a[data-location="%s/html/E-text_Written_in_LaTeX"]' % data_location], - 'found': lambda: world.browser.is_text_present('EXAMPLE: E-TEXT PAGE', wait_time=2) + 'steps': step_selector_list('html', 'E-text_Written_in_LaTeX'), + 'found_func': found_text_func('EXAMPLE: E-TEXT PAGE') }, 'Blank Problem': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Blank_Common_Problem"]' % data_location], - 'found': lambda: world.browser.is_text_present('BLANK COMMON PROBLEM', wait_time=2) + 'steps': step_selector_list('problem', 'Blank_Common_Problem'), + 'found_func': found_text_func('BLANK COMMON PROBLEM') }, 'Dropdown': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Dropdown"]' % data_location], - 'found': lambda: world.browser.is_text_present('DROPDOWN', wait_time=2) + 'steps': step_selector_list('problem', 'Dropdown'), + 'found_func': found_text_func('DROPDOWN') }, 'Multi Choice': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Multiple_Choice"]' % data_location], - 'found': lambda: world.browser.is_text_present('MULTIPLE CHOICE', wait_time=2) + 'steps': step_selector_list('problem', 'Multiple_Choice'), + 'found_func': found_text_func('MULTIPLE CHOICE') }, 'Numerical': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Numerical_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('NUMERICAL INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Numerical_Input'), + 'found_func': found_text_func('NUMERICAL INPUT') }, 'Text Input': { - 'steps': ['a[data-type="problem"]', 'a[data-location="%s/problem/Text_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('TEXT INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Text_Input'), + 'found_func': found_text_func('TEXT INPUT') }, 'Advanced': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Blank_Advanced_Problem"]' % data_location], - 'found': lambda: world.browser.is_text_present('BLANK ADVANCED PROBLEM', wait_time=2) + 'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2), + 'found_func': found_text_func('BLANK ADVANCED PROBLEM') }, 'Circuit': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Circuit_Schematic_Builder"]' % data_location], - 'found': lambda: world.browser.is_text_present('CIRCUIT SCHEMATIC BUILDER', wait_time=2) + 'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2), + 'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER') }, 'Custom Python': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Custom_Python-Evaluated_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('CUSTOM PYTHON-EVALUATED INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2), + 'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT') }, 'Image Mapped': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Image_Mapped_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('IMAGE MAPPED INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2), + 'found_func': found_text_func('IMAGE MAPPED INPUT') }, 'Math Input': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Math_Expression_Input"]' % data_location], - 'found': lambda: world.browser.is_text_present('MATH EXPRESSION INPUT', wait_time=2) + 'steps': step_selector_list('problem', 'Math_Expression_Input', index=2), + 'found_func': found_text_func('MATH EXPRESSION INPUT') }, 'Problem LaTex': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_Written_in_LaTeX"]' % data_location], - 'found': lambda: world.browser.is_text_present('PROBLEM WRITTEN IN LATEX', wait_time=2) + 'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2), + 'found_func': found_text_func('PROBLEM WRITTEN IN LATEX') }, 'Adaptive Hint': { - 'steps': ['a[data-type="problem"]', 'a[id="ui-id-2"]', 'a[data-location="%s/problem/Problem_with_Adaptive_Hint"]' % data_location], - 'found': lambda: world.browser.is_text_present('PROBLEM WITH ADAPTIVE HINT', wait_time=2) + 'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2), + 'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT') }, 'Video': { - 'steps': ['a[data-type="video"]'], - 'found': lambda: world.is_css_present('section.xmodule_VideoModule', wait_time=2) + 'steps': step_selector_list('video', None), + 'found_func': found_css_func('section.xmodule_VideoModule') } } From 1a5bda4d08d76c80194b7143e174f2f042944544 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Thu, 27 Jun 2013 16:35:01 -0400 Subject: [PATCH 03/96] Broke up long line into smaller line --- cms/djangoapps/contentstore/features/component.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index b3775a6cbe..64f088f056 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -8,7 +8,8 @@ DATA_LOCATION = 'i4x://edx/templates' @step(u'I am editing a new unit') def add_unit(step): - css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] + css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', + 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] for selector in css_selectors: world.css_click(selector) From 7aa76bc66325902881a0f4e362f555a77aeaa7ab Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 28 Jun 2013 16:51:03 -0400 Subject: [PATCH 04/96] Add some documentation around XModule/XModuleDescriptor initialization. --- README.md | 6 ++++++ doc/overview.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 92a4116354..4dbf069da3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ otherwise noted. Please see ``LICENSE.txt`` for details. +Documentation +------------ + +High-level documentation of the code is located in the `doc` subdirectory. Start +with `overview.md` to get an introduction to the architecture of the system. + How to Contribute ----------------- diff --git a/doc/overview.md b/doc/overview.md index 31ddd011ff..c38c61b43e 100644 --- a/doc/overview.md +++ b/doc/overview.md @@ -64,6 +64,12 @@ You should be familiar with the following. If you're not, go read some docs... from a Location object, and the ModuleSystem knows how to render things, track events, and complain about 404s + - XModules and XModuleDescriptors are uniquely identified by a Location object, encoding the organization, course, category, name, and possibly revision of the module. + + - XModule initialization: XModules are instantiated by the `XModuleDescriptor.xmodule` method, and given a ModuleSystem, the descriptor which instantiated it, and their relevant model data. + + - XModuleDescriptor initialization: If an XModuleDescriptor is loaded from an XML-based course, the XML data is passed into its `from_xml` method, which is responsible for instantiating a descriptor with the correct attributes. If it's in Mongo, the descriptor is instantiated directly. The module's attributes will be present in the `model_data` dict. + - `course.xml` format. We use python setuptools to connect supported tags with the descriptors that handle them. See `common/lib/xmodule/setup.py`. There are checking and validation tools in `common/validate`. - the xml import+export functionality is in `xml_module.py:XmlDescriptor`, which is a mixin class that's used by the actual descriptor classes. From 5cb4c1a7506b6e2e7c69c7e5e863138a8a8f42ed Mon Sep 17 00:00:00 2001 From: Jean Manuel Nater Date: Mon, 1 Jul 2013 17:06:32 -0400 Subject: [PATCH 05/96] Removed some pep8 violations. --- common/djangoapps/course_groups/tests/tests.py | 13 ++----------- lms/djangoapps/courseware/tests/test_views.py | 5 ++--- lms/djangoapps/instructor/tests/test_enrollment.py | 2 -- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 0dc8ae5aae..2e519edb30 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -23,7 +23,6 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCohorts(django.test.TestCase): - @staticmethod def topic_name_to_id(course, name): """ @@ -34,7 +33,6 @@ class TestCohorts(django.test.TestCase): run=course.url_name, name=name) - @staticmethod def config_course_cohorts(course, discussions, cohorted, @@ -80,7 +78,6 @@ class TestCohorts(django.test.TestCase): course.cohort_config = d - def setUp(self): """ Make sure that course is reloaded every time--clear out the modulestore. @@ -89,7 +86,6 @@ class TestCohorts(django.test.TestCase): # to course. We don't have a course.clone() method. _MODULESTORES.clear() - def test_get_cohort(self): """ Make sure get_cohort() does the right thing when the course is cohorted @@ -105,7 +101,7 @@ class TestCohorts(django.test.TestCase): cohort = CourseUserGroup.objects.create(name="TestCohort", course_id=course.id, - group_type=CourseUserGroup.COHORT) + group_type=CourseUserGroup.COHORT) cohort.users.add(user) @@ -135,7 +131,7 @@ class TestCohorts(django.test.TestCase): cohort = CourseUserGroup.objects.create(name="TestCohort", course_id=course.id, - group_type=CourseUserGroup.COHORT) + group_type=CourseUserGroup.COHORT) # user1 manually added to a cohort cohort.users.add(user1) @@ -169,7 +165,6 @@ class TestCohorts(django.test.TestCase): self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup", "user2 should still be in originally placed cohort") - def test_auto_cohorting_randomization(self): """ Make sure get_cohort() randomizes properly. @@ -199,8 +194,6 @@ class TestCohorts(django.test.TestCase): self.assertGreater(num_users, 1) self.assertLess(num_users, 50) - - def test_get_course_cohorts(self): course1_id = 'a/b/c' course2_id = 'e/f/g' @@ -214,14 +207,12 @@ class TestCohorts(django.test.TestCase): course_id=course1_id, group_type=CourseUserGroup.COHORT) - # second course should have no cohorts self.assertEqual(get_course_cohorts(course2_id), []) cohorts = sorted([c.name for c in get_course_cohorts(course1_id)]) self.assertEqual(cohorts, ['TestCohort', 'TestCohort2']) - def test_is_commentable_cohorted(self): course = modulestore().get_course("edX/toy/2012_Fall") self.assertFalse(course.is_cohorted) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index b4689eae6a..37b81aa96f 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -3,7 +3,6 @@ import datetime from django.test import TestCase from django.http import Http404 -from django.conf import settings from django.test.utils import override_settings from django.contrib.auth.models import User from django.test.client import RequestFactory @@ -52,8 +51,8 @@ class ViewsTestCase(TestCase): self.date = datetime.datetime(2013, 1, 22, tzinfo=UTC) self.course_id = 'edX/toy/2012_Fall' self.enrollment = CourseEnrollment.objects.get_or_create(user=self.user, - course_id=self.course_id, - created=self.date)[0] + course_id=self.course_id, + created=self.date)[0] self.location = ['tag', 'org', 'course', 'category', 'name'] self._MODULESTORES = {} # This is a CourseDescriptor object diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index b3455511b5..f84106a52a 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -6,10 +6,8 @@ Unit tests for enrollment methods in views.py from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse -from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase From f98f500c6ffe3509a5d94943bf9a7c5c5b4d0ba1 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 26 Jun 2013 15:22:06 -0400 Subject: [PATCH 06/96] studio - sets initial studio nav revisions (WIP) --- cms/static/img/logo-edx-studio.png | Bin 2461 -> 4782 bytes cms/static/img/old-edx-studio-logo-small.png | Bin 0 -> 3689 bytes cms/static/img/old-logo-edx-studio.png | Bin 0 -> 2461 bytes cms/static/sass/elements/_header.scss | 650 ++++--------------- cms/static/sass/elements/_navigation.scss | 26 +- cms/templates/widgets/header.html | 232 ++++--- 6 files changed, 296 insertions(+), 612 deletions(-) create mode 100644 cms/static/img/old-edx-studio-logo-small.png create mode 100644 cms/static/img/old-logo-edx-studio.png diff --git a/cms/static/img/logo-edx-studio.png b/cms/static/img/logo-edx-studio.png index 006194a195d546fb40564b6bb07cf926c25cff7a..bad6af1697bc66473c74717cdcfa3f20d0af6723 100644 GIT binary patch literal 4782 zcmV;f5>f4mP))p&kU*S*zNP?^7rNO`7^cky35ny?ein3 z<$X0mwv(a0A!x|5s-}&DRehI=iHxRdcLI>oaE6qI4>O+K_o2qwipSu*D{Lv4&5_*y zQKrpuzTqvQ+C#PL*5>Qw^!xPr`yRIZ8=~Z#%jcocT%2R_4oJk`THKP^&X?+(dP2C*5_Ec<(m&gz9w#}5L2Wi zR>3H7n~;oQf|UTL?DymBzv%ko_58Ww^|rXW=d;TltLXIf^<}cvlhg2gLrnmf=UT+^ z-Hx*O>hTz$;Nzmd0F2(|_WXj8k+8P51&PM_`S}H)=KApUz&3RI_4q`(?h~fyP`BvB z=l2_=;-uE@`1}22$n+be+9^G*YbYI?Hr=wg==z?m6x{Q^vLM*3V{vD*^X~F95$=b$MwTwI5@-C~5ru|Ble_i;a-c zYnq7D^5gUSmDBHX8YZp=Qa7#S-tziFvgWoTWw8rZTDs_W9xb*dXW;VtvlLvnC1(JV z+yI^88=~Mew(S9FqY<9pfP#gHij4r2;Q*N83!C0Bvg`nxYx?jEDz9H!>f)z+HT@yqG=kB*O}rl~He z<6*w(g3Ij}px_#y;H25}A*A8y_WY^a^LfbaAEV*2tEwoc;>GLw@cI2#x#)t0h#ai& z9HQXk9_Zd{MM)ZN|PUDF0mXd4O(WkUYHn?PBK%R-yd;sK-H~|3UfQ%+U_|!29 zj1DY-)K6?q9soHf$p`=-Thwm?^5|OTo12*bUZ!xo;Hy86ah3ePfO%Y)Y3qzq;y^ZWO??+G|fsyZlj} z{SbkiI<}LDM)y-Z7HAtFN==$p5Cm&?APtw(lRGA)zCr390GY9@?-L?~^93RtQH&(w|G$G&|Lh^<&RD6vblK(%Am!)h ze?i`;HxsC)t~8Ek_BpdIU8eg?J8h?Xr~7JmYh9|fTC4R`YwK1QL}e2N$}$KjOBS=E zk{AN`kk^?k)8J4wkfRb)rsc$=NHl?gKp3PUWLhe8Uf#Vgd3pD}kcE?;`Rl>+&VA8y z^7Fg@@B8j|yU*&pUi+o59zI3@c^?94Z!sAuQ(s~XZIc_&&!Px;;Y zMT@rGv8YZ>vmDEidx%}`N(jK`4m=^)yWGS(pnXPr56Wk?SW`y(s3r7+L+mhIC#pQ> z&*zHk)WlQrfb7~^Qd07$T{-)@>t;V}f4gO0K^8z0~QO&O=5 zN6TaLi@)Uom9-6u)o?*`m`evH;rHnwAH_@_g@Jc2x~2KV0?U| zCaFO7>Y53OuD#q2h_oA0D?+O~3~;|Pjz_4_UC~zPRdOCvgBB7LH;{`Ez@DdYdzJ=7 z=d}IU&;(+{up`K0n*romEk`y_#~FGZ50z%B2o$3!Mp^4EL$!} z$1qHbVUqOfam5Io<;ceoEA)4w%Uq7a zt$9=DVE<&{yVU>x@Ccz|s3Nfl*)%XhsEkJBL$_s0Ab;XP$vX>jawadfQqPNhi2zwv zny%GKYJnsw)#{}|9BG&QBD)^Q?ic0nwRH!9*w#WKCgV<$$DwbHFlN?)bT=_W$9n;f zel@V;iQFtXPYgcm)&x}mB0&uwC)9BPakyQ>u3@#Nt~V=4q;d)%c~o$X6hYf?iMK)` z;sk&!zg2}bG=@}*6=(2)f#;v~Hq)fn`JF5EK_KQ&Ara&Ifi$b^X=oru(`DOqROshO z9txWOt9xAtw;U44MCh%!fG8}%szmPNNAD zS!;WYGIYZ;#7g27x=#(VDkK4A2ZB!<5bMd&wZNGUUFeGdWEj}iDNA^8K0hB2QO5=3 z2|;lKgitFbEL{qpWK!)7EA-S(W%qgsBSC=B(<;-)?NrjWcA8r56CkVKkW3&@J`ayn zk0k;S_|Df%M|1&D3OpVl)dUX^5?ZV=BV)A`)7FweX5C!=(VhSRrCsDzo;h=-S*Oz@ zKthGbX=4*COP+4!=239u?@_lDEg(WJ;z$f1G4Py3-QDN*r zvQ<(L9$IO2_S_HFEZdSPzyg*s>(!c?`s?=)2t>^*yLYWx_2#Y;r%tCtfK;RcB2ewX z-#OG4)0Dd#B)TW??*IrcCV||OYT*KcLrX`~Df}gn32R~p@=XyrZaTkBnf0d^Bn-O43`00&*K78 z2}>b0jsO_~)q52lGmxb!a(|MlO6d!8GgPyFQd3VLIle-aMqhdAj2{64jXhK&a;qn{ zDR&Dj)LT8t2V%uR6Rn8bLp;Fu*+L$CRuQv@tXYkLB)1d^9D+tY5h8ZTgVMA4A|(@u zZJQCKyP%80Ek(>{=!H}_L_8$CFS$S}9k6r82?Qj)1#CG-KPXRP2J!}J;4-a%xxk+p z;6XrfRxnn^ymkf zj~Pg{irlayTbK*-8_G!_@G_9WR2G3ro;f=8{W)iPhA1XJBN4Rk|Bl$}g`80J(WNdPrs0Gl6^@%CijXUgzL( zz#Y$&d&-?J@$NV++J^ks1x1rA_##%k(YYNY`n5j#6U9Yww*nR*e(l!Tmh z$VL*#105`;UKF3HXOl@5DhPxSCfJPv@zR03zRu7N&un3!n2c8vPJ2|(45=K$Kbt`7 zBu;ezD;XJ%5=bxW)XC8S5=a*cgsrp5o8ScUJZPQB(aaC;f~}KSfy|Xj0tSMW^7n;{ z|NW1EuHJ&?%4cX1GHz_5S@}2M3DoO}HWyJ{a6JXu-O+gnE@sVwTx3h|uoaIC1P&41 zJ;VtFKRUwET(o=%&I~m%7lvdV(x_z#1Lul8gzPKP9Cl0>szb6&rm$ppv!My?Xrb-* zxCM?@d!hu=!`i=?pt9M>K)MD%<%6R@7=_5?j+0ha`*|THW+0_1(n7TRkV-wxuOzfW z^aX1f0)ZTU91g`9*1Zc$MB3ED^xp}AHeJOIgyVec_3&vPJQwKt}HX z@7zAh48k&y58^Qc*@p#8M24+i!(90u86kUg`KuTU-6A~&M4pPwG?QD#|PhtUPxatg6}6o|zF4+FOgt=vF5Zkquj;agtEyB6QV9UMFM2vU@?dSM(qG+mJ_-AvDu)j;cxw3tOk*8iEj~KUjYO49Fqa-U(qf`k59s(F zzw;^jeFQyqLZ!R*u_ctD(}xsqF;K?7HsLWKMZcQJV#VEN_}96RPA{)@7>@&pEkh*(L~hNbwyiCtT1+OJMN}c=p}If6 zgxa=tEr4gZfnn<({BX`^pUok{!5H2v8UHC|?9U`RC^yNoZ9l)BsuD`0;h`u$QBHf zHIP1{T3wpW=kHryEmKKk4S_S<@~bW>iFnY7&MfF28~dVUrP6OFc_?q1ebem!-uTEP zkKFjr_NK>o&da(2db}f7u4EOany7K>{aIJ$RU|M<3XFB&;-EvJ$SH&)qjy~pMh(Pu zfcnC=_aAgj0{Z}&DgvbJ?ty`Ukxlg8^3=oDqruVMigS|=$6#obRDs6_#Fj}-Wi? zscoRKMh3>QwE5;|y9M=)LqS~?${c1;~ z_B*Q2??Q0MVTKhM$fPEk>j-@tM#Up2j#dwDjSC1QB9Wn!i&|`-EzM7)REYHC9m61y zm@j?83p-I5yT{Dr;kXm$;?~1n-QRXUBS;EhGbGZ5oi@jW6_uI9$!y%&yc{#GeP+3I{zWhFGKR9InT~PffWzb)7S?ORt}nr*&o7DKn2t-I*#g4NF^RNMD#5fWiGT0Y}&FyAZk+nr;!?&EYIbK(L5hpv+*UmjV1{~uk8-pyYsNUz0;vBuScrTp;X zJk6P}^q%!9^PPLgi08Dk{{OXiwkiulaU8aK6?zjkFT!My^9ETJ7A>o?a;n8}E@23Q zykQ#rL=ej?E-3hcpZEbYC@9K72ZN;r_lNzS{BKVCxg5JHKy5g{W|2z93B!Hfut7hALuwS5O$ssI2007*qo IM6N<$f=CrXDF6Tf literal 2461 zcma)7dpK0x9^NjwjC>dpVj7x}M4a4(lItK%BzKKta*vREBE&E=W{e5rk}x8Cd?gGs zlf>sv;S(xyJt7h!Q8~La=W)(+{yO_v``zz)*ZceZ*7`l`d3GAw)^61lR#aD z+ynJ1g_XPmZ5b!6SjYSuR06@MGzz8cm}9KDZGm?p6A{3I`&OESfqFbrFDZ=#5(d(@ z`h1vX_McwVsreKsrAi_xZpP;-*}f}>CBMars3 z2bSh2SV6CsCx=E5Gsx*U{I9DU{3t?3S(@C-LI!|$wHe_D_CE}!Ovt(??Emn1NU_|5 zH(~*0P(OM)ksNvS6sJqz(R^x965!U`Z`#?~d#r%Z_=nb&GYh|-?KyN&@Yc@Rbt=Mc z>xV8RX4$i2Fs!0@=S&nWB(B90eHWskm+G!SaJ{67Xg-`%z8HSBOXVGn<@stf`R7{g z&uM$Pdc(*HZ$0y%4HvB8oR)avCW1G(F3h3Mt}RZHVlB(s?v3Z0lsIM0`>^Dj=&00T zCBCh0;83Q<`_OA(LBiiw@$hHk#UjN`He0&P-%5HI@TCr@L~jXZGUMnIcA8 zEi|3rNhpt)Q+BiI?038xYpSF^ITDzfs8$sMgBr=2Kc1K`VvD1XI>f-RQQ=c`t>1^VG7UXL~?Yq@1XXUCQr z&ipZAv6kSRys8T;Nl`GT6b!b6U-I-Ee6FWKxONJbr?2f7T2&7=+H&F5 zzc}?V*;v$&Y}bM{k8xw9Fuojo)fHSL^R#P~E+USRf58#~E-DasFJ)uP^pX55Xajm% zVD8$ZE1@`qLAtuobd$$TyV8@79yB9%Q&w1;&rSTVr}vx6Y7*%_#>ukyk~zR6s9)yv z&-IUYtd_OTgwfsY+!Aa;7tCUOzhpDr6|@61ntPW<++8G7+AOwyu)>CeE8nQaci~Oe zcb$>bMCcOZmQ#DGdGIXVtFzmlYq;jepm6bZ*V`3+fkMgOgCtQ>*$E$7XZdPB$J^Uo zme|?N(7o4(Ysd$)I_qrVLV$@$Ww8qA>Idr@oA-Ks){mYt>KRK9Bv9)KXC#5%~^ zlAwuI%dVDl#9`wn9{V0d*D#9}B$VSsZBks2skLxC8|ptji|{y%5q)jUl~%%!6;y&K z)Fe*%)SpS&z&Q;Oc1;aU^ql`f@-IB|K92=5xV%%3712D&cS(^mz(8<1n-{P>1 z8WvPuL>DFJdM^EHgk;z#1Gk;4Hvr}JS$R`mwGL5_b{ScOM0(bJGvvZFPu-5GOP>=X zSL-5?4Gr`;g+&-?(a@~XMhE}x+QRZAd-r+1&Y-2ETVD(Ft8FR&&>||YH>dT0cEYXS zQ1tJmwB&Bj!d*u$#j?#o;rV%2qgoqfz87`0yqVUd-#173ZIlpW&=S`83Yw!5B7#9hsrE1Bj>&agdyBAGqI8lR z12mjp#;Z%+DHp%HAW+2I&h|p_yf|{mL3_O$3<>MV7^u-reqTQ#{eiZThp5Jn8beq47#NK diff --git a/cms/static/img/old-edx-studio-logo-small.png b/cms/static/img/old-edx-studio-logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..728a3f81e076c912ec65f015173046ce8d93324b GIT binary patch literal 3689 zcmaJ^c|4Ts-zHnu>}wd)Wb4c@mccL>#xj^>5Kbi%gQ?jt7$R#aWDAjls5GgREh0rp zA(1#5lU+Gv-*@jgztj1>f4sfV=Xsv{zQ51+y1v`>&vWg>aT{R)c>xX%4q>z{3d>#- z+0WsFyzKYOS~WO(k)v6;)0`>3v`}0io&)JaIfntvn?i!%_I)695|xeS;4m?xQ*quG@HD_VydQy#0M6CF0s;s=2%wujMjJ!5#QPI$ z!vpco;m2LP!!LLn`2fvK0VZ@fn}CF;;Q(|JksJi4BY=PN!rA+MvlbBWw+rn80{EY# z+%YErmXts|Kp&zD_SS|%0WcT@s;3WwL4O12XhU_hwAnWd4Aq0{!{9mwfPXF^J6fQR zFC2@q{wEgugaG=}XjHhCR%mD_Bvcnd3G~x~8W|bwbLi-R*&g7aFft8C2a|)2{A57k zgS-O?R2qRo2JAE9&QXGC2p~Jt|CB(Y{!2>^`ln6ohH24pR4ph(d%vWgju_1U?@A*5 z>m5YH;{TiP|4AI=5=O;qVevtf;6QKo;CzqlhoZtQ1MxT-CD4UJA^z;*34aQW668;z z0xX?j0Cfz`n?T+-X#9=9VBlzS5DiE6#-mXPAe#e1Ao##hP-{ayOMOdiBSUuIt#ptm zYby&wOCuv4Lw!p~isBti!jow~u|EIBqJD|p7XyjPj*P+w5<>7k)`1if;P0a0 zgkR@^{3YK%Sf5|#V)09?7CRZO{n7rfQU5ft^|QbISGerMztYE(*?JFT3;X?j`6Uhx z0TvpCbfNc8pAw`=_R77i&m&sUc@xc}W8^s(WIc`Ub;l+oJmbr>5fiva_X2}^vP(&y z`nwD49HokQ9`+;xlFqT_#l@tKcUkZpV~Acg2Z}cGa7u_mTP-T4$*=Z4z6!``s5HI- z$#4$r+z3%xgRU(M?CDoR0wS*cb7LgsDJKwB8$gnG3Dl22-uSfP19f2L{Mz?6 z7*~QuBAM5^QMTbO@x8Ka(ol8cAF0v147b7BaW~sy;xud^&eCld9&IAE2on)DtEz2L z<<|+QfwCKIFf#n`PlLN=w%G5i*P(_T)h?E^HMSKktk&F5}Nw z=c+2;ikZpI)96KUBK@6wIV`2h{Dc>4MrwuJ`MXgL|TI2yLLr+v=gp{Y-ktlwj0wsQ80yb@%xN!$)4gFPN}K7Q?uD-NxYia72M6Ya*om zaN#PHH(bp=TU9ky20#%_dF5uSXcuLEDSDzT|jP zVTi}1PDRwnNm6rG5t8iktc{>dGw5g=-IVd*d921IeS51Ry7f`rbOv5gdKC8u!ndu} zB@>brxkNZyl91oDihN_Ik;#De>hk&H+Kfe#6N>6S(+UqRiNCKFeHU#0u6w@d*3*Oa z$cNdzc{Q`=O?|n;03iOBCU?a7(zDzqhgjo~!#7z4UWG6jpUIfY2mE&VxV4RlL=cbI zLU{D_?b~1TO&@gMdn5I{e5*)8ZK+u1g1_4#*Zq$au>Czo91i1m(JJe`;M+bSChb~{wP~s|1*Ui-V8RQ>O#QRD;LX!SL8m{l(_e%jRRX(wfir< z?vcS7?PbjK&HCDmUqtt}e>ZHGmP^%n2#$O_rT@4$HBV6P^(KTb96yuW>=`cH2|ydlyHhVui)q=j!U90o z<~jv3DNP{9%-z@B1o#w6qAE1)XblqVeN{=y zHE5mw@>fp*{9%Gm)J?8jP43yO0>(KRw4OVxi|^@Sj-&29Il!?yb5Zw_8lbCEi;!-tZQxgBm-)ulx8V{?uv zWiF*Bu5NS6up-iPYzs*v8(n!L<<^t2OG0(;()(J=7`4y+7#BdlpUSg;|5sjLb`th| z=Di8X zFoK=$Dy}YPhD)SC1XXvm6B-n5nKyLpsEDxI{l-i)TVg5=o~}DyTbCgqF7LjTjf}#! zJhPC&ZO$HP@4b8T`{awcs_$i(xi3Y9DnCxHGR^pmQWh*VTEy2Tk-=YFE2bQRDC(Lo zY#@J*UmIhstjDv0(93dNaY>O9f-i0A=%%FDn2kF^Nz&pAL-*47mSJr?ca3L1mv~GU z_T*x*K1AM)w>(^I~2HO-5a{B_Xmw%G)6`qC{{p}-EQPV=$E=7d~a z*B~r!=eaUHx2OD*ZA7&3eYF_qn44~~5S6`ErmzKhxf)vL-5`KV4G@|nlv zn4Ui1;S4U0YcwtvRLR{Tq& zv)T2YHzmJ^%Q@VQko&emaLilWf);I-$Jm#tH8e${yA(!HV$M6?hW$?#lIV(dUxL0e z9@%^59#6X|6{c5qjVO~|gYtdjq`IZKG{L(k<;Ag%ZUs&dha>lY{?Jy(QDqihvHt_K C+lO2L literal 0 HcmV?d00001 diff --git a/cms/static/img/old-logo-edx-studio.png b/cms/static/img/old-logo-edx-studio.png new file mode 100644 index 0000000000000000000000000000000000000000..006194a195d546fb40564b6bb07cf926c25cff7a GIT binary patch literal 2461 zcma)7dpK0x9^NjwjC>dpVj7x}M4a4(lItK%BzKKta*vREBE&E=W{e5rk}x8Cd?gGs zlf>sv;S(xyJt7h!Q8~La=W)(+{yO_v``zz)*ZceZ*7`l`d3GAw)^61lR#aD z+ynJ1g_XPmZ5b!6SjYSuR06@MGzz8cm}9KDZGm?p6A{3I`&OESfqFbrFDZ=#5(d(@ z`h1vX_McwVsreKsrAi_xZpP;-*}f}>CBMars3 z2bSh2SV6CsCx=E5Gsx*U{I9DU{3t?3S(@C-LI!|$wHe_D_CE}!Ovt(??Emn1NU_|5 zH(~*0P(OM)ksNvS6sJqz(R^x965!U`Z`#?~d#r%Z_=nb&GYh|-?KyN&@Yc@Rbt=Mc z>xV8RX4$i2Fs!0@=S&nWB(B90eHWskm+G!SaJ{67Xg-`%z8HSBOXVGn<@stf`R7{g z&uM$Pdc(*HZ$0y%4HvB8oR)avCW1G(F3h3Mt}RZHVlB(s?v3Z0lsIM0`>^Dj=&00T zCBCh0;83Q<`_OA(LBiiw@$hHk#UjN`He0&P-%5HI@TCr@L~jXZGUMnIcA8 zEi|3rNhpt)Q+BiI?038xYpSF^ITDzfs8$sMgBr=2Kc1K`VvD1XI>f-RQQ=c`t>1^VG7UXL~?Yq@1XXUCQr z&ipZAv6kSRys8T;Nl`GT6b!b6U-I-Ee6FWKxONJbr?2f7T2&7=+H&F5 zzc}?V*;v$&Y}bM{k8xw9Fuojo)fHSL^R#P~E+USRf58#~E-DasFJ)uP^pX55Xajm% zVD8$ZE1@`qLAtuobd$$TyV8@79yB9%Q&w1;&rSTVr}vx6Y7*%_#>ukyk~zR6s9)yv z&-IUYtd_OTgwfsY+!Aa;7tCUOzhpDr6|@61ntPW<++8G7+AOwyu)>CeE8nQaci~Oe zcb$>bMCcOZmQ#DGdGIXVtFzmlYq;jepm6bZ*V`3+fkMgOgCtQ>*$E$7XZdPB$J^Uo zme|?N(7o4(Ysd$)I_qrVLV$@$Ww8qA>Idr@oA-Ks){mYt>KRK9Bv9)KXC#5%~^ zlAwuI%dVDl#9`wn9{V0d*D#9}B$VSsZBks2skLxC8|ptji|{y%5q)jUl~%%!6;y&K z)Fe*%)SpS&z&Q;Oc1;aU^ql`f@-IB|K92=5xV%%3712D&cS(^mz(8<1n-{P>1 z8WvPuL>DFJdM^EHgk;z#1Gk;4Hvr}JS$R`mwGL5_b{ScOM0(bJGvvZFPu-5GOP>=X zSL-5?4Gr`;g+&-?(a@~XMhE}x+QRZAd-r+1&Y-2ETVD(Ft8FR&&>||YH>dT0cEYXS zQ1tJmwB&Bj!d*u$#j?#o;rV%2qgoqfz87`0yqVUd-#173ZIlpW&=S`83Yw!5B7#9hsrE1Bj>&agdyBAGqI8lR z12mjp#;Z%+DHp%HAW+2I&h|p_yf|{mL3_O$3<>MV7^u-reqTQ#{eiZThp5Jn8beq47#NK literal 0 HcmV?d00001 diff --git a/cms/static/sass/elements/_header.scss b/cms/static/sass/elements/_header.scss index 247bb35b81..cfc1778123 100644 --- a/cms/static/sass/elements/_header.scss +++ b/cms/static/sass/elements/_header.scss @@ -4,22 +4,11 @@ .wrapper-header { @extend .depth3; margin: 0; - padding: $baseline; - border-bottom: 1px solid $gray; - @include box-shadow(0 1px 5px 0 rgba(0,0,0, 0.2)); + @include box-shadow(0 1px 2px 0 $shadow-l1); background: $white; - height: 76px; position: relative; width: 100%; - - a { - color: $baseFontColor; - display: block; - - &:hover, &:active { - color: $blue; - } - } + padding: $baseline; header.primary { @include clearfix(); @@ -27,543 +16,192 @@ min-width: $fg-min-width; width: flex-grid(12); margin: 0 auto; - color: $gray-l1; } - nav .nav-item { + // ==================== + + // basic layout + .branding, .info-course, .nav-course, .nav-account, .nav-pitch { + @include box-sizing(border-box); display: inline-block; - } -} - -// ==================== - -// basic layout -.wrapper-left, .wrapper-right { - @include box-sizing(border-box); -} - -.wrapper-left { - width: flex-grid(10, 12); - float: left; - margin-right: flex-gutter(); -} - -.wrapper-right { - width: flex-grid(2, 12); - float: right; -} - -// ==================== - -// specific elements - branding -.branding, .info-course, .nav-course, .nav-account, .nav-unauth, .nav-pitch { - display: inline-block; - vertical-align: top; -} - -.branding { - position: relative; - margin: 0 ($baseline/2) 0 0; - padding-right: ($baseline*0.75); - - a { - @extend .text-hide; - display: block; - width: 164px; - height: 32px; - background: transparent url('../img/logo-edx-studio.png') 0 0 no-repeat; - } -} - -// ==================== - -// specific elements - course name/info -.info-course { - @include font-size(14); - position: relative; - margin: -3px ($baseline/2) 0 0; - padding-right: ($baseline*0.75); - - &:before { - @extend .faded-vertical-divider; - content: ""; - display: block; - height: 50px; - position: absolute; - right: 1px; - top: -8px; - width: 1px; + vertical-align: middle; } - &:after { - @extend .faded-vertical-divider-light; - content: ""; - display: block; - height: 50px; - position: absolute; - right: 0px; - top: -12px; - width: 1px; + .branding, .info-course, .nav-course { + margin-right: $baseline; } - .course-number, .course-org { - @include font-size(12); - display: inline-block; - max-width: 70px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.3; - } - - .course-org { - margin-right: ($baseline/4); - max-width: 140px; - } - - .course-title { - display: block; - width: 100%; - max-width: 220px; - overflow: hidden; - margin-top: -4px; - white-space: nowrap; - text-overflow: ellipsis; - @include font-size(16); - font-weight: 600; - } -} - -// ==================== - -// specific elements - course nav -.nav-course { - width: 290px; - @extend .t-copy-sub1; - margin-top: -($baseline/4); - - > ol > .nav-item { - vertical-align: bottom; - margin: 0 ($baseline/2) 0 0; - - &:last-child { - margin-right: 0; - } + // basic layout - nav items + .nav-item { .title { - display: block; - padding: 0 ($baseline/4); - text-transform: uppercase; + + } + } + + nav { + + > ol > .nav-item { + @extend .t-action2; + display: inline-block; + vertical-align: middle; + margin-right: ($baseline); font-weight: 600; - color: $gray-d3; - - .label-prefix { - @include font-size(11); - display: block; - font-weight: 400; - } - } - - // specific nav items - &.nav-course-courseware { - } - - &.nav-course-settings { - } - - &.nav-course-tools { - } - } -} - -// ==================== - -// specific elements - account-based nav -.nav-account { - width: 100%; - margin-top: ($baseline*0.75); - @include font-size(14); - text-align: right; - - .nav-account-username { - width: 100%; - - .icon-user { - display: inline-block; - vertical-align: middle; - margin-right: 3px; - @include font-size(12); - } - - .account-username { - display: inline-block; - vertical-align: middle; - width: 80%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - .icon-expand { - display: inline-block; - vertical-align: middle; - } - } -} - -// ==================== - -// UI - dropdown -.nav-dropdown { - - .nav-item { - position: relative; - - .icon-caret-down { - @include font-size(14); - @include transition (color 0.5s ease-in-out, opacity 0.5s ease-in-out); - display: inline-block; - vertical-align: middle; - margin-left: 2px; - opacity: 0.5; - color: $gray-l2; - } - - &:hover { - - .icon-caret-down { - color: $blue; - opacity: 1.0; - } - } - } - - .wrapper-nav-sub { - position: absolute; - left: -7px; - top: 47px; - width: 140px; - opacity: 0.0; - pointer-events: none; - } - - .nav-sub { - @include border-radius(2px); - @include box-sizing(border-box); - @include box-shadow(0 1px 5px 0 rgba(0,0,0, 0.1)); - position: relative; - width: 100%; - border: 1px solid $gray-l2; - padding: ($baseline/4) ($baseline/2); - background: $white; - - &:after, &:before { - bottom: 100%; - border: solid transparent; - content: " "; - height: 0; - width: 0; - position: absolute; - pointer-events: none; - } - - &:after { - border-color: rgba(255, 255, 255, 0); - border-bottom-color: #fff; - border-width: 5px; - right: 3px; - margin-left: -5px; - } - - &:before { - border-color: rgba(178, 178, 178, 0); - border-bottom-color: $gray-l2; - border-width: 6px; - right: 3px; - margin-left: -6px; - } - - .nav-item { - display: block; - margin: 0 0 ($baseline/4) 0; - border-bottom: 1px solid $gray-l5; - padding: 0 0($baseline/4) 0; - @include font-size(13); - - &:last-child { - margin-bottom: 0; - border-bottom: none; - padding-bottom: 0; - } - - a { - display: block; - } - } - } - - // UI - dropdown - specific navs - &.nav-account { - - .wrapper-nav-sub { - top: 27px; - left: auto; - right: -13px; - width: 110px; - } - - .nav-sub { - text-align: left; - - .icon-expand { - top: -2px; - } - } - - .nav-sub:after { - left: auto; - right: 11px; - } - - .nav-sub:before { - left: auto; - right: 10px; - } - } - - &.nav-course { - - .nav-course-courseware { - - .nav-sub:after { - left: 88px; - } - - .nav-sub:before { - left: 88px; - } - } - - .nav-course-settings { - - .nav-sub:after { - left: 88px; - } - - .nav-sub:before { - left: 88px; - } - } - - .nav-course-tools { - - .wrapper-nav-sub { - top: ($baseline*1.5); - width: 100px; - } - - .nav-sub:after { - left: 68px; - } - - .nav-sub:before { - left: 68px; - } - } - } -} - -// ==================== - -// STATE: is-signed in -.is-signedin { - - &.course .branding { - - &:before { - @extend .faded-vertical-divider; - content: ""; - display: block; - height: 50px; - position: absolute; - right: 1px; - top: -8px; - width: 1px; - } - - &:after { - @extend .faded-vertical-divider-light; - content: ""; - display: block; - height: 50px; - position: absolute; - right: 0px; - top: -12px; - width: 1px; - } - } -} - -// ==================== - -// STATE: not signed in -.not-signedin { - - .wrapper-left { - width: flex-grid(4, 12); - } - - .wrapper-right { - width: flex-grid(8, 12); - } - - // STATE: not signed in - unauthenticated nav - .nav-not-signedin { - float: right; - margin-top: ($baseline/4); - - .nav-item { - @include font-size(16); - vertical-align: middle; - margin: 0 $baseline 0 0; &:last-child { margin-right: 0; } + } + } - .action { - margin-top: -($baseline/4); - display: inline-block; - padding: ($baseline/4) ($baseline/2); + // basic layout - dropdowns + .nav-dd { + + .title { + @extend .t-action2; + + .label, .icon-caret-down { + + } + + .icon-caret-down { + @include transition(opacity 0.25s ease-in-out 0); + opacity: 0.25; + } + + &:hover { + + .icon-caret-down { + opacity: 1.0; + } } } + } - // STATE: not signed in - specific items - .nav-not-signedin-help { + // ==================== - } + // specific elements - branding + .branding { - .nav-not-signedin-signup { - margin-right: ($baseline/2); + a { + display: block; - .action-signup { - @include blue-button; - @include transition(all .15s); - @include font-size(14); - padding: ($baseline/4) ($baseline/2); - text-transform: uppercase; - font-weight: 600; - } - } - - .nav-not-signedin-signin { - - .action-signin { - @include white-button; - @include transition(all .15s); - @include font-size(14); - padding: ($baseline/4) ($baseline/2); - text-transform: uppercase; - font-weight: 600; + img { + width: 100%; + display: block; } } } -} -// ==================== + // ==================== -// STATE: active/current nav states + // specific elements - course name/info + .info-course { -.nav-item.is-current, -body.howitworks .nav-not-signedin-hiw, + .course-org, .course-number { + @extend .text-sr; + } -// dashboard -body.dashboard .nav-account-dashboard, + .course-title { + @extend .t-title7; + display: block; + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 600; + color: $gray-d1; -// course content -body.course.outline .nav-course-courseware .title, -body.course.updates .nav-course-courseware .title, -body.course.pages .nav-course-courseware .title, -body.course.uploads .nav-course-courseware .title, + } -body.course.outline .nav-course-courseware-outline, -body.course.updates .nav-course-courseware-updates, -body.course.pages .nav-course-courseware-pages, -body.course.uploads .nav-course-courseware-uploads, + // entire link + a { -// course settings -body.course.schedule .nav-course-settings .title, -body.course.grading .nav-course-settings .title, -body.course.team .nav-course-settings .title, -body.course.advanced .nav-course-settings .title, + &:hover { -body.course.schedule .nav-course-settings-schedule, -body.course.grading .nav-course-settings-grading, -body.course.team .nav-course-settings-team, -body.course.advanced .nav-course-settings-advanced, - -// course tools -body.course.import .nav-course-tools .title, -body.course.export .nav-course-tools .title, - -body.course.import .nav-course-tools-import, -body.course.export .nav-course-tools-export, - -{ - - color: $blue; - - a { - color: $blue; - pointer-events: none; + } + } } -} -body.signup .nav-not-signedin-signin { + // ==================== - a { - background-color: #d9e3ee; - color: #6d788b; - } -} + // specific elements - course nav + .nav-course { -body.signin .nav-not-signedin-signup { + .nav-item { - a { - background-color: #62aaf5; - color: #fff; - } -} + .title { + @extend .t-action2; -// ==================== - -// STATE: js enabled -.js { - - .nav-dropdown { - - .nav-item .title { - outline: 0; - cursor: pointer; - - &:hover, &:active, &.is-selected { - color: $blue; - - .icon-expand { - color: $blue; + &:hover { + @extend .fake-link; } } } } - .wrapper-nav-sub { - @include transition (opacity 1.0s ease-in-out 0s); - opacity: 0.0; - pointer-events: none; + // ==================== + + // specific elements - account-based nav + .nav-account { + text-align: right; + + .nav-account-help { + + } + + .nav-account-user { - &.is-shown { - opacity: 1.0; - pointer-events: auto; } } + + // ==================== + + // specific elements - pitch/how it works nav + .nav-pitch { + text-align: right; + } +} +// CASE: user signed in +.is-signedin { + + .branding { + width: flex-grid(2, 12); + margin-right: flex-gutter(); + background: purple; + } + + .info-course { + width: flex-grid(4, 12); + margin-right: flex-gutter(); + background: red; + } + + .nav-course { + width: flex-grid(4, 12); + margin-right: flex-gutter(); + background: blue; + } + + .nav-account { + width: flex-grid(2, 12); + background: green; + } +} + +// ==================== + +// CASE: user not signed in +.not-signedin { + + .branding { + width: flex-grid(2, 12); + margin-right: flex-gutter(); + background: purple; + } + + .nav-pitch { + width: flex-grid(10, 12); + background: green; + } } diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index 516a19c7ad..3a7beb0c50 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -18,20 +18,26 @@ nav { // ==================== -// primary - -// ==================== - -// right hand side - -// ==================== - // tabs // ==================== // dropdown +.nav-dd { -// ==================== + .title { -// + .label, .icon-caret-down { + display: inline-block; + vertical-align: middle; + } + + .icon-caret-down { + margin-left: ($baseline/10); + } + } + + .wrapper-nav-sub { + display: none !important; + } +} diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 3c8a598b5e..085030780d 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -1,118 +1,158 @@ <%! from django.core.urlresolvers import reverse %> -
+ - - - - % endif - + + + + + % endif -
- % if user.is_authenticated(): -
+ + + + + + + + % else: + + % endif From 33d0302f22f650e85db88382573a70cf2bb04648 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 26 Jun 2013 15:55:01 -0400 Subject: [PATCH 07/96] studio - proofs nav's flex grid widths/alignment --- cms/static/sass/elements/_header.scss | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/cms/static/sass/elements/_header.scss b/cms/static/sass/elements/_header.scss index cfc1778123..f6a9805818 100644 --- a/cms/static/sass/elements/_header.scss +++ b/cms/static/sass/elements/_header.scss @@ -11,6 +11,7 @@ padding: $baseline; header.primary { + @include box-sizing(border-box); @include clearfix(); max-width: $fg-max-width; min-width: $fg-min-width; @@ -27,10 +28,6 @@ vertical-align: middle; } - .branding, .info-course, .nav-course { - margin-right: $baseline; - } - // basic layout - nav items .nav-item { @@ -60,6 +57,10 @@ .title { @extend .t-action2; + &:hover { + @extend .fake-link; + } + .label, .icon-caret-down { } @@ -103,7 +104,7 @@ } .course-title { - @extend .t-title7; + @extend .t-action3; display: block; width: 100%; overflow: hidden; @@ -131,11 +132,7 @@ .nav-item { .title { - @extend .t-action2; - &:hover { - @extend .fake-link; - } } } } @@ -168,24 +165,19 @@ .branding { width: flex-grid(2, 12); margin-right: flex-gutter(); - background: purple; } .info-course { - width: flex-grid(4, 12); + width: flex-grid(3, 12); margin-right: flex-gutter(); - background: red; } .nav-course { width: flex-grid(4, 12); - margin-right: flex-gutter(); - background: blue; } .nav-account { - width: flex-grid(2, 12); - background: green; + width: flex-grid(3, 12); } } @@ -196,12 +188,9 @@ .branding { width: flex-grid(2, 12); - margin-right: flex-gutter(); - background: purple; } .nav-pitch { width: flex-grid(10, 12); - background: green; } } From 51f96ad77c47a93971141ade60953b53517fa2c2 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 26 Jun 2013 17:01:35 -0400 Subject: [PATCH 08/96] studio - revises button styling/states for primary nav --- cms/static/sass/elements/_controls.scss | 20 +++++++ cms/static/sass/elements/_header.scss | 67 ++++++++++++++--------- cms/static/sass/elements/_navigation.scss | 3 +- cms/templates/widgets/header.html | 5 +- common/static/sass/_mixins.scss | 21 +++++++ 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index e0b4a004ec..df09183123 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -136,6 +136,26 @@ // ==================== // layout-based buttons +.btn-nav-primary { + @extend .btn-nav-simple; + background: $white; + border-color: $white; + color: $gray; + + &:hover, &:active { + background: $white; + color: $blue-s1; + } + + &.current, &.active { + background: $white; + color: $gray-d4; + + &:hover, &:active { + color: $blue-s1; + } + } +} // ==================== diff --git a/cms/static/sass/elements/_header.scss b/cms/static/sass/elements/_header.scss index f6a9805818..88a9ef8bd2 100644 --- a/cms/static/sass/elements/_header.scss +++ b/cms/static/sass/elements/_header.scss @@ -42,7 +42,6 @@ @extend .t-action2; display: inline-block; vertical-align: middle; - margin-right: ($baseline); font-weight: 600; &:last-child { @@ -56,17 +55,18 @@ .title { @extend .t-action2; - - &:hover { - @extend .fake-link; - } + @extend .btn-nav-primary; + @include transition(all 0.25s ease-in-out 0); .label, .icon-caret-down { } + .label { + + } + .icon-caret-down { - @include transition(opacity 0.25s ease-in-out 0); opacity: 0.25; } @@ -98,6 +98,9 @@ // specific elements - course name/info .info-course { + margin-right: ($baseline/4); + border-right: 1px solid $gray-l3; + padding: ($baseline/2) $baseline ($baseline/2) 0; .course-org, .course-number { @extend .text-sr; @@ -111,8 +114,7 @@ white-space: nowrap; text-overflow: ellipsis; font-weight: 600; - color: $gray-d1; - + color: $gray; } // entire link @@ -162,22 +164,24 @@ // CASE: user signed in .is-signedin { - .branding { - width: flex-grid(2, 12); - margin-right: flex-gutter(); - } + .wrapper-header { - .info-course { - width: flex-grid(3, 12); - margin-right: flex-gutter(); - } + .branding { + width: flex-grid(2, 12); + margin-right: flex-gutter(); + } - .nav-course { - width: flex-grid(4, 12); - } + .info-course { + width: flex-grid(3, 12); + } - .nav-account { - width: flex-grid(3, 12); + .nav-course { + width: flex-grid(4, 12); + } + + .nav-account { + width: flex-grid(3, 12); + } } } @@ -186,11 +190,22 @@ // CASE: user not signed in .not-signedin { - .branding { - width: flex-grid(2, 12); - } + .wrapper-header { - .nav-pitch { - width: flex-grid(10, 12); + nav > ol > .nav-item { + margin-right: $baseline; + + &:last-child { + margin-right: 0; + } + } + + .branding { + width: flex-grid(2, 12); + } + + .nav-pitch { + width: flex-grid(10, 12); + } } } diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index 3a7beb0c50..69445c353b 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -28,8 +28,7 @@ nav { .title { .label, .icon-caret-down { - display: inline-block; - vertical-align: middle; + } .icon-caret-down { diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 085030780d..768c895fd4 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -93,7 +93,7 @@
    - +
+ - % else: - - % endif + % else: + + % endif + From 1e5fa875ddf39187b05796b38f69927a80e3c6d6 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 1 Jul 2013 15:15:11 -0400 Subject: [PATCH 17/96] studio - syncs up Sass variable with edX's official blue --- cms/static/sass/_variables.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index bad87952d6..925f9e9d7a 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -47,7 +47,8 @@ $gray-d2: shade($gray,40%); $gray-d3: shade($gray,60%); $gray-d4: shade($gray,80%); -$blue: rgb(85, 151, 221); +// $blue: rgb(85, 151, 221); +$blue: rgb(0, 143, 213); $blue-l1: tint($blue,20%); $blue-l2: tint($blue,40%); $blue-l3: tint($blue,60%); From 415ea63bc0780a49593910f4c0244db51dd8322c Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 12 Jun 2013 14:45:40 -0400 Subject: [PATCH 18/96] Working prototype of crowdsourced hinting module. Conflicts: common/static/coffee/src/logger.coffee --- common/lib/xmodule/setup.py | 1 + .../lib/xmodule/xmodule/crowdsource_hinter.py | 261 ++++++++++++++++++ .../xmodule/js/src/capa/display.coffee | 1 + .../js/src/crowdsource_hinter/display.coffee | 62 +++++ common/static/coffee/src/logger.coffee | 30 +- 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/crowdsource_hinter.py create mode 100644 common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 43d970d898..6b106dd94d 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -55,6 +55,7 @@ setup( "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", + "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", ], 'console_scripts': [ 'xmodule_assets = xmodule.static_content:main', diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py new file mode 100644 index 0000000000..1d424b7fff --- /dev/null +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -0,0 +1,261 @@ +import logging +import copy +import json +import os +import re +import string +import random + +from pkg_resources import resource_listdir, resource_string, resource_isdir + +from lxml import etree + +from xmodule.modulestore import Location +from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.x_module import XModule +from xmodule.xml_module import XmlDescriptor +from xblock.core import XBlock, Scope, String, Integer, Float, Object, Boolean + +from django.utils.html import escape + +log = logging.getLogger(__name__) + + +class CrowdsourceHinterFields(object): + has_children = True + hints = Object(help='''A dictionary mapping answers to lists of [hint, number_of_votes] pairs. + ''', scope=Scope.content, default= { + '4': + [['This is a hint.', 5], + ['This is hint 2', 3], + ['This is hint 3', 2], + ['This is hint 4', 1]]}) + ''' + Testing data for hints: + + ''' + previous_answers = Object(help='''A list of previous answers this student made to this problem. + Of the form (answer, (hint_id_1, hint_id_2, hint_id_3)) for each problem. hint_id's are + None if the hint was not given.''', + scope=Scope.user_state, default=[]) + + user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', + scope=Scope.user_state, default=False) + + +class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): + ''' An Xmodule that makes crowdsourced hints. + ''' + icon_class = 'crowdsource_hinter' + + js = {'coffee': [resource_string(__name__, 'js/src/crowdsource_hinter/display.coffee'), + ], + 'js': []} + js_module_name = "Hinter" + + + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) + + + def get_html(self): + ''' + Does a regular expression find and replace to change the AJAX url. + - Dependent on lon-capa problem. + ''' + # Reset the user vote, for debugging only! Remove for prod. + self.user_voted = False + for child in self.get_display_items(): + out = child.get_html() + # The event listener uses the ajax url to find the child. + child_url = child.system.ajax_url + break + # Wrap the module in a
. This lets us pass data attributes to the javascript. + out += '
' + return out + + def capa_make_answer_hashable(self, answer): + ''' + Capa answer format: dict[problem name] -> [list of answers] + Output format: ((problem name, (answers))) + ''' + out = [] + for problem, a in answer.items(): + out.append((problem, tuple(a))) + return str(tuple(sorted(out))) + + + def ans_to_text(self, answer): + ''' + Converts capa answer format to a string representation + of the answer. + -Lon-capa dependent. + ''' + return answer.values()[0][0] + + + def handle_ajax(self, dispatch, get): + ''' + This is the landing method for AJAX calls. + ''' + if dispatch == 'get_hint': + return self.get_hint(get) + if dispatch == 'get_feedback': + return self.get_feedback(get) + if dispatch == 'vote': + return self.tally_vote(get) + if dispatch == 'submit_hint': + return self.submit_hint(get) + + def get_hint(self, get): + ''' + The student got the incorrect answer found in get. Give him a hint. + ''' + print self.hints + answer = self.ans_to_text(get) + # Look for a hint to give. + if answer not in self.hints: + # No hints to give. Return. + self.previous_answers += [(answer, (None, None, None))] + return json.dumps({'contents': ' '}) + # Get the top hint, plus two random hints. + n_hints = len(self.hints[answer]) + best_hint_index = max(xrange(n_hints), key=lambda i:self.hints[answer][i][1]) + best_hint = self.hints[answer][best_hint_index][0] + if len(self.hints[answer]) == 1: + rand_hint_1 = '' + rand_hint_2 = '' + self.previous_answers += [(answer, (0, None, None))] + elif len(self.hints[answer]) == 2: + best_hint = self.hints[answer][0][0] + rand_hint_1 = self.hints[answer][1][0] + rand_hint_2 = '' + self.previous_answers += [(answer, (0, 1, None))] + else: + hint_index_1, hint_index_2 = random.sample(xrange(len(self.hints[answer])), 2) + rand_hint_1 = self.hints[answer][hint_index_1][0] + rand_hint_2 = self.hints[answer][hint_index_2][0] + self.previous_answers += [(answer, (best_hint_index, hint_index_1, hint_index_2))] + hint_text = best_hint + '
' + rand_hint_1 + '
' + rand_hint_2 + return json.dumps({'contents': hint_text}) + + def get_feedback(self, get): + ''' + The student got it correct. Ask him to vote on hints, or submit a hint. + ''' + # The student got it right. + # Did he submit at least one wrong answer? + out = ' ' + if len(self.previous_answers) == 0: + # No. Nothing to do here. + return json.dumps({'contents': out}) + # Make a hint-voting interface for each wrong answer. The student will only + # be allowed to make one vote / submission, but he can choose which wrong answer + # he wants to look at. + pretty_answers = [] + for i in xrange(len(self.previous_answers)): + answer, hints_offered = self.previous_answers[i] + pretty_answers.append(answer) + # If there are previous hints for this answer, ask the student to vote on one. + if answer in self.hints: + out += '' + + # Add preamble. + out2 = '''Help us improve our hinting system by voting on the hint that was most helpful + to you. Start by picking one of your previous incorrect answers from below:
+
' + return json.dumps({'contents': out2 + out}) + + + def tally_vote(self, get): + ''' + Tally a user's vote on his favorite hint. + get: + 'answer': ans_no (index in previous_answers) + 'hint': hint_no + ''' + if self.user_voted: + return json.dumps({'contents': 'Sorry, but you have already voted!'}) + ans_no = int(get['answer']) + hint_no = int(get['hint']) + answer = self.previous_answers[ans_no][0] + temp_dict = self.hints + temp_dict[answer][hint_no][1] += 1 + # Awkward, but you need to do a direct write for the database to update. + self.hints = temp_dict + # Don't let the user vote again! + self.user_voted = True + # Reset self.previous_answers. + self.previous_answers = [] + # In the future, return a list of how many votes each hint got, maybe? + return json.dumps({'contents': 'Congrats, you\'ve voted!'}) + + + def submit_hint(self, get): + ''' + Take a hint submission and add it to the database. + get: + 'answer': answer index in previous_answers + 'hint': text of the new hint that the user is adding + ''' + # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. + hint = escape(get['hint']) + answer = self.previous_answers[int(get['answer'])][0] + # Add the new hint to self.hints. (Awkward because a direct write + # is necessary.) + temp_dict = self.hints + temp_dict[answer].append([hint, 1]) # With one vote (the user himself). + self.hints = temp_dict + # Mark the user has having voted; reset previous_answers + self.user_voted = True + self.previous_answers = [] + return json.dumps({'contents': 'Thank you for your hint!'}) + + +class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): + module_class = CrowdsourceHinterModule + stores_state = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + children = [] + for child in xml_object: + try: + children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url()) + except Exception as e: + log.exception("Unable to load child when parsing CrowdsourceHinter. Continuing...") + if system.error_tracker is not None: + system.error_tracker("ERROR: " + str(e)) + continue + return {}, children + + def definition_to_xml(self, resource_fs): + xml_object = etree.Element('crowdsource_hinter') + for child in self.get_children(): + xml_object.append( + etree.fromstring(child.export_to_xml(resource_fs))) + return xml_object \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1f3be9e5e9..4640f7555d 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -223,6 +223,7 @@ class @Problem @el.removeClass 'showed' else @gentle_alert response.success + Logger.log 'problem_graded', [@answers, response.contents], @url reset: => Logger.log 'problem_reset', @answers diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee new file mode 100644 index 0000000000..1e38ff0e82 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -0,0 +1,62 @@ +class @Hinter + + constructor: (element) -> + @el = $(element).find('.crowdsource-wrapper') + @url = @el.data('url') + Logger.listen('problem_graded', @el.data('child-url'), @capture_problem) + # The line below will eventually be generated by Python. + @render() + + capture_problem: (event_type, data, element) => + # After a problem gets graded, we get the info here. + # We want to send this info to the server in another AJAX + # request. + answers = data[0] + response = data[1] + if response.search(/class="correct "/) == -1 + # Incorrect. Get hints. + $.postWithPrefix "#{@url}/get_hint", answers, (response) => + @render(response.contents) + else + # Correct. Get feedback from students. + $.postWithPrefix "#{@url}/get_feedback", answers, (response) => + @render(response.contents) + + $: (selector) -> + $(selector, @el) + + bind: => + window.update_schematics() + @$('input.vote').click @vote + @$('#feedback-select').change @feedback_ui_change + @$('input.submit-hint').click @submit_hint + + + vote: (eventObj) => + target = @$(eventObj.currentTarget) + post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')} + $.postWithPrefix "#{@url}/vote", post_json, (response) => + @render(response.contents) + + submit_hint: (eventObj) => + target = @$(eventObj.currentTarget) + textarea_id = '#custom-hint-' + target.data('answer') + console.debug(textarea_id) + post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} + $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => + @render(response.contents) + + feedback_ui_change: => + # Make all of the previous-answer divs hidden. + @$('.previous-answer').css('display', 'none') + # But, now find the selected div, and make it visible. + selector = '#previous-answer-' + @$('#feedback-select option:selected').attr('value') + @$(selector).css('display', 'inline') + + + render: (content) -> + if content + @el.html(content) + JavascriptLoader.executeModuleScripts @el, () => + @bind() + @$('#previous-answer-0').css('display', 'inline') \ No newline at end of file diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index f2dfef5132..6eaa497255 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -1,8 +1,11 @@ class @Logger + # events we want sent to Segment.io for tracking SEGMENT_IO_WHITELIST = ["seq_goto", "seq_next", "seq_prev", "problem_check", "problem_reset", "problem_show", "problem_save"] - @log: (event_type, data) -> + # listeners[event_type][element] -> list of callbacks + listeners = {} + @log: (event_type, data, element = null) -> # Segment.io event tracking if event_type in SEGMENT_IO_WHITELIST # to avoid changing the format of data sent to our servers, we only massage it here @@ -11,11 +14,36 @@ class @Logger else analytics.track event_type, data + # Check to see if we're listening for the event type. + if event_type of listeners + # Cool. Do the elements also match? + # null element in the listener dictionary means any element will do. + # null element in the @log call means we don't know the element name. + if null of listeners[event_type] + # Make the callbacks. + for callback in listeners[event_type][null] + callback(event_type, data, element) + else if element of listeners[event_type] + for callback in listeners[event_type][element] + callback(event_type, data, element) + + # Regardless of whether any callbacks were made, log this event. $.getWithPrefix '/event', event_type: event_type event: JSON.stringify(data) page: window.location.href + @listen: (event_type, element, callback) -> + # Add a listener. If you want any element to trigger this listener, + # do element = null + if event_type not of listeners + listeners[event_type] = {} + if element not of listeners[event_type] + listeners[event_type][element] = [callback] + else + listeners[event_type][element].push callback + + @bind: -> window.onunload = -> $.ajaxWithPrefix From 0c0de20a2f0eadea9b3a3d2b1d9ef93457c0e8a6 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 20 Jun 2013 09:44:19 -0400 Subject: [PATCH 19/96] Began work on instructor view to hinting system. Added moderation feature - you can now choose to hold all hints for moderator approval before showing. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 65 ++++++++- lms/djangoapps/instructor/hint_manager.py | 138 ++++++++++++++++++ lms/templates/courseware/hint_manager.html | 89 +++++++++++ .../courseware/hint_manager_inner.html | 39 +++++ lms/urls.py | 3 + 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/instructor/hint_manager.py create mode 100644 lms/templates/courseware/hint_manager.html create mode 100644 lms/templates/courseware/hint_manager_inner.html diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 1d424b7fff..97120bbf1c 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -42,6 +42,14 @@ class CrowdsourceHinterFields(object): user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', scope=Scope.user_state, default=False) + moderate = String(help='''If 'True', then all hints must be approved by staff before + becoming visible. + This field is automatically populated from the xml metadata.''', scope=Scope.settings, + default='False') + + mod_queue = Dict(help='''Contains hints that have not been approved by the staff yet. Structured + identically to the hints dictionary.''', scope=Scope.content, default={}) + class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ''' An Xmodule that makes crowdsourced hints. @@ -115,7 +123,11 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): print self.hints answer = self.ans_to_text(get) # Look for a hint to give. +<<<<<<< HEAD if answer not in self.hints: +======= + if (answer not in self.hints) or (len(self.hints[answer]) == 0): +>>>>>>> Began work on instructor view to hinting system. # No hints to give. Return. self.previous_answers += [(answer, (None, None, None))] return json.dumps({'contents': ' '}) @@ -126,12 +138,23 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if len(self.hints[answer]) == 1: rand_hint_1 = '' rand_hint_2 = '' +<<<<<<< HEAD self.previous_answers += [(answer, (0, None, None))] elif len(self.hints[answer]) == 2: best_hint = self.hints[answer][0][0] rand_hint_1 = self.hints[answer][1][0] rand_hint_2 = '' self.previous_answers += [(answer, (0, 1, None))] +======= + self.previous_answers += [[answer, [best_hint_index, None, None]]] + elif n_hints == 2: + best_hint = self.hints[answer].values()[0][0] + best_hint_index = self.hints[answer].keys()[0] + rand_hint_1 = self.hints[answer].values()[1][0] + hint_index_1 = self.hints[answer].keys()[1] + rand_hint_2 = '' + self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] +>>>>>>> Began work on instructor view to hinting system. else: hint_index_1, hint_index_2 = random.sample(xrange(len(self.hints[answer])), 2) rand_hint_1 = self.hints[answer][hint_index_1][0] @@ -163,10 +186,20 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): '" style="display:none"> Which hint was most helpful when you got the wrong answer of '\ + answer + '?' # Add each hint to the html string, with a vote button. - for j, hint_id in enumerate(hints_offered): + for hint_id in hints_offered: if hint_id != None: +<<<<<<< HEAD out += '
' + self.hints[answer][hint_id][0] +======= + hint_id = str(hint_id) + try: + out += '
' + self.hints[answer][hint_id][0] + except KeyError: + # Sometimes, the hint that a user saw will have been deleted by the instructor. + continue +>>>>>>> Began work on instructor view to hinting system. # Or, let the student create his own hint @@ -227,15 +260,45 @@ What would you say to help someone who got this wrong answer? answer = self.previous_answers[int(get['answer'])][0] # Add the new hint to self.hints. (Awkward because a direct write # is necessary.) +<<<<<<< HEAD temp_dict = self.hints temp_dict[answer].append([hint, 1]) # With one vote (the user himself). self.hints = temp_dict +======= + if self.moderate: + temp_dict = self.mod_queue + else: + temp_dict = self.hints + if answer in temp_dict: + temp_dict[answer][self.hint_pk] = [hint, 1] # With one vote (the user himself). + else: + temp_dict[answer] = {self.hint_pk: [hint, 1]} + self.hint_pk += 1 + if self.moderate: + self.mod_queue = temp_dict + else: + self.hints = temp_dict +>>>>>>> Began work on instructor view to hinting system. # Mark the user has having voted; reset previous_answers self.user_voted = True self.previous_answers = [] return json.dumps({'contents': 'Thank you for your hint!'}) +<<<<<<< HEAD +======= + def delete_hint(self, answer, hint_id): + ''' + From the answer, delete the hint with hint_id. + Not designed to be accessed via POST request, for now. + -LIKELY DEPRECATED. + ''' + temp_hints = self.hints + del temp_hints[answer][str(hint_id)] + self.hints = temp_hints + + +>>>>>>> Began work on instructor view to hinting system. class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): module_class = CrowdsourceHinterModule stores_state = True diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py new file mode 100644 index 0000000000..431d3f5d7c --- /dev/null +++ b/lms/djangoapps/instructor/hint_manager.py @@ -0,0 +1,138 @@ +''' +Views for hint management. +''' + +from collections import defaultdict +import csv +import json +import logging +from markupsafe import escape +import os +import re +import requests +from requests.status_codes import codes +import urllib +from collections import OrderedDict + +from StringIO import StringIO + +from django.conf import settings +from django.contrib.auth.models import User, Group +from django.http import HttpResponse, Http404 +from django_future.csrf import ensure_csrf_cookie +from django.views.decorators.cache import cache_control +from mitxmako.shortcuts import render_to_response, render_to_string +from django.core.urlresolvers import reverse + +from courseware.courses import get_course_with_access +from courseware.models import XModuleContentField + + +@ensure_csrf_cookie +def hint_manager(request, course_id): + try: + course = get_course_with_access(request.user, course_id, 'staff', depth=None) + except Http404: + out = 'Sorry, but students are not allowed to access the hint manager!' + return + if request.method == 'GET': + out = get_hints(request, course_id, 'mod_queue') + return render_to_response('courseware/hint_manager.html', out) + field = request.POST['field'] + if not (field == 'mod_queue' or field == 'hints'): + # Invalid field. (Don't let users continue - they may overwrite other db's) + return + if request.POST['op'] == 'delete hints': + delete_hints(request, course_id, field) + if request.POST['op'] == 'switch fields': + pass + if request.POST['op'] == 'change votes': + change_votes(request, course_id, field) + rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(request, course_id, field)) + return HttpResponse(json.dumps({'success': True, 'contents': rendered_html})) + + + +def get_hints(request, course_id, field): + # field indicates the database entry that we are modifying. + # Right now, the options are 'hints' or 'mod_queue'. + # DON'T TRUST field attributes that come from ajax. Use an if statement + # to make sure the field is valid before plugging into functions. + + out = '' + if field == 'mod_queue': + other_field = 'hints' + field_label = 'Hints Awaiting Moderation' + other_field_label = 'Approved Hints' + elif field == 'hints': + other_field = 'mod_queue' + field_label = 'Approved Hints' + other_field_label = 'Hints Awaiting Moderation' + chopped_id = '/'.join(course_id.split('/')[:-1]) + chopped_id = re.escape(chopped_id) + all_hints = XModuleContentField.objects.filter(field_name=field, definition_id__regex=chopped_id) + for problem in all_hints: + out += '

Problem: ' + problem.definition_id + '

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

Answer: ' + answer + '

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

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

' + out += '''

Add a hint to this problem

+ Answer (exact formatting): +
Hint:


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

${field_label}

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

Problem: ${problem.definition_id}

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

Answer: ${answer}

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

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

+ % endfor + % endfor + +

Add a hint to this problem

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

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

+

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

+

Enter the number of fingers on a human hand:

+ + + + +
+

Explanation

+

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

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

Problem: ' + problem.definition_id + '

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

Answer: ' + answer + '

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

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

' - out += '''

Add a hint to this problem

- Answer (exact formatting): -
Hint:


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

${field_label}

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

Problem: ${problem.definition_id}

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

Answer: ${answer}

+% for definition_id in all_hints: +

Problem: ${id_to_name[definition_id]}

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

Answer: ${answer}

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

+

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

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

+ % endif % endfor

Add a hint to this problem

- Answer (exact formatting): - +

Answer:

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

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

+

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

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

    Test numerical problem.

    + + + + +
    +

    Explanation

    +

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

    +
    +
    +
    +
    + + + +

    Another test numerical problem.

    + + + + +
    +

    Explanation

    +

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

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

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

    +

    Hints from students who made similar mistakes:

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

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

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

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

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

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

        ${hint_text} -
        +

        % endfor +

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

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

        + - +

        % endfor
      +

      Read about what makes a good hint.

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

      Read about what makes a good hint.

      +

      Read about what makes a good hint.