diff --git a/.gitignore b/.gitignore index 2fd1ca0181..493df5a7fd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ lms/lib/comment_client/python nosetests.xml cover_html/ .idea/ +.redcar/ chromedriver.log \ No newline at end of file diff --git a/.pep8 b/.pep8 new file mode 100644 index 0000000000..25d0edbcb4 --- /dev/null +++ b/.pep8 @@ -0,0 +1,2 @@ +[pep8] +ignore=E501 \ No newline at end of file diff --git a/.ruby-version b/.ruby-version index e46f918393..dd472cffa2 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -1.8.7-p371 +1.8.7-p371 \ No newline at end of file diff --git a/apt-packages.txt b/apt-packages.txt index b783ccb67e..0560dfcbc2 100644 --- a/apt-packages.txt +++ b/apt-packages.txt @@ -9,6 +9,7 @@ gfortran liblapack-dev libfreetype6-dev libpng12-dev +libjpeg-dev libxml2-dev libxslt-dev yui-compressor diff --git a/cms/.coveragerc b/cms/.coveragerc index 9b1e59d670..4f0dbebe79 100644 --- a/cms/.coveragerc +++ b/cms/.coveragerc @@ -1,8 +1,8 @@ # .coveragerc for cms [run] data_file = reports/cms/.coverage -source = cms -omit = cms/envs/*, cms/manage.py +source = cms,common/djangoapps +omit = cms/envs/*, cms/manage.py, common/djangoapps/terrain/*, common/djangoapps/*/migrations/* [report] ignore_errors = True diff --git a/cms/__init__.py b/cms/__init__.py index 8b13789179..e69de29bb2 100644 --- a/cms/__init__.py +++ b/cms/__init__.py @@ -1 +0,0 @@ - diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 22bbc4bc1c..281e3f46b2 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -18,6 +18,8 @@ STAFF_ROLE_NAME = 'staff' # we're just making a Django group for each location/role combo # to do this we're just creating a Group name which is a formatted string # of those two variables + + def get_course_groupname_for_role(location, role): loc = Location(location) # hack: check for existence of a group name in the legacy LMS format _ @@ -25,11 +27,12 @@ def get_course_groupname_for_role(location, role): # more information groupname = '{0}_{1}'.format(role, loc.course) - if len(Group.objects.filter(name = groupname)) == 0: - groupname = '{0}_{1}'.format(role,loc.course_id) + if len(Group.objects.filter(name=groupname)) == 0: + groupname = '{0}_{1}'.format(role, loc.course_id) return groupname + def get_users_in_course_group_by_role(location, role): groupname = get_course_groupname_for_role(location, role) (group, created) = Group.objects.get_or_create(name=groupname) @@ -39,6 +42,8 @@ def get_users_in_course_group_by_role(location, role): ''' Create all permission groups for a new course and subscribe the caller into those roles ''' + + def create_all_course_groups(creator, location): create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME) @@ -46,7 +51,7 @@ def create_all_course_groups(creator, location): def create_new_course_group(creator, location, role): groupname = get_course_groupname_for_role(location, role) - (group, created) =Group.objects.get_or_create(name=groupname) + (group, created) = Group.objects.get_or_create(name=groupname) if created: group.save() @@ -59,6 +64,8 @@ def create_new_course_group(creator, location, role): This is to be called only by either a command line code path or through a app which has already asserted permissions ''' + + def _delete_course_group(location): # remove all memberships instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) @@ -75,6 +82,8 @@ def _delete_course_group(location): This is to be called only by either a command line code path or through an app which has already asserted permissions to do this action ''' + + def _copy_course_group(source, dest): instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) @@ -86,7 +95,7 @@ def _copy_course_group(source, dest): new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME)) for user in staff.user_set.all(): user.groups.add(new_staff_group) - user.save() + user.save() def add_user_to_course_group(caller, user, location, role): @@ -133,8 +142,6 @@ def remove_user_from_course_group(caller, user, location, role): def is_user_in_course_group_role(user, location, role): if user.is_active and user.is_authenticated: # all "is_staff" flagged accounts belong to all groups - return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location,role)).count() > 0 + return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return False - - diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 6995df06a8..153d13dd13 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -8,6 +8,8 @@ import logging ## TODO store as array of { date, content } and override course_info_module.definition_from_xml ## This should be in a class which inherits from XmlDescriptor + + def get_course_updates(location): """ Retrieve the relevant course_info updates and unpack into the model which the client expects: @@ -21,13 +23,13 @@ def get_course_updates(location): # current db rep: {"_id" : locationjson, "definition" : { "data" : "
    [
  1. date

    content
  2. ]
"} "metadata" : ignored} location_base = course_updates.location.url() - + # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: course_html_parsed = html.fromstring(course_updates.definition['data']) except: course_html_parsed = html.fromstring("
    ") - + # Confirm that root is
      , iterate over
    1. , pull out

      subs and then rest of val course_upd_collection = [] if course_html_parsed.tag == 'ol': @@ -40,25 +42,26 @@ def get_course_updates(location): content = update[0].tail else: content = "\n".join([html.tostring(ele) for ele in update[1:]]) - + # make the id on the client be 1..len w/ 1 being the oldest and len being the newest - course_upd_collection.append({"id" : location_base + "/" + str(len(course_html_parsed) - idx), - "date" : update.findtext("h2"), - "content" : content}) - + course_upd_collection.append({"id": location_base + "/" + str(len(course_html_parsed) - idx), + "date": update.findtext("h2"), + "content": content}) + return course_upd_collection + def update_course_updates(location, update, passed_id=None): """ Either add or update the given course update. It will add it if the passed_id is absent or None. It will update it if it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index - into the html structure. + into the html structure. """ try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: return HttpResponseBadRequest - + # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: course_html_parsed = html.fromstring(course_updates.definition['data']) @@ -67,7 +70,7 @@ def update_course_updates(location, update, passed_id=None): # No try/catch b/c failure generates an error back to client new_html_parsed = html.fromstring('
    2. ' + update['date'] + '

      ' + update['content'] + '
    3. ') - + # Confirm that root is
        , iterate over
      1. , pull out

        subs and then rest of val if course_html_parsed.tag == 'ol': # ??? Should this use the id in the json or in the url or does it matter? @@ -80,14 +83,15 @@ def update_course_updates(location, update, passed_id=None): idx = len(course_html_parsed) passed_id = course_updates.location.url() + "/" + str(idx) - + # update db record course_updates.definition['data'] = html.tostring(course_html_parsed) modulestore('direct').update_item(location, course_updates.definition['data']) - - return {"id" : passed_id, - "date" : update['date'], - "content" :update['content']} + + return {"id": passed_id, + "date": update['date'], + "content": update['content']} + def delete_course_update(location, update, passed_id): """ @@ -96,19 +100,19 @@ def delete_course_update(location, update, passed_id): """ if not passed_id: return HttpResponseBadRequest - + try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: return HttpResponseBadRequest - + # TODO use delete_blank_text parser throughout and cache as a static var in a class # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: course_html_parsed = html.fromstring(course_updates.definition['data']) except: course_html_parsed = html.fromstring("
          ") - + if course_html_parsed.tag == 'ol': # ??? Should this use the id in the json or in the url or does it matter? idx = get_idx(passed_id) @@ -120,10 +124,11 @@ def delete_course_update(location, update, passed_id): # update db record course_updates.definition['data'] = html.tostring(course_html_parsed) store = modulestore('direct') - store.update_item(location, course_updates.definition['data']) - + store.update_item(location, course_updates.definition['data']) + return get_course_updates(location) - + + def get_idx(passed_id): """ From the url w/ idx appended, get the idx. @@ -131,4 +136,4 @@ def get_idx(passed_id): # TODO compile this regex into a class static and reuse for each call idx_matcher = re.search(r'.*/(\d+)$', passed_id) if idx_matcher: - return int(idx_matcher.group(1)) \ No newline at end of file + return int(idx_matcher.group(1)) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature new file mode 100644 index 0000000000..4708a60be1 --- /dev/null +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -0,0 +1,51 @@ +Feature: Advanced (manual) course policy + In order to specify course policy settings for which no custom user interface exists + I want to be able to manually enter JSON key/value pairs + + Scenario: A course author sees only display_name on a newly created course + Given I have opened a new course in Studio + When I select the Advanced Settings + Then I see only the display name + + Scenario: Test if there are no policy settings without existing UI controls + Given I am on the Advanced Course Settings page in Studio + When I delete the display name + Then there are no advanced policy settings + And I reload the page + Then there are no advanced policy settings + + Scenario: Test cancel editing key name + Given I am on the Advanced Course Settings page in Studio + When I edit the name of a policy key + And I press the "Cancel" notification button + Then the policy key name is unchanged + + Scenario: Test editing key name + Given I am on the Advanced Course Settings page in Studio + When I edit the name of a policy key + And I press the "Save" notification button + Then the policy key name is changed + + Scenario: Test cancel editing key value + Given I am on the Advanced Course Settings page in Studio + When I edit the value of a policy key + And I press the "Cancel" notification button + Then the policy key value is unchanged + + Scenario: Test editing key value + Given I am on the Advanced Course Settings page in Studio + When I edit the value of a policy key + And I press the "Save" notification button + Then the policy key value is changed + + Scenario: Add new entries, and they appear alphabetically after save + Given I am on the Advanced Course Settings page in Studio + When I create New Entries + Then they are alphabetized + And I reload the page + Then they are alphabetized + + Scenario: Test how multi-line input appears + Given I am on the Advanced Course Settings page in Studio + When I create a JSON object + Then it is displayed as formatted diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py new file mode 100644 index 0000000000..91daf70718 --- /dev/null +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -0,0 +1,182 @@ +from lettuce import world, step +from common import * +import time + +from nose.tools import assert_equal +from nose.tools import assert_true + +""" +http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html +""" +from selenium.webdriver.common.keys import Keys + + +############### ACTIONS #################### +@step('I select the Advanced Settings$') +def i_select_advanced_settings(step): + expand_icon_css = 'li.nav-course-settings i.icon-expand' + if world.browser.is_element_present_by_css(expand_icon_css): + css_click(expand_icon_css) + link_css = 'li.nav-course-settings-advanced a' + css_click(link_css) + + +@step('I am on the Advanced Course Settings page in Studio$') +def i_am_on_advanced_course_settings(step): + step.given('I have opened a new course in Studio') + step.given('I select the Advanced Settings') + + +# TODO: this is copied from terrain's step.py. Need to figure out how to share that code. +@step('I reload the page$') +def reload_the_page(step): + world.browser.reload() + + +@step(u'I edit the name of a policy key$') +def edit_the_name_of_a_policy_key(step): + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + e.fill('new') + + +@step(u'I press the "([^"]*)" notification button$') +def press_the_notification_button(step, name): + world.browser.click_link_by_text(name) + + +@step(u'I edit the value of a policy key$') +def edit_the_value_of_a_policy_key(step): + """ + It is hard to figure out how to get into the CodeMirror + area, so cheat and do it from the policy key field :) + """ + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') + + +@step('I delete the display name$') +def delete_the_display_name(step): + delete_entry(0) + click_save() + + +@step('create New Entries$') +def create_new_entries(step): + create_entry("z", "apple") + create_entry("a", "zebra") + click_save() + + +@step('I create a JSON object$') +def create_JSON_object(step): + create_entry("json", '{"key": "value", "key_2": "value_2"}') + click_save() + + +############### RESULTS #################### +@step('I see only the display name$') +def i_see_only_display_name(step): + assert_policy_entries(["display_name"], ['"Robot Super Course"']) + + +@step('there are no advanced policy settings$') +def no_policy_settings(step): + assert_policy_entries([], []) + + +@step('they are alphabetized$') +def they_are_alphabetized(step): + assert_policy_entries(["a", "display_name", "z"], ['"zebra"', '"Robot Super Course"', '"apple"']) + + +@step('it is displayed as formatted$') +def it_is_formatted(step): + assert_policy_entries(["display_name", "json"], ['"Robot Super Course"', '{\n "key": "value",\n "key_2": "value_2"\n}']) + + +@step(u'the policy key name is unchanged$') +def the_policy_key_name_is_unchanged(step): + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + assert_equal(e.value, 'display_name') + + +@step(u'the policy key name is changed$') +def the_policy_key_name_is_changed(step): + policy_key_css = 'input.policy-key' + e = css_find(policy_key_css).first + assert_equal(e.value, 'new') + + +@step(u'the policy key value is unchanged$') +def the_policy_key_value_is_unchanged(step): + policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' + e = css_find(policy_value_css).first + assert_equal(e.value, '"Robot Super Course"') + + +@step(u'the policy key value is changed$') +def the_policy_key_value_is_unchanged(step): + policy_value_css = 'li.course-advanced-policy-list-item div.value textarea' + e = css_find(policy_value_css).first + assert_equal(e.value, '"Robot Super Course X"') + + +############# HELPERS ############### +def create_entry(key, value): + # Scroll down the page so the button is visible + world.scroll_to_bottom() + css_click_at('a.new-advanced-policy-item', 10, 10) + new_key_css = 'div#__new_advanced_key__ input' + new_key_element = css_find(new_key_css).first + new_key_element.fill(key) +# For some reason have to get the instance for each command (get error that it is no longer attached to the DOM) +# Have to do all this because Selenium has a bug that fill does not remove existing text + new_value_css = 'div.CodeMirror textarea' + css_find(new_value_css).last.fill("") + css_find(new_value_css).last._element.send_keys(Keys.DELETE, Keys.DELETE) + css_find(new_value_css).last.fill(value) + + +def delete_entry(index): + """ + Delete the nth entry where index is 0-based + """ + css = '.delete-button' + assert_true(world.browser.is_element_present_by_css(css, 5)) + delete_buttons = css_find(css) + assert_true(len(delete_buttons) > index, "no delete button exists for entry " + str(index)) + delete_buttons[index].click() + + +def assert_policy_entries(expected_keys, expected_values): + assert_entries('.key input', expected_keys) + assert_entries('.json', expected_values) + + +def assert_entries(css, expected_values): + webElements = css_find(css) + assert_equal(len(expected_values), len(webElements)) +# Sometimes get stale reference if I hold on to the array of elements + for counter in range(len(expected_values)): + assert_equal(expected_values[counter], css_find(css)[counter].value) + + +def click_save(): + css = ".save-button" + + def is_shown(driver): + visible = css_find(css).first.visible + if visible: + # Even when waiting for visible, this fails sporadically. Adding in a small wait. + time.sleep(float(1)) + return visible + wait_for(is_shown) + css_click(css) + + +def fill_last_field(value): + newValue = css_find('#__new_advanced_key__ input').first + newValue.fill(value) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index d910d73085..61b4fee9f6 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,51 +1,67 @@ from lettuce import world, step -from factories import * -from django.core.management import call_command from lettuce.django import django_url -from django.conf import settings -from django.core.management import call_command from nose.tools import assert_true from nose.tools import assert_equal +from selenium.webdriver.support.ui import WebDriverWait + +from terrain.factories import UserFactory, RegistrationFactory, UserProfileFactory +from terrain.factories import CourseFactory, GroupFactory import xmodule.modulestore.django +from auth.authz import get_user_by_email from logging import getLogger logger = getLogger(__name__) ########### STEP HELPERS ############## + + @step('I (?:visit|access|open) the Studio homepage$') def i_visit_the_studio_homepage(step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('body.no-header', 10) + signin_css = 'a.action-signin' + assert world.browser.is_element_present_by_css(signin_css, 10) + @step('I am logged into Studio$') def i_am_logged_into_studio(step): log_into_studio() + @step('I confirm the alert$') def i_confirm_with_ok(step): world.browser.get_alert().accept() + @step(u'I press the "([^"]*)" delete icon$') def i_press_the_category_delete_icon(step, category): if category == 'section': css = 'a.delete-button.delete-section-button span.delete-icon' elif category == 'subsection': - css='a.delete-button.delete-subsection-button span.delete-icon' + css = 'a.delete-button.delete-subsection-button span.delete-icon' else: assert False, 'Invalid category: %s' % category css_click(css) + +@step('I have opened a new course in Studio$') +def i_have_opened_a_new_course(step): + clear_courses() + log_into_studio() + create_a_course() + ####### HELPER FUNCTIONS ############## + + def create_studio_user( uname='robot', email='robot+studio@edx.org', password='test', - is_staff=False): + is_staff=False): studio_user = UserFactory.build( - username=uname, + username=uname, email=email, password=password, is_staff=is_staff) @@ -58,6 +74,7 @@ def create_studio_user( user_profile = UserProfileFactory(user=studio_user) + def flush_xmodule_store(): # Flush and initialize the module store # It needs the templates because it creates new records @@ -70,26 +87,57 @@ def flush_xmodule_store(): xmodule.modulestore.django.modulestore().collection.drop() xmodule.templates.update_templates() -def assert_css_with_text(css,text): + +def assert_css_with_text(css, text): assert_true(world.browser.is_element_present_by_css(css, 5)) assert_equal(world.browser.find_by_css(css).text, text) + def css_click(css): + assert_true(world.browser.is_element_present_by_css(css, 5)) world.browser.find_by_css(css).first.click() + +def css_click_at(css, x=10, y=10): + ''' + A method to click at x,y coordinates of the element + rather than in the center of the element + ''' + assert_true(world.browser.is_element_present_by_css(css, 5)) + e = world.browser.find_by_css(css).first + e.action_chains.move_to_element_with_offset(e._element, x, y) + e.action_chains.click() + e.action_chains.perform() + + def css_fill(css, value): world.browser.find_by_css(css).first.fill(value) + +def css_find(css): + return world.browser.find_by_css(css) + + +def wait_for(func): + WebDriverWait(world.browser.driver, 10).until(func) + + +def id_find(id): + return world.browser.find_by_id(id) + + def clear_courses(): flush_xmodule_store() + def fill_in_course_info( name='Robot Super Course', org='MITx', num='101'): - css_fill('.new-course-name',name) - css_fill('.new-course-org',org) - css_fill('.new-course-number',num) + css_fill('.new-course-name', name) + css_fill('.new-course-org', org) + css_fill('.new-course-number', num) + def log_into_studio( uname='robot', @@ -99,7 +147,11 @@ def log_into_studio( create_studio_user(uname=uname, email=email, is_staff=is_staff) world.browser.cookies.delete() world.browser.visit(django_url('/')) - world.browser.is_element_present_by_css('body.no-header', 10) + signin_css = 'a.action-signin' + world.browser.is_element_present_by_css(signin_css, 10) + + # click the signin button + css_click(signin_css) login_form = world.browser.find_by_css('form#login_form') login_form.find_by_name('email').fill(email) @@ -108,19 +160,34 @@ def log_into_studio( assert_true(world.browser.is_element_present_by_css('.new-course-button', 5)) + def create_a_course(): - css_click('a.new-course-button') - fill_in_course_info() - css_click('input.new-course-save') - assert_true(world.browser.is_element_present_by_css('a#courseware-tab', 5)) + c = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + # Add the user to the instructor group of the course + # so they will have the permissions to see it in studio + g = GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') + u = get_user_by_email('robot+studio@edx.org') + u.groups.add(g) + u.save() + world.browser.reload() + + course_link_css = 'span.class-name' + css_click(course_link_css) + course_title_css = 'span.course-title' + assert_true(world.browser.is_element_present_by_css(course_title_css, 5)) + def add_section(name='My Section'): link_css = 'a.new-courseware-section-button' css_click(link_css) - name_css = '.new-section-name' - save_css = '.new-section-name-save' - css_fill(name_css,name) + name_css = 'input.new-section-name' + save_css = 'input.new-section-name-save' + css_fill(name_css, name) css_click(save_css) + span_css = 'span.section-name-span' + assert_true(world.browser.is_element_present_by_css(span_css, 5)) + def add_subsection(name='Subsection One'): css = 'a.new-subsection-item' @@ -128,4 +195,4 @@ def add_subsection(name='Subsection One'): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' css_fill(name_css, name) - css_click(save_css) \ No newline at end of file + css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 2c1cf6281a..e394165f08 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -2,49 +2,61 @@ from lettuce import world, step from common import * ############### ACTIONS #################### + + @step('There are no courses$') def no_courses(step): clear_courses() + @step('I click the New Course button$') def i_click_new_course(step): css_click('.new-course-button') + @step('I fill in the new course information$') def i_fill_in_a_new_course_information(step): fill_in_course_info() + @step('I create a new course$') def i_create_a_course(step): create_a_course() + @step('I click the course link in My Courses$') def i_click_the_course_link_in_my_courses(step): course_css = 'span.class-name' css_click(course_css) ############ ASSERTIONS ################### + + @step('the Courseware page has loaded in Studio$') def courseware_page_has_loaded_in_studio(step): - courseware_css = 'a#courseware-tab' - assert world.browser.is_element_present_by_css(courseware_css) + course_title_css = 'span.course-title' + assert world.browser.is_element_present_by_css(course_title_css) + @step('I see the course listed in My Courses$') def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' - assert_css_with_text(course_css,'Robot Super Course') + assert_css_with_text(course_css, 'Robot Super Course') + @step('the course is loaded$') def course_is_loaded(step): class_css = 'a.class-name' - assert_css_with_text(class_css,'Robot Super Course') + assert_css_with_text(class_css, 'Robot Super Course') + @step('I am on the "([^"]*)" tab$') def i_am_on_tab(step, tab_name): header_css = 'div.inner-wrapper h1' - assert_css_with_text(header_css,tab_name) + assert_css_with_text(header_css, tab_name) + @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert_css_with_text(link_css,'New Section') + assert_css_with_text(link_css, '+ New Section') diff --git a/cms/djangoapps/contentstore/features/factories.py b/cms/djangoapps/contentstore/features/factories.py index 389f2bac49..087ceaaa2d 100644 --- a/cms/djangoapps/contentstore/features/factories.py +++ b/cms/djangoapps/contentstore/features/factories.py @@ -3,6 +3,7 @@ from student.models import User, UserProfile, Registration from datetime import datetime import uuid + class UserProfileFactory(factory.Factory): FACTORY_FOR = UserProfile @@ -10,12 +11,14 @@ class UserProfileFactory(factory.Factory): name = 'Robot Studio' courseware = 'course.xml' + class RegistrationFactory(factory.Factory): FACTORY_FOR = Registration user = None activation_key = uuid.uuid4().hex + class UserFactory(factory.Factory): FACTORY_FOR = User @@ -28,4 +31,4 @@ class UserFactory(factory.Factory): is_active = True is_superuser = False last_login = datetime.now() - date_joined = datetime.now() \ No newline at end of file + date_joined = datetime.now() diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 8ac30e2170..ca67c477fb 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -2,54 +2,59 @@ from lettuce import world, step from common import * ############### ACTIONS #################### -@step('I have opened a new course in Studio$') -def i_have_opened_a_new_course(step): - clear_courses() - log_into_studio() - create_a_course() + @step('I click the new section link$') def i_click_new_section_link(step): link_css = 'a.new-courseware-section-button' css_click(link_css) + @step('I enter the section name and click save$') def i_save_section_name(step): name_css = '.new-section-name' save_css = '.new-section-name-save' - css_fill(name_css,'My Section') + css_fill(name_css, 'My Section') css_click(save_css) + @step('I have added a new section$') def i_have_added_new_section(step): add_section() - + + @step('I click the Edit link for the release date$') def i_click_the_edit_link_for_the_release_date(step): button_css = 'div.section-published-date a.edit-button' css_click(button_css) + @step('I save a new section release date$') def i_save_a_new_section_release_date(step): date_css = 'input.start-date.date.hasDatepicker' time_css = 'input.start-time.time.ui-timepicker-input' - css_fill(date_css,'12/25/2013') + css_fill(date_css, '12/25/2013') # click here to make the calendar go away css_click(time_css) - css_fill(time_css,'12:00am') + css_fill(time_css, '12:00am') css_click('a.save-button') + ############ ASSERTIONS ################### + + @step('I see my section on the Courseware page$') def i_see_my_section_on_the_courseware_page(step): section_css = 'span.section-name-span' - assert_css_with_text(section_css,'My Section') + assert_css_with_text(section_css, 'My Section') + @step('the section does not exist$') def section_does_not_exist(step): css = 'span.section-name-span' assert world.browser.is_element_not_present_by_css(css) + @step('I see a release date for my section$') def i_see_a_release_date_for_my_section(step): import re @@ -63,18 +68,21 @@ def i_see_a_release_date_for_my_section(step): date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]' time_regex = '[0-2][0-9]:[0-5][0-9]' match_string = '%s %s at %s' % (msg, date_regex, time_regex) - assert re.match(match_string,status_text) + assert re.match(match_string, status_text) + @step('I see a link to create a new subsection$') def i_see_a_link_to_create_a_new_subsection(step): css = 'a.new-subsection-item' assert world.browser.is_element_present_by_css(css) + @step('the section release date picker is not visible$') def the_section_release_date_picker_not_visible(step): css = 'div.edit-subsection-publish-settings' assert False, world.browser.find_by_css(css).visible + @step('the section release date is updated$') def the_section_release_date_is_updated(step): css = 'span.published-status' diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 8a6f93d33b..03a1c9524a 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -5,8 +5,8 @@ Feature: Sign in Scenario: Sign up from the homepage Given I visit the Studio homepage - When I click the link with the text "Sign up" + When I click the link with the text "Sign Up" And I fill in the registration form - And I press the "Create My Account" button on the registration form + And I press the Create My Account button on the registration form Then I should see be on the studio home page - And I should see the message "please click on the activation link in your email." \ No newline at end of file + And I should see the message "please click on the activation link in your email." diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 7794511f94..a786225ead 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -1,5 +1,6 @@ from lettuce import world, step + @step('I fill in the registration form$') def i_fill_in_the_registration_form(step): register_form = world.browser.find_by_css('form#register_form') @@ -9,15 +10,19 @@ def i_fill_in_the_registration_form(step): register_form.find_by_name('name').fill('Robot Studio') register_form.find_by_name('terms_of_service').check() -@step('I press the "([^"]*)" button on the registration form$') -def i_press_the_button_on_the_registration_form(step, button): + +@step('I press the Create My Account button on the registration form$') +def i_press_the_button_on_the_registration_form(step): register_form = world.browser.find_by_css('form#register_form') - register_form.find_by_value(button).click() + submit_css = 'button#submit' + register_form.find_by_css(submit_css).click() + @step('I should see be on the studio home page$') def i_should_see_be_on_the_studio_home_page(step): assert world.browser.find_by_css('div.inner-wrapper') + @step(u'I should see the message "([^"]*)"$') def i_should_see_the_message(step, msg): - assert world.browser.is_text_present(msg, 5) \ No newline at end of file + assert world.browser.is_text_present(msg, 5) diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 010678c0e8..00aa39455d 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -6,11 +6,13 @@ from nose.tools import assert_true, assert_false, assert_equal from logging import getLogger logger = getLogger(__name__) + @step(u'I have a course with no sections$') def have_a_course(step): clear_courses() course = CourseFactory.create() + @step(u'I have a course with 1 section$') def have_a_course_with_1_section(step): clear_courses() @@ -18,8 +20,9 @@ def have_a_course_with_1_section(step): section = ItemFactory.create(parent_location=course.location) subsection1 = ItemFactory.create( parent_location=section.location, - template = 'i4x://edx/templates/sequential/Empty', - display_name = 'Subsection One',) + template='i4x://edx/templates/sequential/Empty', + display_name='Subsection One',) + @step(u'I have a course with multiple sections$') def have_a_course_with_two_sections(step): @@ -28,19 +31,20 @@ def have_a_course_with_two_sections(step): section = ItemFactory.create(parent_location=course.location) subsection1 = ItemFactory.create( parent_location=section.location, - template = 'i4x://edx/templates/sequential/Empty', - display_name = 'Subsection One',) + template='i4x://edx/templates/sequential/Empty', + display_name='Subsection One',) section2 = ItemFactory.create( parent_location=course.location, display_name='Section Two',) subsection2 = ItemFactory.create( parent_location=section2.location, - template = 'i4x://edx/templates/sequential/Empty', - display_name = 'Subsection Alpha',) + template='i4x://edx/templates/sequential/Empty', + display_name='Subsection Alpha',) subsection3 = ItemFactory.create( parent_location=section2.location, - template = 'i4x://edx/templates/sequential/Empty', - display_name = 'Subsection Beta',) + template='i4x://edx/templates/sequential/Empty', + display_name='Subsection Beta',) + @step(u'I navigate to the course overview page$') def navigate_to_the_course_overview_page(step): @@ -48,15 +52,18 @@ def navigate_to_the_course_overview_page(step): course_locator = '.class-name' css_click(course_locator) + @step(u'I navigate to the courseware page of a course with multiple sections') def nav_to_the_courseware_page_of_a_course_with_multiple_sections(step): step.given('I have a course with multiple sections') step.given('I navigate to the course overview page') + @step(u'I add a section') def i_add_a_section(step): add_section(name='My New Section That I Just Added') + @step(u'I click the "([^"]*)" link$') def i_click_the_text_span(step, text): span_locator = '.toggle-button-sections span' @@ -65,16 +72,19 @@ def i_click_the_text_span(step, text): assert_equal(world.browser.find_by_css(span_locator).value, text) css_click(span_locator) + @step(u'I collapse the first section$') def i_collapse_a_section(step): collapse_locator = 'section.courseware-section a.collapse' css_click(collapse_locator) + @step(u'I expand the first section$') def i_expand_a_section(step): expand_locator = 'section.courseware-section a.expand' css_click(expand_locator) + @step(u'I see the "([^"]*)" link$') def i_see_the_span_with_text(step, text): span_locator = '.toggle-button-sections span' @@ -82,6 +92,7 @@ def i_see_the_span_with_text(step, text): assert_equal(world.browser.find_by_css(span_locator).value, text) assert_true(world.browser.find_by_css(span_locator).visible) + @step(u'I do not see the "([^"]*)" link$') def i_do_not_see_the_span_with_text(step, text): # Note that the span will exist on the page but not be visible @@ -89,6 +100,7 @@ def i_do_not_see_the_span_with_text(step, text): assert_true(world.browser.is_element_present_by_css(span_locator)) assert_false(world.browser.find_by_css(span_locator).visible) + @step(u'all sections are expanded$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' @@ -96,9 +108,10 @@ def all_sections_are_expanded(step): for s in subsections: assert_true(s.visible) + @step(u'all sections are collapsed$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' subsections = world.browser.find_by_css(subsection_locator) for s in subsections: - assert_false(s.visible) \ No newline at end of file + assert_false(s.visible) diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index ea614d3feb..e2041b8dbf 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -2,6 +2,8 @@ from lettuce import world, step from common import * ############### ACTIONS #################### + + @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): clear_courses() @@ -9,31 +11,37 @@ def i_have_opened_a_new_course_section(step): create_a_course() add_section() + @step('I click the New Subsection link') def i_click_the_new_subsection_link(step): css = 'a.new-subsection-item' css_click(css) + @step('I enter the subsection name and click save$') def i_save_subsection_name(step): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css,'Subsection One') + css_fill(name_css, 'Subsection One') css_click(save_css) + @step('I have added a new subsection$') def i_have_added_a_new_subsection(step): add_subsection() ############ ASSERTIONS ################### + + @step('I see my subsection on the Courseware page$') def i_see_my_subsection_on_the_courseware_page(step): css = 'span.subsection-name' - assert world.browser.is_element_present_by_css(css) + assert world.browser.is_element_present_by_css(css) css = 'span.subsection-name-value' - assert_css_with_text(css,'Subsection One') + assert_css_with_text(css, 'Subsection One') + @step('the subsection does not exist$') def the_subsection_does_not_exist(step): css = 'span.subsection-name' - assert world.browser.is_element_not_present_by_css(css) \ No newline at end of file + assert world.browser.is_element_not_present_by_css(css) diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone.py index 2357cd1dbd..abf04f3da3 100644 --- a/cms/djangoapps/contentstore/management/commands/clone.py +++ b/cms/djangoapps/contentstore/management/commands/clone.py @@ -14,6 +14,7 @@ from auth.authz import _copy_course_group # To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3 # + class Command(BaseCommand): help = \ '''Clone a MongoDB backed course to another location''' diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index 0313f7faed..e3cac7de02 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -15,26 +15,32 @@ from auth.authz import _delete_course_group # To run from command line: rake cms:delete_course LOC=MITx/111/Foo1 # + class Command(BaseCommand): help = \ '''Delete a MongoDB backed course''' def handle(self, *args, **options): - if len(args) != 1: - raise CommandError("delete_course requires one argument: ") + if len(args) != 1 and len(args) != 2: + raise CommandError("delete_course requires one or more arguments: |commit|") loc_str = args[0] + commit = False + if len(args) == 2: + commit = args[1] == 'commit' + + if commit: + print 'Actually going to delete the course from DB....' + ms = modulestore('direct') cs = contentstore() if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"): if query_yes_no("Are you sure. This action cannot be undone!", default="no"): loc = CourseDescriptor.id_to_location(loc_str) - if delete_course(ms, cs, loc) == True: + if delete_course(ms, cs, loc, commit) == True: print 'removing User permissions from course....' # in the django layer, we need to remove all the user permissions groups associated with this course - _delete_course_group(loc) - - - + if commit: + _delete_course_group(loc) diff --git a/cms/djangoapps/contentstore/management/commands/prompt.py b/cms/djangoapps/contentstore/management/commands/prompt.py index 9c8fd81d45..211c48406c 100644 --- a/cms/djangoapps/contentstore/management/commands/prompt.py +++ b/cms/djangoapps/contentstore/management/commands/prompt.py @@ -1,5 +1,6 @@ import sys + def query_yes_no(question, default="yes"): """Ask a yes/no question via raw_input() and return their answer. @@ -30,4 +31,4 @@ def query_yes_no(question, default="yes"): return valid[choice] else: sys.stdout.write("Please respond with 'yes' or 'no' "\ - "(or 'y' or 'n').\n") \ No newline at end of file + "(or 'y' or 'n').\n") diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 0017010885..7ed4505c94 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -1,84 +1,92 @@ -import logging -from static_replace import replace_urls +from static_replace import replace_static_urls from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from lxml import etree -import re -from django.http import HttpResponseBadRequest, Http404 +from django.http import Http404 -def get_module_info(store, location, parent_location = None, rewrite_static_links = False): - try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) - except ItemNotFoundError: - raise Http404 - data = module.definition['data'] - if rewrite_static_links: - data = replace_urls(module.definition['data'], course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None])) +def get_module_info(store, location, parent_location=None, rewrite_static_links=False): + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except ItemNotFoundError: + # create a new one + template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) + module = store.clone_item(template_location, location) - return { + data = module.definition['data'] + if rewrite_static_links: + data = replace_static_urls( + module.definition['data'], + None, + course_namespace=Location([ + module.location.tag, + module.location.org, + module.location.course, + None, + None + ]) + ) + + return { 'id': module.location.url(), 'data': data, 'metadata': module.metadata } + def set_module_info(store, location, post_data): - module = None - isNew = False - try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) - except: - pass + module = None + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except: + pass - if module is None: - # new module at this location - # presume that we have an 'Empty' template - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) - isNew = True + if module is None: + # new module at this location + # presume that we have an 'Empty' template + template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) + module = store.clone_item(template_location, location) - if post_data.get('data') is not None: - data = post_data['data'] - store.update_item(location, data) - - # cdodge: note calling request.POST.get('children') will return None if children is an empty array - # so it lead to a bug whereby the last component to be deleted in the UI was not actually - # deleting the children object from the children collection - if 'children' in post_data and post_data['children'] is not None: - children = post_data['children'] - store.update_children(location, children) + if post_data.get('data') is not None: + data = post_data['data'] + store.update_item(location, data) - # cdodge: also commit any metadata which might have been passed along in the - # POST from the client, if it is there - # NOTE, that the postback is not the complete metadata, as there's system metadata which is - # not presented to the end-user for editing. So let's fetch the original and - # 'apply' the submitted metadata, so we don't end up deleting system metadata - if post_data.get('metadata') is not None: - posted_metadata = post_data['metadata'] + # cdodge: note calling request.POST.get('children') will return None if children is an empty array + # so it lead to a bug whereby the last component to be deleted in the UI was not actually + # deleting the children object from the children collection + if 'children' in post_data and post_data['children'] is not None: + children = post_data['children'] + store.update_children(location, children) - # update existing metadata with submitted metadata (which can be partial) - # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key in posted_metadata.keys(): - - # let's strip out any metadata fields from the postback which have been identified as system metadata - # and therefore should not be user-editable, so we should accept them back from the client - if metadata_key in module.system_metadata_fields: - del posted_metadata[metadata_key] - elif posted_metadata[metadata_key] is None: - # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in module.metadata: - del module.metadata[metadata_key] - del posted_metadata[metadata_key] - - # overlay the new metadata over the modulestore sourced collection to support partial updates - module.metadata.update(posted_metadata) - - # commit to datastore - store.update_metadata(location, module.metadata) + # cdodge: also commit any metadata which might have been passed along in the + # POST from the client, if it is there + # NOTE, that the postback is not the complete metadata, as there's system metadata which is + # not presented to the end-user for editing. So let's fetch the original and + # 'apply' the submitted metadata, so we don't end up deleting system metadata + if post_data.get('metadata') is not None: + posted_metadata = post_data['metadata'] + + # update existing metadata with submitted metadata (which can be partial) + # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' + for metadata_key in posted_metadata.keys(): + + # let's strip out any metadata fields from the postback which have been identified as system metadata + # and therefore should not be user-editable, so we should accept them back from the client + if metadata_key in module.system_metadata_fields: + del posted_metadata[metadata_key] + elif posted_metadata[metadata_key] is None: + # remove both from passed in collection as well as the collection read in from the modulestore + if metadata_key in module.metadata: + del module.metadata[metadata_key] + del posted_metadata[metadata_key] + + # overlay the new metadata over the modulestore sourced collection to support partial updates + module.metadata.update(posted_metadata) + + # commit to datastore + store.update_metadata(location, module.metadata) diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py index ab83c52438..cb01cb447e 100644 --- a/cms/djangoapps/contentstore/tests/factories.py +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -1,8 +1,9 @@ from factory import Factory from datetime import datetime -import uuid -from time import gmtime from uuid import uuid4 +from student.models import (User, UserProfile, Registration, + CourseEnrollmentAllowed) +from django.contrib.auth.models import Group from django.contrib.auth.models import Group @@ -13,54 +14,44 @@ from student.models import (User, UserProfile, Registration, CourseEnrollmentAllowed) from django.contrib.auth.models import Group -def XMODULE_COURSE_CREATION(class_to_create, **kwargs): - return XModuleCourseFactory._create(class_to_create, **kwargs) +class UserProfileFactory(Factory): + FACTORY_FOR = UserProfile -def XMODULE_ITEM_CREATION(class_to_create, **kwargs): - return XModuleItemFactory._create(class_to_create, **kwargs) + user = None + name = 'Robot Studio' + courseware = 'course.xml' -class XModuleCourseFactory(Factory): - """ - Factory for XModule courses. - """ - ABSTRACT_FACTORY = True - _creation_function = (XMODULE_COURSE_CREATION,) +class RegistrationFactory(Factory): + FACTORY_FOR = Registration - @classmethod - def _create(cls, target_class, *args, **kwargs): + user = None + activation_key = uuid4().hex - template = Location('i4x', 'edx', 'templates', 'course', 'Empty') - org = kwargs.get('org') - number = kwargs.get('number') - display_name = kwargs.get('display_name') - location = Location('i4x', org, number, - 'course', Location.clean(display_name)) - store = modulestore('direct') +class UserFactory(Factory): + FACTORY_FOR = User - # Write the data to the mongo datastore - new_course = store.clone_item(template, location) + username = 'robot' + email = 'robot@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Tester' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime.now() + date_joined = datetime.now() - # This metadata code was copied from cms/djangoapps/contentstore/views.py - if display_name is not None: - new_course.metadata['display_name'] = display_name - new_course.metadata['data_dir'] = uuid4().hex - new_course.metadata['start'] = stringify_time(gmtime()) - new_course.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}, - {"type": "progress", "name": "Progress"}] +class GroupFactory(Factory): + FACTORY_FOR = Group - # Update the data in the mongo datastore - store.update_metadata(new_course.location.url(), new_course.own_metadata) + name = 'test_group' - return new_course -class Course: - pass +class CourseEnrollmentAllowedFactory(Factory): + FACTORY_FOR = CourseEnrollmentAllowed class CourseFactory(XModuleCourseFactory): FACTORY_FOR = Course @@ -157,6 +148,3 @@ class GroupFactory(Factory): class CourseEnrollmentAllowedFactory(Factory): FACTORY_FOR = CourseEnrollmentAllowed - - email = 'test@edx.org' - course_id = 'edX/test/2012_Fall' diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py new file mode 100644 index 0000000000..8e4a016a0f --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -0,0 +1,512 @@ +import json +import shutil +from django.test.client import Client +from django.test.utils import override_settings +from django.conf import settings +from django.core.urlresolvers import reverse +from path import path +from tempdir import mkdtemp_clean +import json +from fs.osfs import OSFS +import copy +from mock import Mock +from json import dumps, loads + +from student.models import Registration +from django.contrib.auth.models import User +from cms.djangoapps.contentstore.utils import get_modulestore + +from utils import ModuleStoreTestCase, parse_json +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from xmodule.modulestore import Location +from xmodule.modulestore.store_utilities import clone_course +from xmodule.modulestore.store_utilities import delete_course +from xmodule.modulestore.django import modulestore, _MODULESTORES +from xmodule.contentstore.django import contentstore +from xmodule.templates import update_templates +from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.templates import update_templates + +from xmodule.capa_module import CapaDescriptor +from xmodule.course_module import CourseDescriptor +from xmodule.seq_module import SequenceDescriptor +from xmodule.modulestore.exceptions import ItemNotFoundError + +TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) +TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') +TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') + + +@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +class ContentStoreToyCourseTest(ModuleStoreTestCase): + """ + Tests that rely on the toy courses. + TODO: refactor using CourseFactory so they do not. + """ + def setUp(self): + uname = 'testuser' + email = 'test+courses@edx.org' + password = 'foo' + + # Create the use so we can log them in. + self.user = User.objects.create_user(uname, email, password) + + # Note that we do not actually need to do anything + # for registration if we directly mark them active. + self.user.is_active = True + # Staff has access to view all courses + self.user.is_staff = True + self.user.save() + + self.client = Client() + self.client.login(username=uname, password=password) + + + def check_edit_unit(self, test_course_name): + import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) + + for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): + print "Checking ", descriptor.location.url() + print descriptor.__class__, descriptor.location + resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + self.assertEqual(resp.status_code, 200) + + def test_edit_unit_toy(self): + self.check_edit_unit('toy') + + def test_edit_unit_full(self): + self.check_edit_unit('full') + + def test_static_tab_reordering(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + # reverse the ordering + reverse_tabs = [] + for tab in course.tabs: + if tab['type'] == 'static_tab': + reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) + + resp = self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json") + + course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + # compare to make sure that the tabs information is in the expected order after the server call + course_tabs = [] + for tab in course.tabs: + if tab['type'] == 'static_tab': + course_tabs.append('i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) + + self.assertEqual(reverse_tabs, course_tabs) + + def test_about_overrides(self): + ''' + This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html + while there is a base definition in /about/effort.html + ''' + import_from_xml(modulestore(), 'common/test/data/', ['full']) + ms = modulestore('direct') + effort = ms.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) + self.assertEqual(effort.definition['data'], '6 hours') + + # this one should be in a non-override folder + effort = ms.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None])) + self.assertEqual(effort.definition['data'], 'TBD') + + def test_remove_hide_progress_tab(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + cs = contentstore() + + source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + course = ms.get_item(source_location) + self.assertNotIn('hide_progress_tab', course.metadata) + + def test_clone_course(self): + + course_data = { + 'template': 'i4x://edx/templates/course/Empty', + 'org': 'MITx', + 'number': '999', + 'display_name': 'Robot Super Course', + } + + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + resp = self.client.post(reverse('create_new_course'), course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + + ms = modulestore('direct') + cs = contentstore() + + source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course') + + clone_course(ms, cs, source_location, dest_location) + + # now loop through all the units in the course and verify that the clone can render them, which + # means the objects are at least present + items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + self.assertGreater(len(items), 0) + clone_items = ms.get_items(Location(['i4x', 'MITx', '999', 'vertical', None])) + self.assertGreater(len(clone_items), 0) + for descriptor in items: + new_loc = descriptor.location._replace(org='MITx', course='999') + print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) + resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) + self.assertEqual(resp.status_code, 200) + + def test_delete_course(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + cs = contentstore() + + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + delete_course(ms, cs, location, commit=True) + + items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + self.assertEqual(len(items), 0) + + def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): + fs = OSFS(root_dir / 'test_export') + self.assertTrue(fs.exists(dirname)) + + query_loc = Location('i4x', location.org, location.course, category_name, None) + items = modulestore.get_items(query_loc) + + for item in items: + fs = OSFS(root_dir / ('test_export/' + dirname)) + self.assertTrue(fs.exists(item.location.name + filename_suffix)) + + def test_export_course(self): + ms = modulestore('direct') + cs = contentstore() + + import_from_xml(ms, 'common/test/data/', ['full']) + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + root_dir = path(mkdtemp_clean()) + + print 'Exporting to tempdir = {0}'.format(root_dir) + + # export out to a tempdir + export_to_xml(ms, cs, location, root_dir, 'test_export') + + # check for static tabs + self.verify_content_existence(ms, root_dir, location, 'tabs', 'static_tab', '.html') + + # check for custom_tags + self.verify_content_existence(ms, root_dir, location, 'info', 'course_info', '.html') + + # check for custom_tags + self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template') + + # check for graiding_policy.json + fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + self.assertTrue(fs.exists('grading_policy.json')) + + course = ms.get_item(location) + # compare what's on disk compared to what we have in our course + with fs.open('grading_policy.json','r') as grading_policy: + on_disk = loads(grading_policy.read()) + self.assertEqual(on_disk, course.definition['data']['grading_policy']) + + #check for policy.json + self.assertTrue(fs.exists('policy.json')) + + # compare what's on disk to what we have in the course module + with fs.open('policy.json','r') as course_policy: + on_disk = loads(course_policy.read()) + self.assertIn('course/6.002_Spring_2012', on_disk) + self.assertEqual(on_disk['course/6.002_Spring_2012'], course.metadata) + + # remove old course + delete_course(ms, cs, location) + + # reimport + import_from_xml(ms, root_dir, ['test_export']) + + items = ms.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + self.assertGreater(len(items), 0) + for descriptor in items: + print "Checking {0}....".format(descriptor.location.url()) + resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + self.assertEqual(resp.status_code, 200) + + shutil.rmtree(root_dir) + + def test_course_handouts_rewrites(self): + ms = modulestore('direct') + cs = contentstore() + + # import a test course + import_from_xml(ms, 'common/test/data/', ['full']) + + handout_location = Location(['i4x', 'edX', 'full', 'course_info', 'handouts']) + + # get module info + resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location})) + + # make sure we got a successful response + self.assertEqual(resp.status_code, 200) + + # check that /static/ has been converted to the full path + # note, we know the link it should be because that's what in the 'full' course in the test data + self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + + + +class ContentStoreTest(ModuleStoreTestCase): + """ + Tests for the CMS ContentStore application. + """ + def setUp(self): + """ + These tests need a user in the DB so that the django Test Client + can log them in. + They inherit from the ModuleStoreTestCase class so that the mongodb collection + will be cleared out before each test case execution and deleted + afterwards. + """ + uname = 'testuser' + email = 'test+courses@edx.org' + password = 'foo' + + # Create the use so we can log them in. + self.user = User.objects.create_user(uname, email, password) + + # Note that we do not actually need to do anything + # for registration if we directly mark them active. + self.user.is_active = True + # Staff has access to view all courses + self.user.is_staff = True + self.user.save() + + self.client = Client() + self.client.login(username=uname, password=password) + + self.course_data = { + 'template': 'i4x://edx/templates/course/Empty', + 'org': 'MITx', + 'number': '999', + 'display_name': 'Robot Super Course', + } + + def test_create_course(self): + """Test new course creation - happy path""" + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + + def test_create_course_duplicate_course(self): + """Test new course creation - error path""" + resp = self.client.post(reverse('create_new_course'), self.course_data) + resp = self.client.post(reverse('create_new_course'), self.course_data) + data = parse_json(resp) + self.assertEqual(resp.status_code, 200) + self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.') + + def test_create_course_duplicate_number(self): + """Test new course creation - error path""" + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.course_data['display_name'] = 'Robot Super Course Two' + + resp = self.client.post(reverse('create_new_course'), self.course_data) + data = parse_json(resp) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(data['ErrMsg'], + 'There is already a course defined with the same organization and course number.') + + def test_create_course_with_bad_organization(self): + """Test new course creation - error path for bad organization name""" + self.course_data['org'] = 'University of California, Berkeley' + resp = self.client.post(reverse('create_new_course'), self.course_data) + data = parse_json(resp) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(data['ErrMsg'], + "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") + + def test_course_index_view_with_no_courses(self): + """Test viewing the index page with no courses""" + # Create a course so there is something to view + resp = self.client.get(reverse('index')) + self.assertContains(resp, + '

          My Courses

          ', + status_code=200, + html=True) + + def test_course_factory(self): + """Test that the course factory works correctly.""" + course = CourseFactory.create() + self.assertIsInstance(course, CourseDescriptor) + + def test_item_factory(self): + """Test that the item factory works correctly.""" + course = CourseFactory.create() + item = ItemFactory.create(parent_location=course.location) + self.assertIsInstance(item, SequenceDescriptor) + + def test_course_index_view_with_course(self): + """Test viewing the index page with an existing course""" + CourseFactory.create(display_name='Robot Super Educational Course') + resp = self.client.get(reverse('index')) + self.assertContains(resp, + 'Robot Super Educational Course', + status_code=200, + html=True) + + def test_course_overview_view_with_course(self): + """Test viewing the course overview page with an existing course""" + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + data = { + 'org': 'MITx', + 'course': '999', + 'name': Location.clean('Robot Super Course'), + } + + resp = self.client.get(reverse('course_index', kwargs=data)) + self.assertContains(resp, + '
          ', + status_code=200, + html=True) + + def test_clone_item(self): + """Test cloning an item. E.g. creating a new section""" + CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + section_data = { + 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', + 'template': 'i4x://edx/templates/chapter/Empty', + 'display_name': 'Section One', + } + + resp = self.client.post(reverse('clone_item'), section_data) + + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertRegexpMatches(data['id'], + '^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$') + + def test_capa_module(self): + """Test that a problem treats markdown specially.""" + course = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + + problem_data = { + 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', + 'template': 'i4x://edx/templates/problem/Blank_Common_Problem' + } + + resp = self.client.post(reverse('clone_item'), problem_data) + + self.assertEqual(resp.status_code, 200) + payload = parse_json(resp) + problem_loc = payload['id'] + problem = get_modulestore(problem_loc).get_item(problem_loc) + # should be a CapaDescriptor + self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") + context = problem.get_context() + self.assertIn('markdown', context, "markdown is missing from context") + self.assertIn('markdown', problem.metadata, "markdown is missing from metadata") + self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") + + def test_import_metadata_with_attempts_empty_string(self): + import_from_xml(modulestore(), 'common/test/data/', ['simple']) + ms = modulestore('direct') + did_load_item = False + try: + ms.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) + did_load_item = True + except ItemNotFoundError: + pass + + # make sure we found the item (e.g. it didn't error while loading) + self.assertTrue(did_load_item) + + def test_metadata_inheritance(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + + ms = modulestore('direct') + course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + verticals = ms.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) + + # let's assert on the metadata_inheritance on an existing vertical + for vertical in verticals: + self.assertIn('xqa_key', vertical.metadata) + self.assertEqual(course.metadata['xqa_key'], vertical.metadata['xqa_key']) + + self.assertGreater(len(verticals), 0) + + new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component') + source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + + # crate a new module and add it as a child to a vertical + ms.clone_item(source_template_location, new_component_location) + parent = verticals[0] + ms.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()]) + + # flush the cache + ms.get_cached_metadata_inheritance_tree(new_component_location, -1) + new_module = ms.get_item(new_component_location) + + # check for grace period definition which should be defined at the course level + self.assertIn('graceperiod', new_module.metadata) + + self.assertEqual(parent.metadata['graceperiod'], new_module.metadata['graceperiod']) + + self.assertEqual(course.metadata['xqa_key'], new_module.metadata['xqa_key']) + + # + # now let's define an override at the leaf node level + # + new_module.metadata['graceperiod'] = '1 day' + ms.update_metadata(new_module.location, new_module.metadata) + + # flush the cache and refetch + ms.get_cached_metadata_inheritance_tree(new_component_location, -1) + new_module = ms.get_item(new_component_location) + + self.assertIn('graceperiod', new_module.metadata) + self.assertEqual('1 day', new_module.metadata['graceperiod']) + + +class TemplateTestCase(ModuleStoreTestCase): + + def test_template_cleanup(self): + ms = modulestore('direct') + + # insert a bogus template in the store + bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus') + source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + + ms.clone_item(source_template_location, bogus_template_location) + + verify_create = ms.get_item(bogus_template_location) + self.assertIsNotNone(verify_create) + + # now run cleanup + update_templates() + + # now try to find dangling template, it should not be in DB any longer + asserted = False + try: + verify_create = ms.get_item(bogus_template_location) + except ItemNotFoundError: + asserted = True + + self.assertTrue(asserted) + + diff --git a/cms/djangoapps/contentstore/tests/test_core_caching.py b/cms/djangoapps/contentstore/tests/test_core_caching.py index 0cb4a4930c..676627a045 100644 --- a/cms/djangoapps/contentstore/tests/test_core_caching.py +++ b/cms/djangoapps/contentstore/tests/test_core_caching.py @@ -1,7 +1,8 @@ -from django.test.testcases import TestCase from cache_toolbox.core import get_cached_content, set_cached_content, del_cached_content from xmodule.modulestore import Location from xmodule.contentstore.content import StaticContent +from django.test import TestCase + class Content: def __init__(self, location, content): @@ -11,6 +12,7 @@ class Content: def get_id(self): return StaticContent.get_id_from_location(self.location) + class CachingTestCase(TestCase): # Tests for https://edx.lighthouseapp.com/projects/102637/tickets/112-updating-asset-does-not-refresh-the-cached-copy unicodeLocation = Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg') @@ -32,7 +34,3 @@ class CachingTestCase(TestCase): 'should not be stored in cache with unicodeLocation') self.assertEqual(None, get_cached_content(self.nonUnicodeLocation), 'should not be stored in cache with nonUnicodeLocation') - - - - diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 74eff6e9cc..5560d2e39b 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,43 +1,57 @@ -from django.test.testcases import TestCase import datetime -import time +import json +import copy +from util import converters +from util.converters import jsdate_to_time + from django.contrib.auth.models import User -import xmodule from django.test.client import Client from django.core.urlresolvers import reverse -from xmodule.modulestore import Location -from cms.djangoapps.models.settings.course_details import CourseDetails,\ - CourseSettingsEncoder -import json -from util import converters -import calendar -from util.converters import jsdate_to_time from django.utils.timezone import UTC + +from xmodule.modulestore import Location +from cms.djangoapps.models.settings.course_details import (CourseDetails, + CourseSettingsEncoder) from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.contentstore.utils import get_modulestore -import copy + +from django.test import TestCase +from utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.django import modulestore + # YYYY-MM-DDThh:mm:ss.s+/-HH:MM class ConvertersTestCase(TestCase): @staticmethod def struct_to_datetime(struct_time): - return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour, - struct_time.tm_min, struct_time.tm_sec, tzinfo = UTC()) - + return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, struct_time.tm_mday, struct_time.tm_hour, + struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) + def compare_dates(self, date1, date2, expected_delta): dt1 = ConvertersTestCase.struct_to_datetime(date1) dt2 = ConvertersTestCase.struct_to_datetime(date2) self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + str(date2) + "!=" + str(expected_delta)) - + def test_iso_to_struct(self): self.compare_dates(converters.jsdate_to_time("2013-01-01"), converters.jsdate_to_time("2012-12-31"), datetime.timedelta(days=1)) self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), converters.jsdate_to_time("2012-12-31T23"), datetime.timedelta(hours=1)) self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), converters.jsdate_to_time("2012-12-31T23:59"), datetime.timedelta(minutes=1)) self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), converters.jsdate_to_time("2012-12-31T23:59:59"), datetime.timedelta(seconds=1)) - - -class CourseTestCase(TestCase): + + +class CourseTestCase(ModuleStoreTestCase): def setUp(self): + """ + These tests need a user in the DB so that the django Test Client + can log them in. + They inherit from the ModuleStoreTestCase class so that the mongodb collection + will be cleared out before each test case execution and deleted + afterwards. + """ uname = 'testuser' email = 'test+courses@edx.org' password = 'foo' @@ -52,36 +66,16 @@ class CourseTestCase(TestCase): self.user.is_staff = True self.user.save() - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - xmodule.templates.update_templates() - self.client = Client() self.client.login(username=uname, password=password) - self.course_data = { - 'template': 'i4x://edx/templates/course/Empty', - 'org': 'MITx', - 'number': '999', - 'display_name': 'Robot Super Course', - } - self.course_location = Location('i4x', 'MITx', '999', 'course', 'Robot_Super_Course') - self.create_course() + t = 'i4x://edx/templates/course/Empty' + o = 'MITx' + n = '999' + dn = 'Robot Super Course' + self.course_location = Location('i4x', o, n, 'course', 'Robot_Super_Course') + CourseFactory.create(template=t, org=o, number=n, display_name=dn) - def tearDown(self): - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - - def create_course(self): - """Create new course""" - self.client.post(reverse('create_new_course'), self.course_data) class CourseDetailsTestCase(CourseTestCase): def test_virgin_fetch(self): @@ -94,7 +88,7 @@ class CourseDetailsTestCase(CourseTestCase): self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview) self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video)) self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) - + def test_encoder(self): details = CourseDetails.fetch(self.course_location) jsondetails = json.dumps(details, cls=CourseSettingsEncoder) @@ -108,7 +102,7 @@ class CourseDetailsTestCase(CourseTestCase): self.assertEqual(jsondetails['overview'], "", "overview somehow initialized") self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") self.assertIsNone(jsondetails['effort'], "effort somehow initialized") - + def test_update_and_fetch(self): ## NOTE: I couldn't figure out how to validly test time setting w/ all the conversions jsondetails = CourseDetails.fetch(self.course_location) @@ -126,6 +120,7 @@ class CourseDetailsTestCase(CourseTestCase): self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).effort, jsondetails.effort, "After set effort") + class CourseDetailsViewTest(CourseTestCase): def alter_field(self, url, details, field, val): setattr(details, field, val) @@ -136,9 +131,9 @@ class CourseDetailsViewTest(CourseTestCase): payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date) payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start) payload['enrollment_end'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_end) - resp = self.client.post(url, json.dumps(payload), "application/json") + resp = self.client.post(url, json.dumps(payload), "application/json") self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val)) - + @staticmethod def convert_datetime_to_iso(datetime): if datetime is not None: @@ -146,27 +141,22 @@ class CourseDetailsViewTest(CourseTestCase): else: return None - def test_update_and_fetch(self): details = CourseDetails.fetch(self.course_location) - - resp = self.client.get(reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, - 'name' : self.course_location.name })) - self.assertContains(resp, '
        1. Course Details
        2. ', status_code=200, html=True) - # resp s/b json from here on - url = reverse('course_settings', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, - 'name' : self.course_location.name, 'section' : 'details' }) + # resp s/b json from here on + url = reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course, + 'name': self.course_location.name, 'section': 'details'}) resp = self.client.get(url) self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get") utc = UTC() - self.alter_field(url, details, 'start_date', datetime.datetime(2012,11,12,1,30, tzinfo=utc)) - self.alter_field(url, details, 'start_date', datetime.datetime(2012,11,1,13,30, tzinfo=utc)) - self.alter_field(url, details, 'end_date', datetime.datetime(2013,2,12,1,30, tzinfo=utc)) - self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012,10,12,1,30, tzinfo=utc)) + self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 12, 1, 30, tzinfo=utc)) + self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 1, 13, 30, tzinfo=utc)) + self.alter_field(url, details, 'end_date', datetime.datetime(2013, 2, 12, 1, 30, tzinfo=utc)) + self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012, 10, 12, 1, 30, tzinfo=utc)) - self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012,11,15,1,30, tzinfo=utc)) + self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012, 11, 15, 1, 30, tzinfo=utc)) self.alter_field(url, details, 'overview', "Overview") self.alter_field(url, details, 'intro_video', "intro_video") self.alter_field(url, details, 'effort', "effort") @@ -179,7 +169,7 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") - + def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: if field in encoded and encoded[field] is not None: @@ -191,14 +181,15 @@ class CourseDetailsViewTest(CourseTestCase): else: details_encoded = jsdate_to_time(details[field]) dt2 = ConvertersTestCase.struct_to_datetime(details_encoded) - + expected_delta = datetime.timedelta(0) self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) else: self.fail(field + " missing from encoded but in details at " + context) elif field in encoded and encoded[field] is not None: self.fail(field + " included in encoding but missing from details at " + context) - + + class CourseGradingTest(CourseTestCase): def test_initial_grader(self): descriptor = get_modulestore(self.course_location).get_item(self.course_location) @@ -218,58 +209,117 @@ class CourseGradingTest(CourseTestCase): self.assertEqual(self.course_location, test_grader.course_location, "Course locations") self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") - + for i, grader in enumerate(test_grader.graders): subgrader = CourseGradingModel.fetch_grader(self.course_location, i) self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal") - + subgrader = CourseGradingModel.fetch_grader(self.course_location.list(), 0) self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list") - + def test_fetch_cutoffs(self): test_grader = CourseGradingModel.fetch_cutoffs(self.course_location) # ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think) self.assertIsNotNone(test_grader, "No cutoffs via fetch") - + test_grader = CourseGradingModel.fetch_cutoffs(self.course_location.url()) self.assertIsNotNone(test_grader, "No cutoffs via fetch with url") - + def test_fetch_grace(self): test_grader = CourseGradingModel.fetch_grace_period(self.course_location) # almost a worthless test self.assertIn('grace_period', test_grader, "No grace via fetch") - + test_grader = CourseGradingModel.fetch_grace_period(self.course_location.url()) self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url") - + def test_update_from_json(self): test_grader = CourseGradingModel.fetch(self.course_location) altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update") - + test_grader.graders[0]['weight'] = test_grader.graders[0].get('weight') * 2 altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Weight[0] * 2") - + test_grader.grade_cutoffs['D'] = 0.3 altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") - - test_grader.grace_period = {'hours' : '4'} + + test_grader.grace_period = {'hours' : 4, 'minutes' : 5, 'seconds': 0} altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") - + def test_update_grader_from_json(self): test_grader = CourseGradingModel.fetch(self.course_location) altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update") - + test_grader.graders[1]['min_count'] = test_grader.graders[1].get('min_count') + 2 altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "min_count[1] + 2") - + test_grader.graders[1]['drop_count'] = test_grader.graders[1].get('drop_count') + 1 altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") - - + +class CourseMetadataEditingTest(CourseTestCase): + def setUp(self): + CourseTestCase.setUp(self) + # add in the full class too + import_from_xml(modulestore(), 'common/test/data/', ['full']) + self.fullcourse_location = Location(['i4x','edX','full','course','6.002_Spring_2012', None]) + + + def test_fetch_initial_fields(self): + test_model = CourseMetadata.fetch(self.course_location) + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") + + test_model = CourseMetadata.fetch(self.fullcourse_location) + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') + self.assertIn('display_name', test_model, 'full missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Testing', "not expected value") + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') + self.assertIn('showanswer', test_model, 'showanswer field ') + self.assertIn('xqa_key', test_model, 'xqa_key field ') + + def test_update_from_json(self): + test_model = CourseMetadata.update_from_json(self.course_location, + { "a" : 1, + "b_a_c_h" : { "c" : "test" }, + "test_text" : "a text string"}) + self.update_check(test_model) + # try fresh fetch to ensure persistence + test_model = CourseMetadata.fetch(self.course_location) + self.update_check(test_model) + # now change some of the existing metadata + test_model = CourseMetadata.update_from_json(self.course_location, + { "a" : 2, + "display_name" : "jolly roger"}) + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value") + self.assertIn('a', test_model, 'Missing revised a metadata field') + self.assertEqual(test_model['a'], 2, "a not expected value") + + def update_check(self, test_model): + self.assertIn('display_name', test_model, 'Missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") + self.assertIn('a', test_model, 'Missing new a metadata field') + self.assertEqual(test_model['a'], 1, "a not expected value") + self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field') + self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value") + self.assertIn('test_text', test_model, 'Missing test_text metadata field') + self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value") + + + def test_delete_key(self): + test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']}) + # ensure no harm + self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') + self.assertIn('display_name', test_model, 'full missing editable metadata field') + self.assertEqual(test_model['display_name'], 'Testing', "not expected value") + self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') + # check for deletion effectiveness + self.assertNotIn('showanswer', test_model, 'showanswer field still in') + self.assertNotIn('xqa_key', test_model, 'xqa_key field still in') \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index 96e4468b31..c57f1322f5 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -2,29 +2,30 @@ from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCas from django.core.urlresolvers import reverse import json + class CourseUpdateTest(CourseTestCase): def test_course_update(self): # first get the update to force the creation - url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, - 'name' : self.course_location.name }) + url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course, + 'name': self.course_location.name}) self.client.get(url) content = '' - payload = { 'content' : content, - 'date' : 'January 8, 2013'} - url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, - 'provided_id' : ''}) - + payload = {'content': content, + 'date': 'January 8, 2013'} + url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course, + 'provided_id': ''}) + resp = self.client.post(url, json.dumps(payload), "application/json") - - payload= json.loads(resp.content) - + + payload = json.loads(resp.content) + self.assertHTMLEqual(content, payload['content'], "single iframe") - - url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, - 'provided_id' : payload['id']}) + + url = reverse('course_info', kwargs={'org': self.course_location.org, 'course': self.course_location.course, + 'provided_id': payload['id']}) content += '
          div

          p

          ' payload['content'] = content resp = self.client.post(url, json.dumps(payload), "application/json") - + self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div") diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 13f6189cc5..09e3b045f9 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -1,16 +1,17 @@ -from django.test.testcases import TestCase from cms.djangoapps.contentstore import utils import mock +from django.test import TestCase + class LMSLinksTestCase(TestCase): def about_page_test(self): - location = 'i4x','mitX','101','course', 'test' + location = 'i4x', 'mitX', '101', 'course', 'test' utils.get_course_id = mock.Mock(return_value="mitX/101/test") link = utils.get_lms_link_for_about_page(location) self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/about") def ls_link_test(self): - location = 'i4x','mitX','101','vertical', 'contacting_us' + location = 'i4x', 'mitX', '101', 'vertical', 'contacting_us' utils.get_course_id = mock.Mock(return_value="mitX/101/test") link = utils.get_lms_link_for_item(location, False) self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/vertical/contacting_us") diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 80909dad7a..c4a46459e2 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -1,48 +1,33 @@ import json import shutil -from django.test import TestCase from django.test.client import Client -from override_settings import override_settings from django.conf import settings from django.core.urlresolvers import reverse from path import path -from tempfile import mkdtemp import json from fs.osfs import OSFS - - -from student.models import Registration -from django.contrib.auth.models import User -import xmodule.modulestore.django -from xmodule.modulestore.xml_importer import import_from_xml import copy -from factories import * +from cms.djangoapps.contentstore.utils import get_modulestore + +from xmodule.modulestore import Location from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.store_utilities import delete_course -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import modulestore, _MODULESTORES from xmodule.contentstore.django import contentstore -from xmodule.course_module import CourseDescriptor +from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml -from cms.djangoapps.contentstore.utils import get_modulestore +from xmodule.modulestore.xml_importer import import_from_xml + from xmodule.capa_module import CapaDescriptor +from xmodule.course_module import CourseDescriptor +from xmodule.seq_module import SequenceDescriptor -def parse_json(response): - """Parse response, which is assumed to be json""" - return json.loads(response.content) +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from utils import ModuleStoreTestCase, parse_json, user, registration -def user(email): - """look up a user by email""" - return User.objects.get(email=email) - - -def registration(email): - """look up registration object by email""" - return Registration.objects.get(user__email=email) - - -class ContentStoreTestCase(TestCase): +class ContentStoreTestCase(ModuleStoreTestCase): def _login(self, email, pw): """Login. View should always return 200. The success/fail is in the returned json""" @@ -99,7 +84,6 @@ class ContentStoreTestCase(TestCase): # Now make sure that the user is now actually activated self.assertTrue(user(email).is_active) - class AuthTestCase(ContentStoreTestCase): """Check that various permissions-related things work""" @@ -187,353 +171,3 @@ class AuthTestCase(ContentStoreTestCase): self.assertEqual(resp.status_code, 302) # Logged in should work. - -TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) -TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') -TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') - -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) -class ContentStoreTest(TestCase): - - def setUp(self): - uname = 'testuser' - email = 'test+courses@edx.org' - password = 'foo' - - # Create the use so we can log them in. - self.user = User.objects.create_user(uname, email, password) - - # Note that we do not actually need to do anything - # for registration if we directly mark them active. - self.user.is_active = True - # Staff has access to view all courses - self.user.is_staff = True - self.user.save() - - # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. - # Note that if your test module gets in some weird state - # (though it shouldn't), do this manually - # from the bash shell to drop it: - # $ mongo test_xmodule --eval "db.dropDatabase()" - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - xmodule.templates.update_templates() - - self.client = Client() - self.client.login(username=uname, password=password) - - self.course_data = { - 'template': 'i4x://edx/templates/course/Empty', - 'org': 'MITx', - 'number': '999', - 'display_name': 'Robot Super Course', - } - - def tearDown(self): - # Make sure you flush out the test modulestore after the end - # of the last test because otherwise on the next run - # cms/djangoapps/contentstore/__init__.py - # update_templates() will try to update the templates - # via upsert and it sometimes seems to be messing things up. - xmodule.modulestore.django._MODULESTORES = {} - xmodule.modulestore.django.modulestore().collection.drop() - - def test_create_course(self): - """Test new course creation - happy path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') - - def test_create_course_duplicate_course(self): - """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.') - - def test_create_course_duplicate_number(self): - """Test new course creation - error path""" - resp = self.client.post(reverse('create_new_course'), self.course_data) - self.course_data['display_name'] = 'Robot Super Course Two' - - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - 'There is already a course defined with the same organization and course number.') - - def test_create_course_with_bad_organization(self): - """Test new course creation - error path for bad organization name""" - self.course_data['org'] = 'University of California, Berkeley' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") - - def test_course_index_view_with_no_courses(self): - """Test viewing the index page with no courses""" - # Create a course so there is something to view - resp = self.client.get(reverse('index')) - self.assertContains(resp, - '

          My Courses

          ', - status_code=200, - html=True) - - def test_course_factory(self): - course = CourseFactory.create() - self.assertIsInstance(course, xmodule.course_module.CourseDescriptor) - - def test_item_factory(self): - course = CourseFactory.create() - item = ItemFactory.create(parent_location=course.location) - self.assertIsInstance(item, xmodule.seq_module.SequenceDescriptor) - - def test_course_index_view_with_course(self): - """Test viewing the index page with an existing course""" - CourseFactory.create(display_name='Robot Super Educational Course') - resp = self.client.get(reverse('index')) - self.assertContains(resp, - 'Robot Super Educational Course', - status_code=200, - html=True) - - def test_course_overview_view_with_course(self): - """Test viewing the course overview page with an existing course""" - CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') - - data = { - 'org': 'MITx', - 'course': '999', - 'name': Location.clean('Robot Super Course'), - } - - resp = self.client.get(reverse('course_index', kwargs=data)) - self.assertContains(resp, - 'Robot Super Course', - status_code=200, - html=True) - - def test_clone_item(self): - """Test cloning an item. E.g. creating a new section""" - CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') - - section_data = { - 'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course', - 'template' : 'i4x://edx/templates/chapter/Empty', - 'display_name': 'Section One', - } - - resp = self.client.post(reverse('clone_item'), section_data) - - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertRegexpMatches(data['id'], - '^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$') - - def check_edit_unit(self, test_course_name): - import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) - - for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): - print "Checking ", descriptor.location.url() - print descriptor.__class__, descriptor.location - resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) - self.assertEqual(resp.status_code, 200) - - def test_edit_unit_toy(self): - self.check_edit_unit('toy') - - def test_edit_unit_full(self): - self.check_edit_unit('full') - - def test_static_tab_reordering(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - - ms = modulestore('direct') - course = ms.get_item(Location(['i4x','edX','full','course','6.002_Spring_2012', None])) - - # reverse the ordering - reverse_tabs = [] - for tab in course.tabs: - if tab['type'] == 'static_tab': - reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) - - resp = self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs':reverse_tabs}), "application/json") - - course = ms.get_item(Location(['i4x','edX','full','course','6.002_Spring_2012', None])) - - # compare to make sure that the tabs information is in the expected order after the server call - course_tabs = [] - for tab in course.tabs: - if tab['type'] == 'static_tab': - course_tabs.append('i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) - - self.assertEqual(reverse_tabs, course_tabs) - - - - - def test_about_overrides(self): - ''' - This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html - while there is a base definition in /about/effort.html - ''' - import_from_xml(modulestore(), 'common/test/data/', ['full']) - ms = modulestore('direct') - effort = ms.get_item(Location(['i4x','edX','full','about','effort', None])) - self.assertEqual(effort.definition['data'],'6 hours') - - # this one should be in a non-override folder - effort = ms.get_item(Location(['i4x','edX','full','about','end_date', None])) - self.assertEqual(effort.definition['data'],'TBD') - - def test_remove_hide_progress_tab(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - - ms = modulestore('direct') - cs = contentstore() - - source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - course = ms.get_item(source_location) - self.assertNotIn('hide_progress_tab', course.metadata) - - - def test_clone_course(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - - resp = self.client.post(reverse('create_new_course'), self.course_data) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') - - ms = modulestore('direct') - cs = contentstore() - - source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course') - - clone_course(ms, cs, source_location, dest_location) - - # now loop through all the units in the course and verify that the clone can render them, which - # means the objects are at least present - items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) - self.assertGreater(len(items), 0) - clone_items = ms.get_items(Location(['i4x', 'MITx','999','vertical', None])) - self.assertGreater(len(clone_items), 0) - for descriptor in items: - new_loc = descriptor.location._replace(org = 'MITx', course='999') - print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) - resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) - self.assertEqual(resp.status_code, 200) - - def test_delete_course(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - - ms = modulestore('direct') - cs = contentstore() - - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - - delete_course(ms, cs, location) - - items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) - self.assertEqual(len(items), 0) - - def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): - fs = OSFS(root_dir / 'test_export') - self.assertTrue(fs.exists(dirname)) - - query_loc = Location('i4x', location.org, location.course, category_name, None) - items = modulestore.get_items(query_loc) - - for item in items: - fs = OSFS(root_dir / ('test_export/' + dirname)) - self.assertTrue(fs.exists(item.location.name + filename_suffix)) - - def test_export_course(self): - ms = modulestore('direct') - cs = contentstore() - - import_from_xml(ms, 'common/test/data/', ['full']) - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - - root_dir = path(mkdtemp()) - - print 'Exporting to tempdir = {0}'.format(root_dir) - - # export out to a tempdir - export_to_xml(ms, cs, location, root_dir, 'test_export') - - # check for static tabs - self.verify_content_existence(ms, root_dir, location, 'tabs', 'static_tab', '.html') - - # check for custom_tags - self.verify_content_existence(ms, root_dir, location, 'info', 'course_info', '.html') - - # check for custom_tags - self.verify_content_existence(ms, root_dir, location, 'custom_tags', 'custom_tag_template') - - - # remove old course - delete_course(ms, cs, location) - - # reimport - import_from_xml(ms, root_dir, ['test_export']) - - items = ms.get_items(Location(['i4x','edX', 'full', 'vertical', None])) - self.assertGreater(len(items), 0) - for descriptor in items: - print "Checking {0}....".format(descriptor.location.url()) - resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) - self.assertEqual(resp.status_code, 200) - - shutil.rmtree(root_dir) - - - def test_course_handouts_rewrites(self): - ms = modulestore('direct') - cs = contentstore() - - # import a test course - import_from_xml(ms, 'common/test/data/', ['full']) - - handout_location= Location(['i4x', 'edX', 'full', 'course_info', 'handouts']) - - # get module info - resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location})) - - # make sure we got a successful response - self.assertEqual(resp.status_code, 200) - - # check that /static/ has been converted to the full path - # note, we know the link it should be because that's what in the 'full' course in the test data - self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') - - - def test_capa_module(self): - """Test that a problem treats markdown specially.""" - CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') - - problem_data = { - 'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course', - 'template' : 'i4x://edx/templates/problem/Empty' - } - - resp = self.client.post(reverse('clone_item'), problem_data) - - self.assertEqual(resp.status_code, 200) - payload = parse_json(resp) - problem_loc = payload['id'] - problem = get_modulestore(problem_loc).get_item(problem_loc) - # should be a CapaDescriptor - self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") - context = problem.get_context() - self.assertIn('markdown', context, "markdown is missing from context") - self.assertIn('markdown', problem.metadata, "markdown is missing from metadata") - self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py new file mode 100644 index 0000000000..b6b8cd5023 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -0,0 +1,65 @@ +import json +import copy +from uuid import uuid4 +from django.test import TestCase +from django.conf import settings + +from student.models import Registration +from django.contrib.auth.models import User + +import xmodule.modulestore.django +from xmodule.templates import update_templates + + +class ModuleStoreTestCase(TestCase): + """ Subclass for any test case that uses the mongodb + module store. This populates a uniquely named modulestore + collection with templates before running the TestCase + and drops it they are finished. """ + + def _pre_setup(self): + super(ModuleStoreTestCase, self)._pre_setup() + + # Use a uuid to differentiate + # the mongo collections on jenkins. + self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) + self.test_MODULESTORE = self.orig_MODULESTORE + self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + settings.MODULESTORE = self.test_MODULESTORE + + # Flush and initialize the module store + # It needs the templates because it creates new records + # by cloning from the template. + # Note that if your test module gets in some weird state + # (though it shouldn't), do this manually + # from the bash shell to drop it: + # $ mongo test_xmodule --eval "db.dropDatabase()" + xmodule.modulestore.django._MODULESTORES = {} + update_templates() + + def _post_teardown(self): + # Make sure you flush out the modulestore. + # Drop the collection at the end of the test, + # otherwise there will be lingering collections leftover + # from executing the tests. + xmodule.modulestore.django._MODULESTORES = {} + xmodule.modulestore.django.modulestore().collection.drop() + settings.MODULESTORE = self.orig_MODULESTORE + + super(ModuleStoreTestCase, self)._post_teardown() + + +def parse_json(response): + """Parse response, which is assumed to be json""" + return json.loads(response.content) + + +def user(email): + """look up a user by email""" + return User.objects.get(email=email) + + +def registration(email): + """look up registration object by email""" + return Registration.objects.get(user__email=email) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index da2993e463..cba30131b5 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -5,18 +5,20 @@ from xmodule.modulestore.exceptions import ItemNotFoundError DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info'] + def get_modulestore(location): """ Returns the correct modulestore to use for modifying the specified location """ if not isinstance(location, Location): location = Location(location) - + if location.category in DIRECT_ONLY_CATEGORIES: return modulestore('direct') else: return modulestore() + def get_course_location_for_item(location): ''' cdodge: for a given Xmodule, return the course that it belongs to @@ -46,6 +48,7 @@ def get_course_location_for_item(location): return location + def get_course_for_item(location): ''' cdodge: for a given Xmodule, return the course that it belongs to @@ -72,12 +75,20 @@ def get_course_for_item(location): return courses[0] -def get_lms_link_for_item(location, preview=False): +def get_lms_link_for_item(location, preview=False, course_id=None): + if course_id is None: + course_id = get_course_id(location) + if settings.LMS_BASE is not None: - lms_link = "//{preview}{lms_base}/courses/{course_id}/jump_to/{location}".format( - preview='preview.' if preview else '', - lms_base=settings.LMS_BASE, - course_id=get_course_id(location), + if preview: + lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE', + 'preview.' + settings.LMS_BASE) + else: + lms_base = settings.LMS_BASE + + lms_link = "//{lms_base}/courses/{course_id}/jump_to/{location}".format( + lms_base=lms_base, + course_id=course_id, location=Location(location) ) else: @@ -85,6 +96,7 @@ def get_lms_link_for_item(location, preview=False): return lms_link + def get_lms_link_for_about_page(location): """ Returns the url to the course about page from the location tuple. @@ -99,6 +111,7 @@ def get_lms_link_for_about_page(location): return lms_link + def get_course_id(location): """ Returns the course_id from a given the location tuple. @@ -106,6 +119,7 @@ def get_course_id(location): # TODO: These will need to be changed to point to the particular instance of this problem in the particular course return modulestore().get_containing_courses(Location(location))[0].id + class UnitState(object): draft = 'draft' private = 'private' @@ -135,6 +149,7 @@ def compute_unit_state(unit): def get_date_display(date): return date.strftime("%d %B, %Y at %I:%M %p") + def update_item(location, value): """ If value is None, delete the db entry. Otherwise, update it using the correct modulestore. @@ -142,4 +157,4 @@ def update_item(location, value): if value is None: get_modulestore(location).delete_item(location) else: - get_modulestore(location).update_item(location, value) \ No newline at end of file + get_modulestore(location).update_item(location, value) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 14f96e312a..5fb65fd18e 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -31,7 +31,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str -from static_replace import replace_urls +import static_replace from external_auth.views import ssl_login_shortcut from mitxmako.shortcuts import render_to_response, render_to_string @@ -58,7 +58,8 @@ from cms.djangoapps.models.settings.course_details import CourseDetails,\ CourseSettingsEncoder from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.contentstore.utils import get_modulestore -from lxml import etree +from django.shortcuts import redirect +from cms.djangoapps.models.settings.course_metadata import CourseMetadata # to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz' @@ -81,6 +82,12 @@ def signup(request): csrf_token = csrf(request)['csrf_token'] return render_to_response('signup.html', {'csrf': csrf_token}) +def old_login_redirect(request): + ''' + Redirect to the active login url. + ''' + return redirect('login', permanent=True) + @ssl_login_shortcut @ensure_csrf_cookie def login_page(request): @@ -93,6 +100,11 @@ def login_page(request): 'forgot_password_link': "//{base}/#forgot-password-modal".format(base=settings.LMS_BASE), }) +def howitworks(request): + if request.user.is_authenticated(): + return index(request) + else: + return render_to_response('howitworks.html', {}) # ==== Views for any logged-in user ================================== @@ -102,7 +114,7 @@ def index(request): """ List all courses available to the logged in user """ - courses = modulestore().get_items(['i4x', None, None, 'course', None]) + courses = modulestore('direct').get_items(['i4x', None, None, 'course', None]) # filter out courses that we don't have access too def course_filter(course): @@ -114,14 +126,16 @@ def index(request): courses = filter(course_filter, courses) return render_to_response('index.html', { - 'new_course_template' : Location('i4x', 'edx', 'templates', 'course', 'Empty'), + 'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'), 'courses': [(course.metadata.get('display_name'), reverse('course_index', args=[ course.location.org, course.location.course, - course.location.name])) + course.location.name]), + get_lms_link_for_item(course.location, course_id=course.location.course_id)) for course in courses], - 'user': request.user + 'user': request.user, + 'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff }) @@ -132,7 +146,7 @@ def has_access(user, location, role=STAFF_ROLE_NAME): Return True if user allowed to access this piece of data Note that the CMS permissions model is with respect to courses There is a super-admin permissions if user.is_staff is set - Also, since we're unifying the user database between LMS and CAS, + Also, since we're unifying the user database between LMS and CAS, I'm presuming that the course instructor (formally known as admin) will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR has all the rights that STAFF do @@ -154,15 +168,17 @@ def course_index(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ location = ['i4x', org, course, 'course', name] - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() - upload_asset_callback_url = reverse('upload_asset', kwargs = { - 'org' : org, - 'course' : course, - 'coursename' : name + lms_link = get_lms_link_for_item(location) + + upload_asset_callback_url = reverse('upload_asset', kwargs={ + 'org': org, + 'course': course, + 'coursename': name }) course = modulestore().get_item(location) @@ -171,6 +187,7 @@ def course_index(request, org, course, name): return render_to_response('overview.html', { 'active_tab': 'courseware', 'context_course': course, + 'lms_link': lms_link, 'sections': sections, 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, @@ -213,7 +230,7 @@ def edit_subsection(request, location): # remove all metadata from the generic dictionary that is presented in a more normalized UI - policy_metadata = dict((key,value) for key, value in item.metadata.iteritems() + policy_metadata = dict((key, value) for key, value in item.metadata.iteritems() if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields) can_view_live = False @@ -233,9 +250,9 @@ def edit_subsection(request, location): 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, 'parent_item': parent, - 'policy_metadata' : policy_metadata, - 'subsection_units' : subsection_units, - 'can_view_live' : can_view_live + 'policy_metadata': policy_metadata, + 'subsection_units': subsection_units, + 'can_view_live': can_view_live }) @@ -261,7 +278,6 @@ def edit_unit(request, location): break lms_link = get_lms_link_for_item(item.location) - preview_lms_link = get_lms_link_for_item(item.location, preview=True) component_templates = defaultdict(list) @@ -272,7 +288,7 @@ def edit_unit(request, location): template.display_name, template.location.url(), 'markdown' in template.metadata, - template.location.name == 'Empty' + 'empty' in template.metadata )) components = [ @@ -292,23 +308,26 @@ def edit_unit(request, location): containing_section = modulestore().get_item(containing_section_locs[0]) # cdodge hack. We're having trouble previewing drafts via jump_to redirect - # so let's generate the link url here + # so let's generate the link url here # need to figure out where this item is in the list of children as the preview will need this - index =1 + index = 1 for child in containing_subsection.get_children(): if child.location == item.location: break index = index + 1 - preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format( - preview='preview.', - lms_base=settings.LMS_BASE, + preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE', + 'preview.' + settings.LMS_BASE) + + preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format( + preview_lms_base=preview_lms_base, + lms_base=settings.LMS_BASE, org=course.location.org, - course=course.location.course, - course_name=course.location.name, - section=containing_section.location.name, - subsection=containing_subsection.location.name, + course=course.location.course, + course_name=course.location.name, + section=containing_section.location.name, + subsection=containing_subsection.location.name, index=index) unit_state = compute_unit_state(item) @@ -359,14 +378,14 @@ def assignment_type_update(request, org, course, category, name): location = Location(['i4x', org, course, category, name]) if not has_access(request.user, location): raise HttpResponseForbidden() - + if request.method == 'GET': - return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)), + return HttpResponse(json.dumps(CourseGradingModel.get_section_grader_type(location)), mimetype="application/json") - elif request.method == 'POST': # post or put, doesn't matter. - return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)), + elif request.method == 'POST': # post or put, doesn't matter. + return HttpResponse(json.dumps(CourseGradingModel.update_section_grader_type(location, request.POST)), mimetype="application/json") - + def user_author_string(user): '''Get an author string for commits by this user. Format: @@ -474,7 +493,7 @@ def preview_module_system(request, preview_id, descriptor): get_module=partial(get_preview_module, request, preview_id), render_template=render_from_lms, debug=True, - replace_urls=replace_urls, + replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location), user=request.user, ) @@ -511,24 +530,24 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ error_msg=exc_info_to_str(sys.exc_info()) ).xmodule_constructor(system)(None, None) - # cdodge: Special case + # cdodge: Special case if module.location.category == 'static_tab': module.get_html = wrap_xmodule( module.get_html, module, "xmodule_tab_display.html", ) - else: + else: module.get_html = wrap_xmodule( module.get_html, module, "xmodule_display.html", ) - + module.get_html = replace_static_urls( module.get_html, module.metadata.get('data_dir', module.location.course), - course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None]) + course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None]) ) save_preview_state(request, preview_id, descriptor.location.url(), module.get_instance_state(), module.get_shared_state()) @@ -555,7 +574,7 @@ def _xmodule_recurse(item, action): _xmodule_recurse(child, action) action(item) - + @login_required @expect_json @@ -589,8 +608,8 @@ def delete_item(request): # semantics of delete_item whereby the store is draft aware. Right now calling # delete_item on a vertical tries to delete the draft version leaving the # requested delete to never occur - if item.location.revision is None and item.location.category=='vertical' and delete_all_versions: - modulestore('direct').delete_item(item.location) + if item.location.revision is None and item.location.category == 'vertical' and delete_all_versions: + modulestore('direct').delete_item(item.location) return HttpResponse() @@ -609,7 +628,7 @@ def save_item(request): if request.POST.get('data') is not None: data = request.POST['data'] store.update_item(item_location, data) - + # cdodge: note calling request.POST.get('children') will return None if children is an empty array # so it lead to a bug whereby the last component to be deleted in the UI was not actually # deleting the children object from the children collection @@ -699,7 +718,7 @@ def unpublish_unit(request): def clone_item(request): parent_location = Location(request.POST['parent_location']) template = Location(request.POST['template']) - + display_name = request.POST.get('display_name') if not has_access(request.user, parent_location): @@ -739,9 +758,9 @@ def upload_asset(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] if not has_access(request.user, location): return HttpResponseForbidden() - + # Does the course actually exist?!? Get anything from it to prove its existance - + try: item = modulestore().get_item(location) except: @@ -775,12 +794,12 @@ def upload_asset(request, org, course, coursename): # readback the saved content - we need the database timestamp readback = contentstore().find(content.location) - - response_payload = {'displayname' : content.name, - 'uploadDate' : get_date_display(readback.last_modified_at), - 'url' : StaticContent.get_url_path_from_location(content.location), - 'thumb_url' : StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None, - 'msg' : 'Upload completed' + + response_payload = {'displayname': content.name, + 'uploadDate': get_date_display(readback.last_modified_at), + 'url': StaticContent.get_url_path_from_location(content.location), + 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None, + 'msg': 'Upload completed' } response = HttpResponse(json.dumps(response_payload)) @@ -793,7 +812,7 @@ This view will return all CMS users who are editors for the specified course @login_required @ensure_csrf_cookie def manage_users(request, location): - + # check that logged in user has permissions to this item if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): raise PermissionDenied() @@ -804,16 +823,16 @@ def manage_users(request, location): 'active_tab': 'users', 'context_course': course_module, 'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME), - 'add_user_postback_url' : reverse('add_user', args=[location]).rstrip('/'), - 'remove_user_postback_url' : reverse('remove_user', args=[location]).rstrip('/'), - 'allow_actions' : has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), - 'request_user_id' : request.user.id + 'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'), + 'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'), + 'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), + 'request_user_id': request.user.id }) - + def create_json_response(errmsg = None): if errmsg is not None: - resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg' : errmsg})) + resp = HttpResponse(json.dumps({'Status': 'Failed', 'ErrMsg': errmsg})) else: resp = HttpResponse(json.dumps({'Status': 'OK'})) @@ -829,15 +848,15 @@ the specified course def add_user(request, location): email = request.POST["email"] - if email=='': + if email == '': return create_json_response('Please specify an email address.') - + # check that logged in user has admin permissions to this course if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): raise PermissionDenied() - + user = get_user_by_email(email) - + # user doesn't exist?!? Return error. if user is None: return create_json_response('Could not find user by email address \'{0}\'.'.format(email)) @@ -860,7 +879,7 @@ the specified course @ensure_csrf_cookie def remove_user(request, location): email = request.POST["email"] - + # check that logged in user has admin permissions on this course if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): raise PermissionDenied() @@ -887,7 +906,7 @@ def landing(request, org, course, coursename): def static_pages(request, org, course, coursename): location = ['i4x', org, course, 'course', coursename] - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() @@ -916,13 +935,13 @@ def reorder_static_tabs(request): # get list of existing static tabs in course # make sure they are the same lengths (i.e. the number of passed in tabs equals the number # that we know about) otherwise we can drop some! - + existing_static_tabs = [t for t in course.tabs if t['type'] == 'static_tab'] if len(existing_static_tabs) != len(tabs): return HttpResponseBadRequest() # load all reference tabs, return BadRequest if we can't find any of them - tab_items =[] + tab_items = [] for tab in tabs: item = modulestore('direct').get_item(Location(tab)) if item is None: @@ -935,15 +954,15 @@ def reorder_static_tabs(request): static_tab_idx = 0 for tab in course.tabs: if tab['type'] == 'static_tab': - reordered_tabs.append({'type': 'static_tab', - 'name' : tab_items[static_tab_idx].metadata.get('display_name'), - 'url_slug' : tab_items[static_tab_idx].location.name}) + reordered_tabs.append({'type': 'static_tab', + 'name': tab_items[static_tab_idx].metadata.get('display_name'), + 'url_slug': tab_items[static_tab_idx].location.name}) static_tab_idx += 1 else: reordered_tabs.append(tab) - # OK, re-assemble the static tabs in the new order + # OK, re-assemble the static tabs in the new order course.tabs = reordered_tabs modulestore('direct').update_metadata(course.location, course.metadata) return HttpResponse() @@ -952,7 +971,7 @@ def reorder_static_tabs(request): @login_required @ensure_csrf_cookie def edit_tabs(request, org, course, coursename): - location = ['i4x', org, course, 'course', coursename] + location = ['i4x', org, course, 'course', coursename] course_item = modulestore().get_item(location) static_tabs_loc = Location('i4x', org, course, 'static_tab', None) @@ -981,7 +1000,7 @@ def edit_tabs(request, org, course, coursename): return render_to_response('edit-tabs.html', { 'active_tab': 'pages', - 'context_course':course_item, + 'context_course': course_item, 'components': components }) @@ -1002,24 +1021,24 @@ def course_info(request, org, course, name, provided_id=None): org, course, name: Attributes of the Location for the item to edit """ location = ['i4x', org, course, 'course', name] - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() - + course_module = modulestore().get_item(location) - + # get current updates location = ['i4x', org, course, 'course_info', "updates"] return render_to_response('course_info.html', { 'active_tab': 'courseinfo-tab', 'context_course': course_module, - 'url_base' : "/" + org + "/" + course + "/", - 'course_updates' : json.dumps(get_course_updates(location)), + 'url_base': "/" + org + "/" + course + "/", + 'course_updates': json.dumps(get_course_updates(location)), 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() }) - + @expect_json @login_required @ensure_csrf_cookie @@ -1033,7 +1052,7 @@ def course_info_updates(request, org, course, provided_id=None): # ??? No way to check for access permission afaik # get current updates location = ['i4x', org, course, 'course_info', "updates"] - + # Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-( # Possibly due to my removing the seemingly redundant pattern in urls.py if provided_id == '': @@ -1048,7 +1067,7 @@ def course_info_updates(request, org, course, provided_id=None): real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] else: real_method = request.method - + if request.method == 'GET': return HttpResponse(json.dumps(get_course_updates(location)), mimetype="application/json") elif real_method == 'DELETE': # coming as POST need to pull from Request Header X-HTTP-Method-Override DELETE @@ -1065,7 +1084,7 @@ def course_info_updates(request, org, course, provided_id=None): @ensure_csrf_cookie def module_info(request, module_location): location = Location(module_location) - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() @@ -1076,12 +1095,12 @@ def module_info(request, module_location): else: real_method = request.method - rewrite_static_links = request.GET.get('rewrite_url_links','True') in ['True', 'true'] - logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links','False'), rewrite_static_links)) - + rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true'] + logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links)) + # check that logged in user has permissions to this item if not has_access(request.user, location): - raise PermissionDenied() + raise PermissionDenied() if real_method == 'GET': return HttpResponse(json.dumps(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)), mimetype="application/json") @@ -1099,20 +1118,66 @@ def get_course_settings(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ location = ['i4x', org, course, 'course', name] - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() - + course_module = modulestore().get_item(location) course_details = CourseDetails.fetch(location) - + return render_to_response('settings.html', { - 'active_tab': 'settings', 'context_course': course_module, + 'course_location' : location, 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) }) - + +@login_required +@ensure_csrf_cookie +def course_config_graders_page(request, org, course, name): + """ + Send models and views as well as html for editing the course settings to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + course_details = CourseGradingModel.fetch(location) + + return render_to_response('settings_graders.html', { + 'context_course': course_module, + 'course_location' : location, + 'course_details': json.dumps(course_details, cls=CourseSettingsEncoder) + }) + +@login_required +@ensure_csrf_cookie +def course_config_advanced_page(request, org, course, name): + """ + Send models and views as well as html for editing the advanced course settings to the client. + + org, course, name: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + course_module = modulestore().get_item(location) + + return render_to_response('settings_advanced.html', { + 'context_course': course_module, + 'course_location' : location, + 'advanced_blacklist' : json.dumps(CourseMetadata.FILTERED_LIST), + 'advanced_dict' : json.dumps(CourseMetadata.fetch(location)), + }) + @expect_json @login_required @ensure_csrf_cookie @@ -1135,13 +1200,13 @@ def course_settings_updates(request, org, course, name, section): elif section == 'grading': manager = CourseGradingModel else: return - + if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( - return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course',name])), cls=CourseSettingsEncoder), + return HttpResponse(json.dumps(manager.fetch(Location(['i4x', org, course, 'course', name])), cls=CourseSettingsEncoder), mimetype="application/json") - elif request.method == 'POST': # post or put, doesn't matter. - return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), + elif request.method == 'POST': # post or put, doesn't matter. + return HttpResponse(json.dumps(manager.update_from_json(request.POST), cls=CourseSettingsEncoder), mimetype="application/json") @expect_json @@ -1154,7 +1219,7 @@ def course_grader_updates(request, org, course, name, grader_index=None): org, course: Attributes of the Location for the item to edit """ - + location = ['i4x', org, course, 'course', name] # check that logged in user has permissions to this item @@ -1165,19 +1230,50 @@ def course_grader_updates(request, org, course, name, grader_index=None): real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] else: real_method = request.method - + if real_method == 'GET': # Cannot just do a get w/o knowing the course name :-( - return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course',name]), grader_index)), + return HttpResponse(json.dumps(CourseGradingModel.fetch_grader(Location(['i4x', org, course, 'course', name]), grader_index)), mimetype="application/json") elif real_method == "DELETE": - # ??? Shoudl this return anything? Perhaps success fail? - CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course',name]), grader_index) + # ??? Shoudl this return anything? Perhaps success fail? + CourseGradingModel.delete_grader(Location(['i4x', org, course, 'course', name]), grader_index) return HttpResponse() - elif request.method == 'POST': # post or put, doesn't matter. - return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course',name]), request.POST)), + elif request.method == 'POST': # post or put, doesn't matter. + return HttpResponse(json.dumps(CourseGradingModel.update_grader_from_json(Location(['i4x', org, course, 'course', name]), request.POST)), mimetype="application/json") + +## NB: expect_json failed on ["key", "key2"] and json payload +@login_required +@ensure_csrf_cookie +def course_advanced_updates(request, org, course, name): + """ + restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh, + the payload is either a key or a list of keys to delete. + + org, course: Attributes of the Location for the item to edit + """ + location = ['i4x', org, course, 'course', name] + + # check that logged in user has permissions to this item + if not has_access(request.user, location): + raise PermissionDenied() + + # NB: we're setting Backbone.emulateHTTP to true on the client so everything comes as a post!!! + if request.method == 'POST' and 'HTTP_X_HTTP_METHOD_OVERRIDE' in request.META: + real_method = request.META['HTTP_X_HTTP_METHOD_OVERRIDE'] + else: + real_method = request.method + + if real_method == 'GET': + return HttpResponse(json.dumps(CourseMetadata.fetch(location)), mimetype="application/json") + elif real_method == 'DELETE': + return HttpResponse(json.dumps(CourseMetadata.delete_key(location, json.loads(request.body))), mimetype="application/json") + elif real_method == 'POST' or real_method == 'PUT': + # NOTE: request.POST is messed up because expect_json cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key + return HttpResponse(json.dumps(CourseMetadata.update_from_json(location, json.loads(request.body))), mimetype="application/json") + @login_required @ensure_csrf_cookie @@ -1188,20 +1284,20 @@ def asset_index(request, org, course, name): org, course, name: Attributes of the Location for the item to edit """ location = ['i4x', org, course, 'course', name] - + # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() - upload_asset_callback_url = reverse('upload_asset', kwargs = { - 'org' : org, - 'course' : course, - 'coursename' : name + upload_asset_callback_url = reverse('upload_asset', kwargs={ + 'org': org, + 'course': course, + 'coursename': name }) course_module = modulestore().get_item(location) - + course_reference = StaticContent.compute_location(org, course, name) assets = contentstore().get_all_content_for_course(course_reference) @@ -1215,15 +1311,15 @@ def asset_index(request, org, course, name): display_info = {} display_info['displayname'] = asset['displayname'] display_info['uploadDate'] = get_date_display(asset['uploadDate']) - + asset_location = StaticContent.compute_location(id['org'], id['course'], id['name']) display_info['url'] = StaticContent.get_url_path_from_location(asset_location) - + # note, due to the schema change we may not have a 'thumbnail_location' in the result set _thumbnail_location = asset.get('thumbnail_location', None) thumbnail_location = Location(_thumbnail_location) if _thumbnail_location is not None else None display_info['thumb_url'] = StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_location is not None else None - + asset_display.append(display_info) return render_to_response('asset_index.html', { @@ -1241,10 +1337,19 @@ def edge(request): @login_required @expect_json def create_new_course(request): + + if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff: + raise PermissionDenied() + + # This logic is repeated in xmodule/modulestore/tests/factories.py + # so if you change anything here, you need to also change it there. + # TODO: write a test that creates two courses, one with the factory and + # the other with this method, then compare them to make sure they are + # equivalent. template = Location(request.POST['template']) - org = request.POST.get('org') - number = request.POST.get('number') - display_name = request.POST.get('display_name') + org = request.POST.get('org') + number = request.POST.get('number') + display_name = request.POST.get('display_name') try: dest_location = Location('i4x', org, number, 'course', Location.clean(display_name)) @@ -1290,13 +1395,16 @@ def initialize_course_tabs(course): # at least a list populated with the minimal times # @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better # place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here - course.tabs = [{"type": "courseware"}, + + # This logic is repeated in xmodule/modulestore/tests/factories.py + # so if you change anything here, you need to also change it there. + course.tabs = [{"type": "courseware"}, {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"}] - modulestore('direct').update_metadata(course.location.url(), course.own_metadata) + modulestore('direct').update_metadata(course.location.url(), course.own_metadata) @ensure_csrf_cookie @login_required @@ -1336,7 +1444,7 @@ def import_course(request, org, course, name): # find the 'course.xml' file - for r,d,f in os.walk(course_dir): + for r, d, f in os.walk(course_dir): for files in f: if files == 'course.xml': break @@ -1350,10 +1458,10 @@ def import_course(request, org, course, name): if r != course_dir: for fname in os.listdir(r): - shutil.move(r/fname, course_dir) + shutil.move(r / fname, course_dir) module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT, - [course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace = Location(location)) + [course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace=Location(location)) # we can blow this away when we're done importing. shutil.rmtree(course_dir) @@ -1369,7 +1477,7 @@ def import_course(request, org, course, name): return render_to_response('import.html', { 'context_course': course_module, 'active_tab': 'import', - 'successful_import_redirect_url' : reverse('course_index', args=[ + 'successful_import_redirect_url': reverse('course_index', args=[ course_module.location.org, course_module.location.course, course_module.location.name]) @@ -1384,12 +1492,12 @@ def generate_export_course(request, org, course, name): raise PermissionDenied() loc = Location(location) - export_file = NamedTemporaryFile(prefix=name+'.', suffix=".tar.gz") + export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") root_dir = path(mkdtemp()) # export out to a tempdir - + logging.debug('root = {0}'.format(root_dir)) export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name) @@ -1397,11 +1505,11 @@ def generate_export_course(request, org, course, name): logging.debug('tar file being generated at {0}'.format(export_file.name)) tf = tarfile.open(name=export_file.name, mode='w:gz') - tf.add(root_dir/name, arcname=name) + tf.add(root_dir / name, arcname=name) tf.close() # remove temp dir - shutil.rmtree(root_dir/name) + shutil.rmtree(root_dir / name) wrapper = FileWrapper(export_file) response = HttpResponse(wrapper, content_type='application/x-tgz') @@ -1423,7 +1531,7 @@ def export_course(request, org, course, name): return render_to_response('export.html', { 'context_course': course_module, 'active_tab': 'export', - 'successful_import_redirect_url' : '' + 'successful_import_redirect_url': '' }) def event(request): @@ -1431,4 +1539,4 @@ def event(request): A noop to swallow the analytics call so that cms methods don't spook and poor developers looking at console logs don't get distracted :-) ''' - return HttpResponse(True) \ No newline at end of file + return HttpResponse(True) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index d01e784d74..b27f4e3804 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -31,16 +31,16 @@ class CourseDetails(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + course = cls(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) - + course.start_date = descriptor.start course.end_date = descriptor.end course.enrollment_start = descriptor.enrollment_start course.enrollment_end = descriptor.enrollment_end - + temploc = course_location._replace(category='about', name='syllabus') try: course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data'] @@ -52,32 +52,32 @@ class CourseDetails(object): course.overview = get_modulestore(temploc).get_item(temploc).definition['data'] except ItemNotFoundError: pass - + temploc = temploc._replace(name='effort') try: course.effort = get_modulestore(temploc).get_item(temploc).definition['data'] except ItemNotFoundError: pass - + temploc = temploc._replace(name='video') try: raw_video = get_modulestore(temploc).get_item(temploc).definition['data'] - course.intro_video = CourseDetails.parse_video_tag(raw_video) + course.intro_video = CourseDetails.parse_video_tag(raw_video) except ItemNotFoundError: pass - + return course - + @classmethod def update_from_json(cls, jsondict): """ Decode the json into CourseDetails and save any changed attrs to the db """ - ## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore + ## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore course_location = jsondict['course_location'] ## Will probably want to cache the inflight courses because every blur generates an update descriptor = get_modulestore(course_location).get_item(course_location) - + dirty = False if 'start_date' in jsondict: @@ -87,7 +87,7 @@ class CourseDetails(object): if converted != descriptor.start: dirty = True descriptor.start = converted - + if 'end_date' in jsondict: converted = jsdate_to_time(jsondict['end_date']) else: @@ -96,7 +96,7 @@ class CourseDetails(object): if converted != descriptor.end: dirty = True descriptor.end = converted - + if 'enrollment_start' in jsondict: converted = jsdate_to_time(jsondict['enrollment_start']) else: @@ -105,7 +105,7 @@ class CourseDetails(object): if converted != descriptor.enrollment_start: dirty = True descriptor.enrollment_start = converted - + if 'enrollment_end' in jsondict: converted = jsdate_to_time(jsondict['enrollment_end']) else: @@ -114,10 +114,10 @@ class CourseDetails(object): if converted != descriptor.enrollment_end: dirty = True descriptor.enrollment_end = converted - + if dirty: get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. temploc = Location(course_location)._replace(category='about', name='syllabus') @@ -125,19 +125,19 @@ class CourseDetails(object): temploc = temploc._replace(name='overview') update_item(temploc, jsondict['overview']) - + temploc = temploc._replace(name='effort') update_item(temploc, jsondict['effort']) - + temploc = temploc._replace(name='video') recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video']) update_item(temploc, recomposed_video_tag) - - + + # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm # it persisted correctly return CourseDetails.fetch(course_location) - + @staticmethod def parse_video_tag(raw_video): """ @@ -147,17 +147,17 @@ class CourseDetails(object): """ if not raw_video: return None - + keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video) if keystring_matcher is None: keystring_matcher = re.search('' return result - + # TODO move to a more general util? Is there a better way to do the isinstance model check? class CourseSettingsEncoder(json.JSONEncoder): diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 9cfa18c8c9..3d0b8f78af 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -6,55 +6,55 @@ from util import converters class CourseGradingModel(object): """ - Basically a DAO and Model combo for CRUD operations pertaining to grading policy. + Basically a DAO and Model combo for CRUD operations pertaining to grading policy. """ def __init__(self, course_descriptor): self.course_location = course_descriptor.location - self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100] + self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100] self.grade_cutoffs = course_descriptor.grade_cutoffs self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor) - - @classmethod + + @classmethod def fetch(cls, course_location): """ Fetch the course details for the given course from persistence and return a CourseDetails model. """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) model = cls(descriptor) return model - + @staticmethod def fetch_grader(course_location, index): """ - Fetch the course's nth grader + Fetch the course's nth grader Returns an empty dict if there's no such grader. """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) # # ??? it would be good if these had the course_location in them so that they stand alone sufficiently # # but that would require not using CourseDescriptor's field directly. Opinions? - index = int(index) - if len(descriptor.raw_grader) > index: + index = int(index) + if len(descriptor.raw_grader) > index: return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) - + # return empty model else: return { - "id" : index, - "type" : "", - "min_count" : 0, - "drop_count" : 0, - "short_label" : None, - "weight" : 0 + "id": index, + "type": "", + "min_count": 0, + "drop_count": 0, + "short_label": None, + "weight": 0 } - + @staticmethod def fetch_cutoffs(course_location): """ @@ -62,7 +62,7 @@ class CourseGradingModel(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) return descriptor.grade_cutoffs @@ -73,10 +73,10 @@ class CourseGradingModel(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) - return {'grace_period' : CourseGradingModel.convert_set_grace_period(descriptor) } - + return {'grace_period': CourseGradingModel.convert_set_grace_period(descriptor)} + @staticmethod def update_from_json(jsondict): """ @@ -85,32 +85,32 @@ class CourseGradingModel(object): """ course_location = jsondict['course_location'] descriptor = get_modulestore(course_location).get_item(course_location) - + graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']] - + descriptor.raw_grader = graders_parsed descriptor.grade_cutoffs = jsondict['grade_cutoffs'] - + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period']) - + return CourseGradingModel.fetch(course_location) - - + + @staticmethod def update_grader_from_json(course_location, grader): """ - Create or update the grader of the given type (string key) for the given course. Returns the modified + Create or update the grader of the given type (string key) for the given course. Returns the modified grader which is a full model on the client but not on the server (just a dict) """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) # # ??? it would be good if these had the course_location in them so that they stand alone sufficiently # # but that would require not using CourseDescriptor's field directly. Opinions? - # parse removes the id; so, grab it before parse + # parse removes the id; so, grab it before parse index = int(grader.get('id', len(descriptor.raw_grader))) grader = CourseGradingModel.parse_grader(grader) @@ -118,11 +118,11 @@ class CourseGradingModel(object): descriptor.raw_grader[index] = grader else: descriptor.raw_grader.append(grader) - + get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) - + return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) - + @staticmethod def update_cutoffs_from_json(course_location, cutoffs): """ @@ -131,18 +131,18 @@ class CourseGradingModel(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) descriptor.grade_cutoffs = cutoffs get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) - + return cutoffs - - + + @staticmethod def update_grace_period_from_json(course_location, graceperiodjson): """ - Update the course's default grace period. Incoming dict is {hours: h, minutes: m} possibly as a + Update the course's default grace period. Incoming dict is {hours: h, minutes: m} possibly as a grace_period entry in an enclosing dict. It is also safe to call this method with a value of None for graceperiodjson. """ @@ -155,12 +155,13 @@ class CourseGradingModel(object): if 'grace_period' in graceperiodjson: graceperiodjson = graceperiodjson['grace_period'] - grace_rep = " ".join(["%s %s" % (value, key) for (key, value) in graceperiodjson.iteritems()]) + # lms requires these to be in a fixed order + grace_rep = "{0[hours]:d} hours {0[minutes]:d} minutes {0[seconds]:d} seconds".format(graceperiodjson) descriptor = get_modulestore(course_location).get_item(course_location) descriptor.metadata['graceperiod'] = grace_rep get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + @staticmethod def delete_grader(course_location, index): """ @@ -168,16 +169,16 @@ class CourseGradingModel(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) - index = int(index) + index = int(index) if index < len(descriptor.raw_grader): del descriptor.raw_grader[index] # force propagation to definition descriptor.raw_grader = descriptor.raw_grader get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) - - # NOTE cannot delete cutoffs. May be useful to reset + + # NOTE cannot delete cutoffs. May be useful to reset @staticmethod def delete_cutoffs(course_location, cutoffs): """ @@ -185,13 +186,13 @@ class CourseGradingModel(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS'] get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) - + return descriptor.grade_cutoffs - + @staticmethod def delete_grace_period(course_location): """ @@ -199,28 +200,28 @@ class CourseGradingModel(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + descriptor = get_modulestore(course_location).get_item(course_location) if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod'] get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + @staticmethod def get_section_grader_type(location): if not isinstance(location, Location): location = Location(location) - + descriptor = get_modulestore(location).get_item(location) return { - "graderType" : descriptor.metadata.get('format', u"Not Graded"), - "location" : location, - "id" : 99 # just an arbitrary value to + "graderType": descriptor.metadata.get('format', u"Not Graded"), + "location": location, + "id": 99 # just an arbitrary value to } - + @staticmethod def update_section_grader_type(location, jsondict): if not isinstance(location, Location): location = Location(location) - + descriptor = get_modulestore(location).get_item(location) if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded": descriptor.metadata['format'] = jsondict.get('graderType') @@ -228,16 +229,16 @@ class CourseGradingModel(object): else: if 'format' in descriptor.metadata: del descriptor.metadata['format'] if 'graded' in descriptor.metadata: del descriptor.metadata['graded'] - - get_modulestore(location).update_metadata(location, descriptor.metadata) - - + + get_modulestore(location).update_metadata(location, descriptor.metadata) + + @staticmethod def convert_set_grace_period(descriptor): - # 5 hours 59 minutes 59 seconds => converted to iso format + # 5 hours 59 minutes 59 seconds => { hours: 5, minutes : 59, seconds : 59} rawgrace = descriptor.metadata.get('graceperiod', None) if rawgrace: - parsedgrace = {str(key): val for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)} + parsedgrace = {str(key): int(val) for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)} return parsedgrace else: return None @@ -245,13 +246,13 @@ class CourseGradingModel(object): def parse_grader(json_grader): # manual to clear out kruft result = { - "type" : json_grader["type"], - "min_count" : int(json_grader.get('min_count', 0)), - "drop_count" : int(json_grader.get('drop_count', 0)), - "short_label" : json_grader.get('short_label', None), - "weight" : float(json_grader.get('weight', 0)) / 100.0 + "type": json_grader["type"], + "min_count": int(json_grader.get('min_count', 0)), + "drop_count": int(json_grader.get('drop_count', 0)), + "short_label": json_grader.get('short_label', None), + "weight": float(json_grader.get('weight', 0)) / 100.0 } - + return result @staticmethod @@ -260,6 +261,6 @@ class CourseGradingModel(object): if grader['weight']: grader['weight'] *= 100 if not 'short_label' in grader: - grader['short_label'] = "" - + grader['short_label'] = "" + return grader diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py new file mode 100644 index 0000000000..d088d75665 --- /dev/null +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -0,0 +1,70 @@ +from xmodule.modulestore import Location +from contentstore.utils import get_modulestore +from xmodule.x_module import XModuleDescriptor + + +class CourseMetadata(object): + ''' + For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones. + The objects have no predefined attrs but instead are obj encodings of the editable metadata. + ''' + # __new_advanced_key__ is used by client not server; so, could argue against it being here + FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] + + @classmethod + def fetch(cls, course_location): + """ + Fetch the key:value editable course details for the given course from persistence and return a CourseMetadata model. + """ + if not isinstance(course_location, Location): + course_location = Location(course_location) + + course = {} + + descriptor = get_modulestore(course_location).get_item(course_location) + + for k, v in descriptor.metadata.iteritems(): + if k not in cls.FILTERED_LIST: + course[k] = v + + return course + + @classmethod + def update_from_json(cls, course_location, jsondict): + """ + Decode the json into CourseMetadata and save any changed attrs to the db. + + Ensures none of the fields are in the blacklist. + """ + descriptor = get_modulestore(course_location).get_item(course_location) + + dirty = False + + for k, v in jsondict.iteritems(): + # should it be an error if one of the filtered list items is in the payload? + if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v): + dirty = True + descriptor.metadata[k] = v + + if dirty: + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm + # it persisted correctly + return cls.fetch(course_location) + + @classmethod + def delete_key(cls, course_location, payload): + ''' + Remove the given metadata key(s) from the course. payload can be a single key or [key..] + ''' + descriptor = get_modulestore(course_location).get_item(course_location) + + for key in payload['deleteKeys']: + if key in descriptor.metadata: + del descriptor.metadata[key] + + get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + + return cls.fetch(course_location) + \ No newline at end of file diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 5bc9b53fc4..26a8adc92c 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -1,5 +1,5 @@ """ -This config file extends the test environment configuration +This config file extends the test environment configuration so that we can run the lettuce acceptance tests. """ from .test import * @@ -21,14 +21,14 @@ DATA_DIR = COURSES_ROOT # } # } -# Set this up so that rake lms[acceptance] and running the +# Set this up so that rake lms[acceptance] and running the # harvest command both use the same (test) database # which they can flush without messing up your dev db DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ENV_ROOT / "db" / "test_mitx.db", - 'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db", + 'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db", } } diff --git a/cms/envs/aws.py b/cms/envs/aws.py index c13e4db919..a147f84531 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -20,7 +20,7 @@ CONFIG_PREFIX = "" if SERVICE_VARIANT: CONFIG_PREFIX = SERVICE_VARIANT + "." -############################### ALWAYS THE SAME ################################ +############### ALWAYS THE SAME ################################ DEBUG = False TEMPLATE_DEBUG = False @@ -28,7 +28,7 @@ EMAIL_BACKEND = 'django_ses.SESBackend' SESSION_ENGINE = 'django.contrib.sessions.backends.cache' DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' -########################### NON-SECURE ENV CONFIG ############################## +############# NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: ENV_TOKENS = json.load(env_file) @@ -49,13 +49,10 @@ for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): LOGGING = get_logger_config(LOG_DIR, logging_env=ENV_TOKENS['LOGGING_ENV'], syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), - debug=False) + debug=False, + service_variant=SERVICE_VARIANT) -with open(ENV_ROOT / "repos.json") as repos_file: - REPOS = json.load(repos_file) - - -############################## SECURE AUTH ITEMS ############################### +################ SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: AUTH_TOKENS = json.load(auth_file) diff --git a/cms/envs/common.py b/cms/envs/common.py index f2d47dfdc6..a83f61d8f9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -20,7 +20,6 @@ Longer TODO: """ import sys -import tempfile import os.path import os import lms.envs.common @@ -33,8 +32,8 @@ MITX_FEATURES = { 'USE_DJANGO_PIPELINE': True, 'GITHUB_PUSH': False, 'ENABLE_DISCUSSION_SERVICE': False, - 'AUTH_USE_MIT_CERTIFICATES' : False, - 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests + 'AUTH_USE_MIT_CERTIFICATES': False, + 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests } ENABLE_JASMINE = False @@ -59,7 +58,8 @@ sys.path.append(COMMON_ROOT / 'lib') ############################# WEB CONFIGURATION ############################# # This is where we stick our compiled template files. -MAKO_MODULE_DIR = tempfile.mkdtemp('mako') +from tempdir import mkdtemp_clean +MAKO_MODULE_DIR = mkdtemp_clean('mako') MAKO_TEMPLATES = {} MAKO_TEMPLATES['main'] = [ PROJECT_ROOT / 'templates', @@ -74,8 +74,8 @@ TEMPLATE_DIRS = MAKO_TEMPLATES['main'] MITX_ROOT_URL = '' -LOGIN_REDIRECT_URL = MITX_ROOT_URL + '/login' -LOGIN_URL = MITX_ROOT_URL + '/login' +LOGIN_REDIRECT_URL = MITX_ROOT_URL + '/signin' +LOGIN_URL = MITX_ROOT_URL + '/signin' TEMPLATE_CONTEXT_PROCESSORS = ( @@ -165,13 +165,6 @@ STATICFILES_DIRS = [ # This is how you would use the textbook images locally # ("book", ENV_ROOT / "book_images") ] -if os.path.isdir(GITHUB_REPO_ROOT): - STATICFILES_DIRS += [ - # TODO (cpennington): When courses aren't loaded from github, remove this - (course_dir, GITHUB_REPO_ROOT / course_dir) - for course_dir in os.listdir(GITHUB_REPO_ROOT) - if os.path.isdir(GITHUB_REPO_ROOT / course_dir) - ] # Locale/Internationalization TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name @@ -179,6 +172,9 @@ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identi USE_I18N = True USE_L10N = True +# Tracking +TRACK_MAX_EVENT = 10000 + # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -229,7 +225,7 @@ PIPELINE_JS = { 'source_filenames': sorted( rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.coffee') + rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.coffee') - ) + [ 'js/hesitate.js', 'js/base.js'], + ) + ['js/hesitate.js', 'js/base.js'], 'output_filename': 'js/cms-application.js', }, 'module-js': { @@ -282,7 +278,12 @@ INSTALLED_APPS = ( 'auth', 'student', # misleading name due to sharing with lms 'course_groups', # not used in cms (yet), but tests run + + # tracking + 'track', + # For asset pipelining 'pipeline', 'staticfiles', + 'static_replace', ) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index e29ee62e20..3dee93a398 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -12,7 +12,7 @@ TEMPLATE_DEBUG = DEBUG LOGGING = get_logger_config(ENV_ROOT / "log", logging_env="dev", tracking_filename="tracking.log", - dev_env = True, + dev_env=True, debug=True) modulestore_options = { @@ -41,7 +41,7 @@ CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'OPTIONS': { 'host': 'localhost', - 'db' : 'xcontent', + 'db': 'xcontent', } } diff --git a/cms/envs/dev_ike.py b/cms/envs/dev_ike.py index 5fb120854b..1ebf219d44 100644 --- a/cms/envs/dev_ike.py +++ b/cms/envs/dev_ike.py @@ -9,8 +9,6 @@ import socket MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True -MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss - -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy - +MITX_FEATURES['USE_DJANGO_PIPELINE'] = False # don't recompile scss +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy diff --git a/cms/envs/test.py b/cms/envs/test.py index 9777795ce7..abe03edd41 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -11,7 +11,6 @@ from .common import * import os from path import path - # Nose Test Runner INSTALLED_APPS += ('django_nose',) NOSE_ARGS = ['--with-xunit'] @@ -20,7 +19,7 @@ TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_ROOT = path('test_root') # Makes the tests run much faster... -SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead +SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead # Want static files in the same dir for running on jenkins. STATIC_ROOT = TEST_ROOT / "staticfiles" @@ -66,7 +65,7 @@ CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'OPTIONS': { 'host': 'localhost', - 'db' : 'xcontent', + 'db': 'xcontent', } } @@ -75,23 +74,12 @@ DATABASES = { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ENV_ROOT / "db" / "cms.db", }, - - # The following are for testing purposes... - 'edX/toy/2012_Fall': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ENV_ROOT / "db" / "course1.db", - }, - - 'edx/full/6.002_Spring_2012': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ENV_ROOT / "db" / "course2.db", - } } LMS_BASE = "localhost:8000" CACHES = { - # This is the cache used for most things. Askbot will not work without a + # This is the cache used for most things. Askbot will not work without a # functioning cache -- it relies on caching to load its settings in places. # In staging/prod envs, the sessions also live here. 'default': { diff --git a/cms/manage.py b/cms/manage.py index f8773c0641..723fa59da1 100644 --- a/cms/manage.py +++ b/cms/manage.py @@ -2,7 +2,7 @@ from django.core.management import execute_manager import imp try: - imp.find_module('settings') # Assumed to be in the same directory. + imp.find_module('settings') # Assumed to be in the same directory. except ImportError: import sys sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. " diff --git a/cms/static/client_templates/advanced_entry.html b/cms/static/client_templates/advanced_entry.html new file mode 100644 index 0000000000..0312fdd344 --- /dev/null +++ b/cms/static/client_templates/advanced_entry.html @@ -0,0 +1,16 @@ +
        3. +
          + + + Keys are case sensitive and cannot contain spaces or start with a number +
          + +
          + + +
          + +
          + Delete +
          +
        4. \ No newline at end of file diff --git a/cms/static/client_templates/course_grade_policy.html b/cms/static/client_templates/course_grade_policy.html index c9a21280dd..db129614f6 100644 --- a/cms/static/client_templates/course_grade_policy.html +++ b/cms/static/client_templates/course_grade_policy.html @@ -1,69 +1,37 @@ -
        5. -
          - +
        6. +
          + + + e.g. Homework, Midterm Exams +
          -
          -
          - - e.g. Homework, Labs, Midterm Exams, Final Exam -
          -
          - - -
          - - -
          -
          - - e.g. HW, Midterm, Final -
          -
          -
          - -
          - - -
          -
          - - e.g. 25% -
          -
          -
          - -
          - - -
          -
          - - total exercises assigned -
          -
          -
          - -
          - - -
          -
          - - total exercises that won't be graded -
          -
          -
          - Delete +
          + + + e.g. HW, Midterm +
          + +
          + + + e.g. 25% +
          + +
          + + + total exercises assigned +
          + +
          + + + total exercises that won't be graded +
          + +
          + Delete +
        7. diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 5a826c1794..9fbe4e5789 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -1,6 +1,4 @@ class CMS.Views.TabsEdit extends Backbone.View - events: - 'click .new-tab': 'addNewTab' initialize: => @$('.component').each((idx, element) => @@ -13,6 +11,7 @@ class CMS.Views.TabsEdit extends Backbone.View ) ) + @options.mast.find('.new-tab').on('click', @addNewTab) @$('.components').sortable( handle: '.drag-handle' update: @tabMoved diff --git a/cms/static/img/hiw-feature1.png b/cms/static/img/hiw-feature1.png new file mode 100644 index 0000000000..3cfd48d066 Binary files /dev/null and b/cms/static/img/hiw-feature1.png differ diff --git a/cms/static/img/hiw-feature2.png b/cms/static/img/hiw-feature2.png new file mode 100644 index 0000000000..9442325dd5 Binary files /dev/null and b/cms/static/img/hiw-feature2.png differ diff --git a/cms/static/img/hiw-feature3.png b/cms/static/img/hiw-feature3.png new file mode 100644 index 0000000000..fa6b81ae89 Binary files /dev/null and b/cms/static/img/hiw-feature3.png differ diff --git a/cms/static/img/html-icon.png b/cms/static/img/html-icon.png index e739f2fc11..8f576178b2 100644 Binary files a/cms/static/img/html-icon.png and b/cms/static/img/html-icon.png differ diff --git a/cms/static/img/large-discussion-icon.png b/cms/static/img/large-discussion-icon.png index 2f0bfea98f..cebf332769 100644 Binary files a/cms/static/img/large-discussion-icon.png and b/cms/static/img/large-discussion-icon.png differ diff --git a/cms/static/img/large-freeform-icon.png b/cms/static/img/large-freeform-icon.png index b1d195a7ca..0d5e454f58 100644 Binary files a/cms/static/img/large-freeform-icon.png and b/cms/static/img/large-freeform-icon.png differ diff --git a/cms/static/img/large-problem-icon.png b/cms/static/img/large-problem-icon.png index b962d42b14..a30ab8eac8 100644 Binary files a/cms/static/img/large-problem-icon.png and b/cms/static/img/large-problem-icon.png differ diff --git a/cms/static/img/large-video-icon.png b/cms/static/img/large-video-icon.png index 392851324c..f1ab048b4c 100644 Binary files a/cms/static/img/large-video-icon.png and b/cms/static/img/large-video-icon.png differ diff --git a/cms/static/img/logo-edx-studio-white.png b/cms/static/img/logo-edx-studio-white.png new file mode 100644 index 0000000000..3e3ee63622 Binary files /dev/null and b/cms/static/img/logo-edx-studio-white.png differ diff --git a/cms/static/img/logo-edx-studio.png b/cms/static/img/logo-edx-studio.png new file mode 100644 index 0000000000..006194a195 Binary files /dev/null and b/cms/static/img/logo-edx-studio.png differ diff --git a/cms/static/img/pl-1x1-000.png b/cms/static/img/pl-1x1-000.png new file mode 100644 index 0000000000..b94b7a9746 Binary files /dev/null and b/cms/static/img/pl-1x1-000.png differ diff --git a/cms/static/img/pl-1x1-fff.png b/cms/static/img/pl-1x1-fff.png new file mode 100644 index 0000000000..7081c75d36 Binary files /dev/null and b/cms/static/img/pl-1x1-fff.png differ diff --git a/cms/static/img/preview-lms-staticpages.png b/cms/static/img/preview-lms-staticpages.png new file mode 100644 index 0000000000..05a62f7c7f Binary files /dev/null and b/cms/static/img/preview-lms-staticpages.png differ diff --git a/cms/static/img/thumb-hiw-feature1.png b/cms/static/img/thumb-hiw-feature1.png new file mode 100644 index 0000000000..b2dc0c00ee Binary files /dev/null and b/cms/static/img/thumb-hiw-feature1.png differ diff --git a/cms/static/img/thumb-hiw-feature2.png b/cms/static/img/thumb-hiw-feature2.png new file mode 100644 index 0000000000..e96bcad1aa Binary files /dev/null and b/cms/static/img/thumb-hiw-feature2.png differ diff --git a/cms/static/img/thumb-hiw-feature3.png b/cms/static/img/thumb-hiw-feature3.png new file mode 100644 index 0000000000..f694fca516 Binary files /dev/null and b/cms/static/img/thumb-hiw-feature3.png differ diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 41c1ee3cdb..d8b32cb0e8 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -5,7 +5,7 @@ var $newComponentItem; var $changedInput; var $spinner; -$(document).ready(function() { +$(document).ready(function () { $body = $('body'); $modal = $('.history-modal'); $modalCover = $(' diff --git a/cms/templates/activation_complete.html b/cms/templates/activation_complete.html index 5d9437ccb3..1e195a632c 100644 --- a/cms/templates/activation_complete.html +++ b/cms/templates/activation_complete.html @@ -5,7 +5,7 @@

          Activation Complete!

          -

          Thanks for activating your account. Log in here.

          +

          Thanks for activating your account. Log in here.

          diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 01766e2dac..5ace98df56 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="bodyclass">assets -<%block name="title">Courseware Assets +<%block name="bodyclass">is-signedin course uploads +<%block name="title">Uploads & Files <%namespace name='static' file='static_content.html'/> @@ -33,12 +33,27 @@ +
          +
          +
          + Course Content +

          Files & Uploads

          +
          + + +
          +
          +
          diff --git a/cms/templates/base.html b/cms/templates/base.html index 84f10fc2d1..498897bd11 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -5,23 +5,29 @@ + + <%block name="title"></%block> | + % if context_course: + <% ctx_loc = context_course.location %> + ${context_course.display_name} | + % endif + edX Studio + + + + <%static:css group='base-style'/> - + - <%block name="title"></%block> - - - - <%block name="header_extras"> - <%include file="widgets/header.html" args="active_tab=active_tab"/> + <%include file="widgets/header.html" /> <%include file="courseware_vendor_js.html"/> @@ -47,9 +53,9 @@ <%block name="content"> + <%include file="widgets/footer.html" /> <%block name="jsextra"> - diff --git a/cms/templates/course_index.html b/cms/templates/course_index.html index e490ad7817..5c8772c1ed 100644 --- a/cms/templates/course_index.html +++ b/cms/templates/course_index.html @@ -1,5 +1,5 @@ <%inherit file="base.html" /> -<%block name="title">Course Manager + <%include file="widgets/header.html"/> <%block name="content"> diff --git a/cms/templates/course_info.html b/cms/templates/course_info.html index 83d829efa0..a68a0da76a 100644 --- a/cms/templates/course_info.html +++ b/cms/templates/course_info.html @@ -2,8 +2,9 @@ <%namespace name='static' file='static_content.html'/> -<%block name="title">Course Info -<%block name="bodyclass">course-info +<%block name="title">Updates +<%block name="bodyclass">is-signedin course course-info updates + <%block name="jsextra"> @@ -41,16 +42,38 @@ <%block name="content"> +
          +
          +
          + Course Content +

          Course Updates

          +
          + + +
          +
          + +
          +
          +
          +

          Course updates are announcements or notifications you want to share with your class. Other course authors have used them for important exam/date reminders, change in schedules, and to call out any important steps students need to be aware of.

          +
          +
          +
          +
          -

          Course Info

          diff --git a/cms/templates/edit-static-page.html b/cms/templates/edit-static-page.html index 02fe2308fa..f1b2374b46 100644 --- a/cms/templates/edit-static-page.html +++ b/cms/templates/edit-static-page.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Edit Static Page -<%block name="bodyclass">edit-static-page +<%block name="title">Editing Static Page +<%block name="bodyclass">is-signedin course pages edit-static-page <%block name="content">
          diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index c6ffb14124..1a44de60c1 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -1,7 +1,7 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Tabs -<%block name="bodyclass">static-pages +<%block name="title">Static Pages +<%block name="bodyclass">is-signedin course pages static-pages <%block name="jsextra"> <%block name="content"> +
          +
          +
          + Course Content +

          Static Pages

          +
          + + +
          +
          + +
          +
          + +
          +
          +
          -
          -

          Here you can add and manage additional pages for your course

          -

          These pages will be added to the primary navigation menu alongside Courseware, Course Info, Discussion, etc.

          -
          - -
            @@ -43,4 +67,17 @@
          + +
          +

          How Static Pages are Used in Your Course

          +
          + Preview of how Static Pages are used in your course +
          These pages will be presented in your course's main navigation alongside Courseware, Course Info, Discussion, etc.
          +
          + + + + close modal + +
          \ No newline at end of file diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index de5e14e0a9..43775122d4 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -7,8 +7,9 @@ %> <%! from django.core.urlresolvers import reverse %> -<%block name="bodyclass">subsection <%block name="title">CMS Subsection +<%block name="bodyclass">is-signedin course subsection + <%namespace name="units" file="widgets/units.html" /> <%namespace name='static' file='static_content.html'/> @@ -21,7 +22,7 @@
          - +
          @@ -97,6 +98,7 @@
          +
          <%block name="jsextra"> @@ -107,6 +109,8 @@ + + - - + \ No newline at end of file diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 36930f5386..722e756203 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -1,17 +1,31 @@ <%inherit file="base.html" /> <%block name="title">Course Staff Manager -<%block name="bodyclass">users +<%block name="bodyclass">is-signedin course users settings team + <%block name="content"> +
          +
          +
          + Course Settings +

          Course Team

          +
          + + +
          +
          +
          -
          - %if allow_actions: - - New User - - %endif -

          The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.

          @@ -97,7 +111,7 @@ $cancelButton.bind('click', hideNewUserForm); $('.new-user-button').bind('click', showNewUserForm); - $body.bind('keyup', { $cancelButton: $cancelButton }, checkForCancel); + $('body').bind('keyup', { $cancelButton: $cancelButton }, checkForCancel); $('.remove-user').click(function() { $.ajax({ diff --git a/cms/templates/overview.html b/cms/templates/overview.html index a20531200e..1098a6885e 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -6,7 +6,8 @@ from datetime import datetime %> <%! from django.core.urlresolvers import reverse %> -<%block name="title">CMS Courseware Overview +<%block name="title">Course Outline +<%block name="bodyclass">is-signedin course outline <%namespace name='static' file='static_content.html'/> <%namespace name="units" file="widgets/units.html" /> @@ -18,6 +19,7 @@ + - - - - - + + + + + + + + + + + + +<%block name="content"> +
          +
          +
          + Settings +

          Advanced Settings

          +
          + +
          +
          + +
          + Your policy changes have been saved. +
          + +
          + There was an error saving your information. Please see below. +
          + +
          +
          +

          Manual Policy Definition

          + Manually Edit Course Policy Values (JSON Key / Value pairs) +
          + +

          Warning: Add only manual policy data that you are familiar + with.

          + +
            + +
          + + +
          +
          +
          + + +
          +
          + + +
          +
          +
          + + +

          Note: Your changes will not take effect until you save your + progress. Take care with key and value formatting, as validation is not implemented.

          +
          + +
          + +
          +
          +
          + \ No newline at end of file diff --git a/cms/templates/settings_discussions_faculty.html b/cms/templates/settings_discussions_faculty.html new file mode 100644 index 0000000000..fc30b6eebb --- /dev/null +++ b/cms/templates/settings_discussions_faculty.html @@ -0,0 +1,430 @@ + +<%inherit file="base.html" /> +<%block name="title">Schedule and details +<%block name="bodyclass">is-signedin course settings + + +<%namespace name='static' file='static_content.html'/> +<%! +from contentstore import utils +%> + + +<%block name="jsextra"> + + + + + + + + +<%block name="content"> + +
          +
          +

          Settings

          +
          +
          + +
          +

          Faculty

          + +
          +
          +

          Faculty Members

          + Individuals instructing and help with this course +
          + +
          +
          +
            +
          • +
            + +
            + +
            +
            + +
            + +
            + +
            +
            + +
            + + +
            + +
            + +
            + + A brief description of your education, experience, and expertise +
            +
            + + Delete Faculty Member +
          • + +
          • +
            + +
            + +
            +
            + +
            + +
            + +
            +
            + +
            + +
            +
            + + Upload Faculty Photo + + Max size: 30KB +
            +
            +
            + +
            + +
            +
            + + A brief description of your education, experience, and expertise +
            +
            +
            +
          • +
          + + + New Faculty Member + +
          +
          +
          + +
          + +
          +

          Problems

          + +
          +
          +

          General Settings

          + Course-wide settings for all problems +
          + +
          +

          Problem Randomization:

          + +
          +
          + + +
          + + randomize all problems +
          +
          + +
          + + +
          + + do not randomize problems +
          +
          + +
          + + +
          + + randomize problems per student +
          +
          +
          +
          + +
          +

          Show Answers:

          + +
          +
          + + +
          + + Answers will be shown after the number of attempts has been met +
          +
          + +
          + + +
          + + Answers will never be shown, regardless of attempts +
          +
          +
          +
          + +
          + + +
          +
          + + Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0" +
          +
          +
          +
          + +
          +
          +

          [Assignment Type Name]

          +
          + +
          +

          Problem Randomization:

          + +
          +
          + + +
          + + randomize all problems +
          +
          + +
          + + +
          + + do not randomize problems +
          +
          + +
          + + +
          + + randomize problems per student +
          +
          +
          +
          + +
          +

          Show Answers:

          + +
          +
          + + +
          + + Answers will be shown after the number of attempts has been met +
          +
          + +
          + + +
          + + Answers will never be shown, regardless of attempts +
          +
          +
          +
          + +
          + + +
          +
          + + Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0" +
          +
          +
          +
          +
          + +
          +

          Discussions

          + +
          +
          +

          General Settings

          + Course-wide settings for online discussion +
          + +
          +

          Anonymous Discussions:

          + +
          +
          + + +
          + + Students and faculty will be able to post anonymously +
          +
          + +
          + + +
          + + Posting anonymously is not allowed. Any previous anonymous posts will be reverted to non-anonymous +
          +
          +
          +
          + +
          +

          Anonymous Discussions:

          + +
          +
          + + +
          + + Students and faculty will be able to post anonymously +
          +
          + +
          + + +
          + + This option is disabled since there are previous discussions that are anonymous. +
          +
          +
          +
          + +
          +

          Discussion Categories

          + +
          + + + + New Discussion Category + +
          +
          +
          +
          +
          +
          +
          +
          +
          + diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html new file mode 100644 index 0000000000..923cc35313 --- /dev/null +++ b/cms/templates/settings_graders.html @@ -0,0 +1,152 @@ +<%inherit file="base.html" /> +<%block name="title">Grading +<%block name="bodyclass">is-signedin course grading settings + +<%namespace name='static' file='static_content.html'/> +<%! +from contentstore import utils +%> + +<%block name="jsextra"> + + + + + + + + + + + + + +<%block name="content"> +
          +
          +
          + Settings +

          Grading

          +
          +
          +
          + +
          +
          +
          +
          +
          +
          +

          Overall Grade Range

          + Your overall grading scale for student final grades +
          + +
            +
          1. +
            + +
            +
            +
              +
            1. 0
            2. +
            3. 10
            4. +
            5. 20
            6. +
            7. 30
            8. +
            9. 40
            10. +
            11. 50
            12. +
            13. 60
            14. +
            15. 70
            16. +
            17. 80
            18. +
            19. 90
            20. +
            21. 100
            22. +
            +
              +
            +
            +
            +
            +
          2. +
          +
          + +
          + +
          +
          +

          Grading Rules & Policies

          + Deadlines, requirements, and logistics around grading student work +
          + +
            +
          1. + + + Leeway on due dates +
          2. +
          +
          + +
          + +
          +
          +

          Assignment Types

          + Categories and labels for any exercises that are gradable +
          + +
            + +
          + + +
          +
          +
          + + +
          +
          + diff --git a/cms/templates/signup.html b/cms/templates/signup.html index 2c60b758e6..30c5c1cf2b 100644 --- a/cms/templates/signup.html +++ b/cms/templates/signup.html @@ -1,94 +1,141 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> -<%block name="title">Sign up -<%block name="bodyclass">no-header +<%block name="title">Sign Up +<%block name="bodyclass">not-signedin signup <%block name="content"> -
          +
          +
          +
          +

          Sign Up for edX Studio

          + +
          - +
          +

          I've never authored a course online before. Is there help?

          +

          Absolutely. We have created an online course, edX101, that describes some best practices: from filming video, creating exercises, to the basics of running an online course. Additionally, we're always here to help, just drop us a note.

          +
          + +
          +
          + - + ); + }); + })(this) + \ No newline at end of file diff --git a/cms/templates/unit.html b/cms/templates/unit.html index f3a779604e..5cd872ce98 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -1,8 +1,9 @@ <%inherit file="base.html" /> <%! from django.core.urlresolvers import reverse %> <%namespace name="units" file="widgets/units.html" /> -<%block name="bodyclass">unit -<%block name="title">CMS Unit +<%block name="title">Individual Unit +<%block name="bodyclass">is-signedin course unit + <%block name="jsextra"> @@ -35,7 +44,7 @@
          -

          +

            % for id in components:
          1. @@ -56,38 +65,66 @@
          % for type, templates in sorted(component_templates.items()):
          -

          Select ${type} component type:

          - - + % if type == "problem": +
          + + % endif +
          +
            + % for name, location, has_markdown, is_empty in templates: + % if has_markdown or type != "problem": + % if is_empty: +
          • + + ${name} + +
          • + + % else: +
          • + + ${name} + +
          • + % endif + % endif + + %endfor +
          +
          + % if type == "problem": +
          +
            + % for name, location, has_markdown, is_empty in templates: + % if not has_markdown: + % if is_empty: +
          • + + ${name} + +
          • + + % else: +
          • + + ${name} + + +
          • + % endif + % endif + % endfor +
          +
          +
          + % endif Cancel
          % endfor diff --git a/cms/templates/widgets/footer.html b/cms/templates/widgets/footer.html new file mode 100644 index 0000000000..0f265dfc2c --- /dev/null +++ b/cms/templates/widgets/footer.html @@ -0,0 +1,30 @@ +<%! from django.core.urlresolvers import reverse %> + + \ No newline at end of file diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 5f41452339..a063e4b526 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -1,40 +1,117 @@ <%! from django.core.urlresolvers import reverse %> -<% active_tab_class = 'active-tab-' + active_tab if active_tab else '' %> -
          -
          -
          -
          - % if context_course: - <% ctx_loc = context_course.location %> - › - ${context_course.display_name} › - % endif -
          +
          + + +
          + % if user.is_authenticated(): + + % else: + + % endif +
          +
          +
          \ No newline at end of file diff --git a/cms/templates/widgets/problem-edit.html b/cms/templates/widgets/problem-edit.html index 4ff9d299ab..8ca07a7928 100644 --- a/cms/templates/widgets/problem-edit.html +++ b/cms/templates/widgets/problem-edit.html @@ -1,20 +1,20 @@ <%include file="metadata-edit.html" />
          - %if markdown != '' or data == '\n\n': + %if enable_markdown:
          • -
          • -
          • -
          • -
          • @@ -56,7 +56,7 @@
          -
          Check Multiple
          +
          Checkboxes
          @@ -67,7 +67,7 @@
          -
          String Response
          +
          Text Input
          @@ -76,7 +76,7 @@
          -
          Numerical Response
          +
          Numerical Input
          @@ -85,7 +85,7 @@
          -
          Option Response
          +
          Dropdown
          diff --git a/cms/templates/widgets/source-edit.html b/cms/templates/widgets/source-edit.html index f0922831e1..c7460c9cf7 100644 --- a/cms/templates/widgets/source-edit.html +++ b/cms/templates/widgets/source-edit.html @@ -10,7 +10,7 @@

          High Level Source Editing

          -
          +
          @@ -18,6 +18,9 @@ + + +
          @@ -25,88 +28,148 @@ - diff --git a/cms/urls.py b/cms/urls.py index c928e74d19..7b7b5e9375 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -6,7 +6,8 @@ from django.conf.urls import patterns, include, url # admin.autodiscover() urlpatterns = ('', - url(r'^$', 'contentstore.views.index', name='index'), + url(r'^$', 'contentstore.views.howitworks', name='homepage'), + url(r'^listing', 'contentstore.views.index', name='index'), url(r'^edit/(?P.*?)$', 'contentstore.views.edit_unit', name='edit_unit'), url(r'^subsection/(?P.*?)$', 'contentstore.views.edit_subsection', name='edit_subsection'), url(r'^preview_component/(?P.*?)$', 'contentstore.views.preview_component', name='preview_component'), @@ -42,13 +43,18 @@ urlpatterns = ('', 'contentstore.views.remove_user', name='remove_user'), url(r'^(?P[^/]+)/(?P[^/]+)/info/(?P[^/]+)$', 'contentstore.views.course_info', name='course_info'), url(r'^(?P[^/]+)/(?P[^/]+)/course_info/updates/(?P.*)$', 'contentstore.views.course_info_updates', name='course_info'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/settings/(?P[^/]+)/section/(?P
          [^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'), - url(r'^(?P[^/]+)/(?P[^/]+)/grades/(?P[^/]+)/(?P.*)$', 'contentstore.views.course_grader_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)$', 'contentstore.views.get_course_settings', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)$', 'contentstore.views.course_config_graders_page', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-details/(?P[^/]+)/section/(?P
          [^/]+).*$', 'contentstore.views.course_settings_updates', name='course_settings'), + url(r'^(?P[^/]+)/(?P[^/]+)/settings-grading/(?P[^/]+)/(?P.*)$', 'contentstore.views.course_grader_updates', name='course_settings'), + # This is the URL to initially render the course advanced settings. + url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)$', 'contentstore.views.course_config_advanced_page', name='course_advanced_settings'), + # This is the URL used by BackBone for updating and re-fetching the model. + url(r'^(?P[^/]+)/(?P[^/]+)/settings-advanced/(?P[^/]+)/update.*$', 'contentstore.views.course_advanced_updates', name='course_advanced_settings_updates'), url(r'^(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/gradeas.*$', 'contentstore.views.assignment_type_update', name='assignment_type_update'), - url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', + url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', name='static_pages'), url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'), url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'), @@ -56,7 +62,7 @@ urlpatterns = ('', # this is a generic method to return the data/metadata associated with a xmodule url(r'^module_info/(?P.*)$', 'contentstore.views.module_info', name='module_info'), - + # temporary landing page for a course url(r'^edge/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.landing', name='landing'), @@ -76,13 +82,15 @@ urlpatterns = ('', # User creation and updating views urlpatterns += ( + url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'), url(r'^signup$', 'contentstore.views.signup', name='signup'), url(r'^create_account$', 'student.views.create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name='activate'), # form page - url(r'^login$', 'contentstore.views.login_page', name='login'), + url(r'^login$', 'contentstore.views.old_login_redirect', name='old_login'), + url(r'^signin$', 'contentstore.views.login_page', name='login'), # ajax view that actually does the work url(r'^login_post$', 'student.views.login_user', name='login_post'), diff --git a/common/djangoapps/cache_toolbox/core.py b/common/djangoapps/cache_toolbox/core.py index 3bd184bc2c..a9c7002aa6 100644 --- a/common/djangoapps/cache_toolbox/core.py +++ b/common/djangoapps/cache_toolbox/core.py @@ -14,6 +14,7 @@ from django.db import DEFAULT_DB_ALIAS from . import app_settings from xmodule.contentstore.content import StaticContent + def get_instance(model, instance_or_pk, timeout=None, using=None): """ Returns the ``model`` instance with a primary key of ``instance_or_pk``. @@ -108,11 +109,14 @@ def instance_key(model, instance_or_pk): getattr(instance_or_pk, 'pk', instance_or_pk), ) + def set_cached_content(content): cache.set(str(content.location), content) + def get_cached_content(location): return cache.get(str(location)) + def del_cached_content(location): cache.delete(str(location)) diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py index bc5d80842e..c5e887801e 100644 --- a/common/djangoapps/contentserver/middleware.py +++ b/common/djangoapps/contentserver/middleware.py @@ -12,7 +12,7 @@ from xmodule.exceptions import NotFoundError class StaticContentServer(object): def process_request(self, request): # look to see if the request is prefixed with 'c4x' tag - if request.path.startswith('/' + XASSET_LOCATION_TAG +'/'): + if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'): loc = StaticContent.get_location_from_path(request.path) # first look in our cache so we don't have to round-trip to the DB content = get_cached_content(loc) @@ -21,7 +21,9 @@ class StaticContentServer(object): try: content = contentstore().find(loc) except NotFoundError: - raise Http404 + response = HttpResponse() + response.status_code = 404 + return response # since we fetched it from DB, let's cache it going forward set_cached_content(content) diff --git a/common/djangoapps/course_groups/cohorts.py b/common/djangoapps/course_groups/cohorts.py index f84e18b214..f0234ec71a 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/common/djangoapps/course_groups/cohorts.py @@ -6,6 +6,7 @@ forums, and to the cohort admin views. from django.contrib.auth.models import User from django.http import Http404 import logging +import random from courseware import courses from student.models import get_user_by_username_or_email @@ -13,6 +14,7 @@ from .models import CourseUserGroup log = logging.getLogger(__name__) + def is_course_cohorted(course_id): """ Given a course id, return a boolean for whether or not the course is @@ -63,7 +65,23 @@ def is_commentable_cohorted(course_id, commentable_id): ans)) return ans + +def get_cohorted_commentables(course_id): + """ + Given a course_id return a list of strings representing cohorted commentables + """ + course = courses.get_course_by_id(course_id) + + if not course.is_cohorted: + # this is the easy case :) + ans = [] + else: + ans = course.cohorted_discussions + + return ans + + def get_cohort(user, course_id): """ Given a django User and a course_id, return the user's cohort in that @@ -95,9 +113,30 @@ def get_cohort(user, course_id): group_type=CourseUserGroup.COHORT, users__id=user.id) except CourseUserGroup.DoesNotExist: - # TODO: add auto-cohorting logic here once we know what that will be. + # Didn't find the group. We'll go on to create one if needed. + pass + + if not course.auto_cohort: return None + choices = course.auto_cohort_groups + if len(choices) == 0: + # Nowhere to put user + log.warning("Course %s is auto-cohorted, but there are no" + " auto_cohort_groups specified", + course_id) + return None + + # Put user in a random group, creating it if needed + group_name = random.choice(choices) + group, created = CourseUserGroup.objects.get_or_create( + course_id=course_id, + group_type=CourseUserGroup.COHORT, + name=group_name) + + user.course_groups.add(group) + return group + def get_course_cohorts(course_id): """ @@ -115,6 +154,7 @@ def get_course_cohorts(course_id): ### Helpers for cohort management views + def get_cohort_by_name(course_id, name): """ Return the CourseUserGroup object for the given cohort. Raises DoesNotExist @@ -124,6 +164,7 @@ def get_cohort_by_name(course_id, name): group_type=CourseUserGroup.COHORT, name=name) + def get_cohort_by_id(course_id, cohort_id): """ Return the CourseUserGroup object for the given cohort. Raises DoesNotExist @@ -133,6 +174,7 @@ def get_cohort_by_id(course_id, cohort_id): group_type=CourseUserGroup.COHORT, id=cohort_id) + def add_cohort(course_id, name): """ Add a cohort to a course. Raises ValueError if a cohort of the same name already @@ -148,12 +190,14 @@ def add_cohort(course_id, name): group_type=CourseUserGroup.COHORT, name=name) + class CohortConflict(Exception): """ Raised when user to be added is already in another cohort in same course. """ pass + def add_user_to_cohort(cohort, username_or_email): """ Look up the given user, and if successful, add them to the specified cohort. @@ -211,4 +255,3 @@ def delete_empty_cohort(course_id, name): name, course_id)) cohort.delete() - diff --git a/common/djangoapps/course_groups/models.py b/common/djangoapps/course_groups/models.py index 957d230d92..8bab17493b 100644 --- a/common/djangoapps/course_groups/models.py +++ b/common/djangoapps/course_groups/models.py @@ -5,6 +5,7 @@ from django.db import models log = logging.getLogger(__name__) + class CourseUserGroup(models.Model): """ This model represents groups of users in a course. Groups may have different types, @@ -30,5 +31,3 @@ class CourseUserGroup(models.Model): COHORT = 'cohort' GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),) group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES) - - diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 86f0be0791..efed39d536 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -2,7 +2,7 @@ import django.test from django.contrib.auth.models import User from django.conf import settings -from override_settings import override_settings +from django.test.utils import override_settings from course_groups.models import CourseUserGroup from course_groups.cohorts import (get_cohort, get_course_cohorts, @@ -12,7 +12,8 @@ from xmodule.modulestore.django import modulestore, _MODULESTORES # NOTE: running this with the lms.envs.test config works without # manually overriding the modulestore. However, running with -# cms.envs.test doesn't. +# cms.envs.test doesn't. + def xml_store_config(data_dir): return { @@ -28,6 +29,7 @@ def xml_store_config(data_dir): TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestCohorts(django.test.TestCase): @@ -45,7 +47,10 @@ class TestCohorts(django.test.TestCase): @staticmethod def config_course_cohorts(course, discussions, - cohorted, cohorted_discussions=None): + cohorted, + cohorted_discussions=None, + auto_cohort=None, + auto_cohort_groups=None): """ Given a course with no discussion set up, add the discussions and set the cohort config appropriately. @@ -57,6 +62,9 @@ class TestCohorts(django.test.TestCase): cohorted: bool. cohorted_discussions: optional list of topic names. If specified, converts them to use the same ids as topic names. + auto_cohort: optional bool. + auto_cohort_groups: optional list of strings + (names of groups to put students into). Returns: Nothing -- modifies course in place. @@ -74,6 +82,12 @@ class TestCohorts(django.test.TestCase): if cohorted_discussions is not None: d["cohorted_discussions"] = [to_id(name) for name in cohorted_discussions] + + if auto_cohort is not None: + d["auto_cohort"] = auto_cohort + if auto_cohort_groups is not None: + d["auto_cohort_groups"] = auto_cohort_groups + course.metadata["cohort_config"] = d @@ -87,12 +101,9 @@ class TestCohorts(django.test.TestCase): def test_get_cohort(self): - # Need to fix this, but after we're testing on staging. (Looks like - # problem is that when get_cohort internally tries to look up the - # course.id, it fails, even though we loaded it through the modulestore. - - # Proper fix: give all tests a standard modulestore that uses the test - # dir. + """ + Make sure get_cohort() does the right thing when the course is cohorted + """ course = modulestore().get_course("edX/toy/2012_Fall") self.assertEqual(course.id, "edX/toy/2012_Fall") self.assertFalse(course.is_cohorted) @@ -120,6 +131,54 @@ class TestCohorts(django.test.TestCase): self.assertEquals(get_cohort(other_user, course.id), None, "other_user shouldn't have a cohort") + def test_auto_cohorting(self): + """ + Make sure get_cohort() does the right thing when the course is auto_cohorted + """ + course = modulestore().get_course("edX/toy/2012_Fall") + self.assertEqual(course.id, "edX/toy/2012_Fall") + self.assertFalse(course.is_cohorted) + + user1 = User.objects.create(username="test", email="a@b.com") + user2 = User.objects.create(username="test2", email="a2@b.com") + user3 = User.objects.create(username="test3", email="a3@b.com") + + cohort = CourseUserGroup.objects.create(name="TestCohort", + course_id=course.id, + group_type=CourseUserGroup.COHORT) + + # user1 manually added to a cohort + cohort.users.add(user1) + + # Make the course auto cohorted... + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=["AutoGroup"]) + + self.assertEquals(get_cohort(user1, course.id).id, cohort.id, + "user1 should stay put") + + self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup", + "user2 should be auto-cohorted") + + # Now make the group list empty + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=[]) + + self.assertEquals(get_cohort(user3, course.id), None, + "No groups->no auto-cohorting") + + # Now make it different + self.config_course_cohorts(course, [], cohorted=True, + auto_cohort=True, + auto_cohort_groups=["OtherGroup"]) + + self.assertEquals(get_cohort(user3, course.id).name, "OtherGroup", + "New list->new group") + self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup", + "user2 should still be in originally placed cohort") + def test_get_course_cohorts(self): course1_id = 'a/b/c' @@ -184,6 +243,3 @@ class TestCohorts(django.test.TestCase): self.assertTrue( is_commentable_cohorted(course.id, to_id("Feedback")), "Feedback was listed as cohorted. Should be.") - - - diff --git a/common/djangoapps/course_groups/views.py b/common/djangoapps/course_groups/views.py index d591c44356..6d5ac43fb0 100644 --- a/common/djangoapps/course_groups/views.py +++ b/common/djangoapps/course_groups/views.py @@ -22,6 +22,7 @@ import track.views log = logging.getLogger(__name__) + def json_http_response(data): """ Return an HttpResponse with the data json-serialized and the right content @@ -29,6 +30,7 @@ def json_http_response(data): """ return HttpResponse(json.dumps(data), content_type="application/json") + def split_by_comma_and_whitespace(s): """ Split a string both by commas and whitespice. Returns a list. @@ -177,6 +179,7 @@ def add_users_to_cohort(request, course_id, cohort_id): 'conflict': conflict, 'unknown': unknown}) + @ensure_csrf_cookie @require_POST def remove_user_from_cohort(request, course_id, cohort_id): diff --git a/common/djangoapps/external_auth/admin.py b/common/djangoapps/external_auth/admin.py index e93325bcb2..1ee18dadc1 100644 --- a/common/djangoapps/external_auth/admin.py +++ b/common/djangoapps/external_auth/admin.py @@ -5,8 +5,9 @@ django admin pages for courseware model from external_auth.models import * from django.contrib import admin + class ExternalAuthMapAdmin(admin.ModelAdmin): - search_fields = ['external_id','user__username'] + search_fields = ['external_id', 'user__username'] date_hierarchy = 'dtcreated' admin.site.register(ExternalAuthMap, ExternalAuthMapAdmin) diff --git a/common/djangoapps/external_auth/models.py b/common/djangoapps/external_auth/models.py index e43b306bbb..6c2f38d8b3 100644 --- a/common/djangoapps/external_auth/models.py +++ b/common/djangoapps/external_auth/models.py @@ -12,20 +12,20 @@ file and check it in at the same time as your model changes. To do that, from django.db import models from django.contrib.auth.models import User + class ExternalAuthMap(models.Model): class Meta: unique_together = (('external_id', 'external_domain'), ) external_id = models.CharField(max_length=255, db_index=True) external_domain = models.CharField(max_length=255, db_index=True) - external_credentials = models.TextField(blank=True) # JSON dictionary + external_credentials = models.TextField(blank=True) # JSON dictionary external_email = models.CharField(max_length=255, db_index=True) - external_name = models.CharField(blank=True,max_length=255, db_index=True) + external_name = models.CharField(blank=True, max_length=255, db_index=True) user = models.OneToOneField(User, unique=True, db_index=True, null=True) - internal_password = models.CharField(blank=True, max_length=31) # randomly generated - dtcreated = models.DateTimeField('creation date',auto_now_add=True) - dtsignup = models.DateTimeField('signup date',null=True) # set after signup - + internal_password = models.CharField(blank=True, max_length=31) # randomly generated + dtcreated = models.DateTimeField('creation date', auto_now_add=True) + dtsignup = models.DateTimeField('signup date', null=True) # set after signup + def __unicode__(self): s = "[%s] = (%s / %s)" % (self.external_id, self.external_name, self.external_email) return s - diff --git a/common/djangoapps/external_auth/tests/test_openid_provider.py b/common/djangoapps/external_auth/tests/test_openid_provider.py index 9c522f88b4..570dfbf9ee 100644 --- a/common/djangoapps/external_auth/tests/test_openid_provider.py +++ b/common/djangoapps/external_auth/tests/test_openid_provider.py @@ -13,9 +13,10 @@ from django.test import TestCase, LiveServerTestCase from django.core.urlresolvers import reverse from django.test.client import RequestFactory + class MyFetcher(HTTPFetcher): """A fetcher that uses server-internal calls for performing HTTP - requests. + requests. """ def __init__(self, client): @@ -42,7 +43,7 @@ class MyFetcher(HTTPFetcher): if headers and 'Accept' in headers: data['CONTENT_TYPE'] = headers['Accept'] response = self.client.get(url, data) - + # Translate the test client response to the fetcher's HTTP response abstraction content = response.content final_url = url @@ -60,6 +61,7 @@ class MyFetcher(HTTPFetcher): status=status, ) + class OpenIdProviderTest(TestCase): # def setUp(self): @@ -78,7 +80,7 @@ class OpenIdProviderTest(TestCase): provider_url = reverse('openid-provider-xrds') factory = RequestFactory() request = factory.request() - abs_provider_url = request.build_absolute_uri(location = provider_url) + abs_provider_url = request.build_absolute_uri(location=provider_url) # In order for this absolute URL to work (i.e. to get xrds, then authentication) # in the test environment, we either need a live server that works with the default @@ -86,10 +88,10 @@ class OpenIdProviderTest(TestCase): # Here we do the latter: fetcher = MyFetcher(self.client) openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) - + # now we can begin the login process by invoking a local openid client, # with a pointer to the (also-local) openid provider: - with self.settings(OPENID_SSO_SERVER_URL = abs_provider_url): + with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): url = reverse('openid-login') resp = self.client.post(url) code = 200 @@ -107,7 +109,7 @@ class OpenIdProviderTest(TestCase): provider_url = reverse('openid-provider-login') factory = RequestFactory() request = factory.request() - abs_provider_url = request.build_absolute_uri(location = provider_url) + abs_provider_url = request.build_absolute_uri(location=provider_url) # In order for this absolute URL to work (i.e. to get xrds, then authentication) # in the test environment, we either need a live server that works with the default @@ -115,10 +117,10 @@ class OpenIdProviderTest(TestCase): # Here we do the latter: fetcher = MyFetcher(self.client) openid.fetchers.setDefaultFetcher(fetcher, wrap_exceptions=False) - + # now we can begin the login process by invoking a local openid client, # with a pointer to the (also-local) openid provider: - with self.settings(OPENID_SSO_SERVER_URL = abs_provider_url): + with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): url = reverse('openid-login') resp = self.client.post(url) code = 200 @@ -143,43 +145,43 @@ class OpenIdProviderTest(TestCase): self.assertContains(resp, '', html=True) # this should work on the server: self.assertContains(resp, '', html=True) - + # not included here are elements that will vary from run to run: # # - - + + def testOpenIdSetup(self): if not settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): return url = reverse('openid-provider-login') post_args = { - "openid.mode" : "checkid_setup", - "openid.return_to" : "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", - "openid.assoc_handle" : "{HMAC-SHA1}{50ff8120}{rh87+Q==}", - "openid.claimed_id" : "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns" : "http://specs.openid.net/auth/2.0", - "openid.realm" : "http://testserver/", - "openid.identity" : "http://specs.openid.net/auth/2.0/identifier_select", - "openid.ns.ax" : "http://openid.net/srv/ax/1.0", - "openid.ax.mode" : "fetch_request", - "openid.ax.required" : "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", - "openid.ax.type.fullname" : "http://axschema.org/namePerson", - "openid.ax.type.lastname" : "http://axschema.org/namePerson/last", - "openid.ax.type.firstname" : "http://axschema.org/namePerson/first", - "openid.ax.type.nickname" : "http://axschema.org/namePerson/friendly", - "openid.ax.type.email" : "http://axschema.org/contact/email", - "openid.ax.type.old_email" : "http://schema.openid.net/contact/email", - "openid.ax.type.old_nickname" : "http://schema.openid.net/namePerson/friendly", - "openid.ax.type.old_fullname" : "http://schema.openid.net/namePerson", + "openid.mode": "checkid_setup", + "openid.return_to": "http://testserver/openid/complete/?janrain_nonce=2013-01-23T06%3A20%3A17ZaN7j6H", + "openid.assoc_handle": "{HMAC-SHA1}{50ff8120}{rh87+Q==}", + "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns": "http://specs.openid.net/auth/2.0", + "openid.realm": "http://testserver/", + "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", + "openid.ns.ax": "http://openid.net/srv/ax/1.0", + "openid.ax.mode": "fetch_request", + "openid.ax.required": "email,fullname,old_email,firstname,old_nickname,lastname,old_fullname,nickname", + "openid.ax.type.fullname": "http://axschema.org/namePerson", + "openid.ax.type.lastname": "http://axschema.org/namePerson/last", + "openid.ax.type.firstname": "http://axschema.org/namePerson/first", + "openid.ax.type.nickname": "http://axschema.org/namePerson/friendly", + "openid.ax.type.email": "http://axschema.org/contact/email", + "openid.ax.type.old_email": "http://schema.openid.net/contact/email", + "openid.ax.type.old_nickname": "http://schema.openid.net/namePerson/friendly", + "openid.ax.type.old_fullname": "http://schema.openid.net/namePerson", } resp = self.client.post(url, post_args) code = 200 self.assertEqual(resp.status_code, code, "got code {0} for url '{1}'. Expected code {2}" .format(resp.status_code, url, code)) - - + + # In order for this absolute URL to work (i.e. to get xrds, then authentication) # in the test environment, we either need a live server that works with the default # fetcher (i.e. urlopen2), or a test server that is reached through a custom fetcher. @@ -196,11 +198,11 @@ class OpenIdProviderLiveServerTest(LiveServerTestCase): provider_url = reverse('openid-provider-xrds') factory = RequestFactory() request = factory.request() - abs_provider_url = request.build_absolute_uri(location = provider_url) + abs_provider_url = request.build_absolute_uri(location=provider_url) # now we can begin the login process by invoking a local openid client, # with a pointer to the (also-local) openid provider: - with self.settings(OPENID_SSO_SERVER_URL = abs_provider_url): + with self.settings(OPENID_SSO_SERVER_URL=abs_provider_url): url = reverse('openid-login') resp = self.client.post(url) code = 200 diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index d557b33e9c..effae923b3 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -215,7 +215,7 @@ def ssl_dn_extract_info(dn): else: return None return (user, email, fullname) - + def ssl_get_cert_from_request(request): """ @@ -460,7 +460,7 @@ def provider_login(request): openid_request.answer(False), {}) # checkid_setup, so display login page - # (by falling through to the provider_login at the + # (by falling through to the provider_login at the # bottom of this method). elif openid_request.mode == 'checkid_setup': if openid_request.idSelect(): @@ -482,7 +482,7 @@ def provider_login(request): # handle login redirection: these are also sent to this view function, # but are distinguished by lacking the openid mode. We also know that - # they are posts, because they come from the popup + # they are posts, because they come from the popup elif request.method == 'POST' and 'openid_setup' in request.session: # get OpenID request from session openid_setup = request.session['openid_setup'] @@ -495,7 +495,7 @@ def provider_login(request): return default_render_failure(request, "Invalid OpenID trust root") # check if user with given email exists - # Failure is redirected to this method (by using the original URL), + # Failure is redirected to this method (by using the original URL), # which will bring up the login dialog. email = request.POST.get('email', None) try: @@ -542,17 +542,17 @@ def provider_login(request): # missing fields is up to the Consumer. The proper change # should only return the username, however this will likely # break the CS50 client. Temporarily we will be returning - # username filling in for fullname in addition to username + # username filling in for fullname in addition to username # as sreg nickname. - - # Note too that this is hardcoded, and not really responding to + + # Note too that this is hardcoded, and not really responding to # the extensions that were registered in the first place. results = { 'nickname': user.username, 'email': user.email, 'fullname': user.username } - + # the request succeeded: return provider_respond(server, openid_request, response, results) diff --git a/common/djangoapps/mitxmako/makoloader.py b/common/djangoapps/mitxmako/makoloader.py index 1379027e07..d623e8bcff 100644 --- a/common/djangoapps/mitxmako/makoloader.py +++ b/common/djangoapps/mitxmako/makoloader.py @@ -9,37 +9,39 @@ from django.template.loaders.app_directories import Loader as AppDirectoriesLoad from mitxmako.template import Template import mitxmako.middleware +import tempdir log = logging.getLogger(__name__) + class MakoLoader(object): """ This is a Django loader object which will load the template as a Mako template if the first line is "## mako". It is based off BaseLoader in django.template.loader. """ - + is_usable = False def __init__(self, base_loader): # base_loader is an instance of a BaseLoader subclass self.base_loader = base_loader - + module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) - + if module_directory is None: log.warning("For more caching of mako templates, set the MAKO_MODULE_DIR in settings!") - module_directory = tempfile.mkdtemp() - + module_directory = tempdir.mkdtemp_clean() + self.module_directory = module_directory - - + + def __call__(self, template_name, template_dirs=None): return self.load_template(template_name, template_dirs) def load_template(self, template_name, template_dirs=None): source, file_path = self.load_template_source(template_name, template_dirs) - + if source.startswith("## mako\n"): # This is a mako template template = Template(filename=file_path, module_directory=self.module_directory, uri=template_name) @@ -56,23 +58,24 @@ class MakoLoader(object): # This allows for correct identification (later) of the actual template that does # not exist. return source, file_path - + def load_template_source(self, template_name, template_dirs=None): # Just having this makes the template load as an instance, instead of a class. return self.base_loader.load_template_source(template_name, template_dirs) def reset(self): self.base_loader.reset() - + class MakoFilesystemLoader(MakoLoader): is_usable = True - + def __init__(self): MakoLoader.__init__(self, FilesystemLoader()) - + + class MakoAppDirectoriesLoader(MakoLoader): is_usable = True - + def __init__(self): MakoLoader.__init__(self, AppDirectoriesLoader()) diff --git a/common/djangoapps/mitxmako/middleware.py b/common/djangoapps/mitxmako/middleware.py index 64cb2e5415..3f66f8cc48 100644 --- a/common/djangoapps/mitxmako/middleware.py +++ b/common/djangoapps/mitxmako/middleware.py @@ -13,7 +13,7 @@ # limitations under the License. from mako.lookup import TemplateLookup -import tempfile +import tempdir from django.template import RequestContext from django.conf import settings @@ -29,7 +29,7 @@ class MakoMiddleware(object): module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) if module_directory is None: - module_directory = tempfile.mkdtemp() + module_directory = tempdir.mkdtemp_clean() for location in template_locations: lookup[location] = TemplateLookup(directories=template_locations[location], diff --git a/common/djangoapps/mitxmako/template.py b/common/djangoapps/mitxmako/template.py index 947dc8c1a4..6ef8058c7c 100644 --- a/common/djangoapps/mitxmako/template.py +++ b/common/djangoapps/mitxmako/template.py @@ -20,13 +20,15 @@ from mitxmako import middleware django_variables = ['lookup', 'output_encoding', 'encoding_errors'] # TODO: We should make this a Django Template subclass that simply has the MakoTemplate inside of it? (Intead of inheriting from MakoTemplate) + + class Template(MakoTemplate): """ This bridges the gap between a Mako template and a djano template. It can be rendered like it is a django template because the arguments are transformed in a way that MakoTemplate can understand. """ - + def __init__(self, *args, **kwargs): """Overrides base __init__ to provide django variable overrides""" if not kwargs.get('no_django', False): @@ -34,8 +36,8 @@ class Template(MakoTemplate): overrides['lookup'] = overrides['lookup']['main'] kwargs.update(overrides) super(Template, self).__init__(*args, **kwargs) - - + + def render(self, context_instance): """ This takes a render call with a context (from Django) and translates @@ -43,7 +45,7 @@ class Template(MakoTemplate): """ # collapse context_instance to a single dictionary for mako context_dictionary = {} - + # In various testing contexts, there might not be a current request context. if middleware.requestcontext is not None: for d in middleware.requestcontext: @@ -53,5 +55,5 @@ class Template(MakoTemplate): context_dictionary['settings'] = settings context_dictionary['MITX_ROOT_URL'] = settings.MITX_ROOT_URL context_dictionary['django_context'] = context_instance - + return super(Template, self).render_unicode(**context_dictionary) diff --git a/common/djangoapps/mitxmako/templatetag_helpers.py b/common/djangoapps/mitxmako/templatetag_helpers.py index e254625d3d..cd871a0fc5 100644 --- a/common/djangoapps/mitxmako/templatetag_helpers.py +++ b/common/djangoapps/mitxmako/templatetag_helpers.py @@ -2,14 +2,15 @@ from django.template import loader from django.template.base import Template, Context from django.template.loader import get_template, select_template + def django_template_include(file_name, mako_context): """ This can be used within a mako template to include a django template in the way that a django-style {% include %} does. Pass it context which can be the mako context ('context') or a dictionary. """ - - dictionary = dict( mako_context ) + + dictionary = dict(mako_context) return loader.render_to_string(file_name, dictionary=dictionary) @@ -18,7 +19,7 @@ def render_inclusion(func, file_name, takes_context, django_context, *args, **kw This allows a mako template to call a template tag function (written for django templates) that is an "inclusion tag". These functions are decorated with @register.inclusion_tag. - + -func: This is the function that is registered as an inclusion tag. You must import it directly using a python import statement. -file_name: This is the filename of the template, passed into the @@ -29,10 +30,10 @@ def render_inclusion(func, file_name, takes_context, django_context, *args, **kw a copy of the django context is available as 'django_context'. -*args and **kwargs are the arguments to func. """ - + if takes_context: args = [django_context] + list(args) - + _dict = func(*args, **kwargs) if isinstance(file_name, Template): t = file_name @@ -40,14 +41,12 @@ def render_inclusion(func, file_name, takes_context, django_context, *args, **kw t = select_template(file_name) else: t = get_template(file_name) - + nodelist = t.nodelist - + new_context = Context(_dict) csrf_token = django_context.get('csrf_token', None) if csrf_token is not None: new_context['csrf_token'] = csrf_token - - return nodelist.render(new_context) - + return nodelist.render(new_context) diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py deleted file mode 100644 index e75362d784..0000000000 --- a/common/djangoapps/static_replace.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging -import re - -from staticfiles.storage import staticfiles_storage -from staticfiles import finders -from django.conf import settings - -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml import XMLModuleStore -from xmodule.contentstore.content import StaticContent - -log = logging.getLogger(__name__) - -def try_staticfiles_lookup(path): - """ - Try to lookup a path in staticfiles_storage. If it fails, return - a dead link instead of raising an exception. - """ - try: - url = staticfiles_storage.url(path) - except Exception as err: - log.warning("staticfiles_storage couldn't find path {0}: {1}".format( - path, str(err))) - # Just return the original path; don't kill everything. - url = path - return url - - -def replace(static_url, prefix=None, course_namespace=None): - if prefix is None: - prefix = '' - else: - prefix = prefix + '/' - - quote = static_url.group('quote') - - servable = ( - # If in debug mode, we'll serve up anything that the finders can find - (settings.DEBUG and finders.find(static_url.group('rest'), True)) or - # Otherwise, we'll only serve up stuff that the storages can find - staticfiles_storage.exists(static_url.group('rest')) - ) - - if servable: - return static_url.group(0) - else: - # don't error if file can't be found - # cdodge: to support the change over to Mongo backed content stores, lets - # use the utility functions in StaticContent.py - if static_url.group('prefix') == '/static/' and not isinstance(modulestore(), XMLModuleStore): - if course_namespace is None: - raise BaseException('You must pass in course_namespace when remapping static content urls with MongoDB stores') - url = StaticContent.convert_legacy_static_url(static_url.group('rest'), course_namespace) - else: - url = try_staticfiles_lookup(prefix + static_url.group('rest')) - - new_link = "".join([quote, url, quote]) - return new_link - - - -def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): - - def replace_url(static_url): - return replace(static_url, staticfiles_prefix, course_namespace = course_namespace) - - return re.sub(r""" - (?x) # flags=re.VERBOSE - (?P\\?['"]) # the opening quotes - (?P{prefix}) # the prefix - (?P.*?) # everything else in the url - (?P=quote) # the first matching closing quote - """.format(prefix=replace_prefix), replace_url, text) diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py new file mode 100644 index 0000000000..fb1f48d143 --- /dev/null +++ b/common/djangoapps/static_replace/__init__.py @@ -0,0 +1,114 @@ +import logging +import re + +from staticfiles.storage import staticfiles_storage +from staticfiles import finders +from django.conf import settings + +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.xml import XMLModuleStore +from xmodule.contentstore.content import StaticContent + +log = logging.getLogger(__name__) + + +def _url_replace_regex(prefix): + """ + Match static urls in quotes that don't end in '?raw'. + + To anyone contemplating making this more complicated: + http://xkcd.com/1171/ + """ + return r""" + (?x) # flags=re.VERBOSE + (?P\\?['"]) # the opening quotes + (?P{prefix}) # the prefix + (?P.*?) # everything else in the url + (?P=quote) # the first matching closing quote + """.format(prefix=prefix) + + +def try_staticfiles_lookup(path): + """ + Try to lookup a path in staticfiles_storage. If it fails, return + a dead link instead of raising an exception. + """ + try: + url = staticfiles_storage.url(path) + except Exception as err: + log.warning("staticfiles_storage couldn't find path {0}: {1}".format( + path, str(err))) + # Just return the original path; don't kill everything. + url = path + return url + + +def replace_course_urls(text, course_id): + """ + Replace /course/$stuff urls with /courses/$course_id/$stuff urls + + text: The text to replace + course_module: A CourseDescriptor + + returns: text with the links replaced + """ + + + def replace_course_url(match): + quote = match.group('quote') + rest = match.group('rest') + return "".join([quote, '/courses/' + course_id + '/', rest, quote]) + + return re.sub(_url_replace_regex('/course/'), replace_course_url, text) + + +def replace_static_urls(text, data_directory, course_namespace=None): + """ + Replace /static/$stuff urls either with their correct url as generated by collectstatic, + (/static/$md5_hashed_stuff) or by the course-specific content static url + /static/$course_data_dir/$stuff, or, if course_namespace is not None, by the + correct url in the contentstore (c4x://) + + text: The source text to do the substitution in + data_directory: The directory in which course data is stored + course_namespace: The course identifier used to distinguish static content for this course in studio + """ + + def replace_static_url(match): + original = match.group(0) + prefix = match.group('prefix') + quote = match.group('quote') + rest = match.group('rest') + + # Don't mess with things that end in '?raw' + if rest.endswith('?raw'): + return original + + # course_namespace is not None, then use studio style urls + if course_namespace is not None and not isinstance(modulestore(), XMLModuleStore): + url = StaticContent.convert_legacy_static_url(rest, course_namespace) + # In debug mode, if we can find the url as is, + elif settings.DEBUG and finders.find(rest, True): + return original + # Otherwise, look the file up in staticfiles_storage, and append the data directory if needed + else: + course_path = "/".join((data_directory, rest)) + + try: + if staticfiles_storage.exists(rest): + url = staticfiles_storage.url(rest) + else: + url = staticfiles_storage.url(course_path) + # And if that fails, assume that it's course content, and add manually data directory + except Exception as err: + log.warning("staticfiles_storage couldn't find path {0}: {1}".format( + rest, str(err))) + url = "".join([prefix, course_path]) + + return "".join([quote, url, quote]) + + return re.sub( + _url_replace_regex('/static/(?!{data_dir})'.format(data_dir=data_directory)), + replace_static_url, + text + ) diff --git a/lms/djangoapps/portal/__init__.py b/common/djangoapps/static_replace/management/__init__.py similarity index 100% rename from lms/djangoapps/portal/__init__.py rename to common/djangoapps/static_replace/management/__init__.py diff --git a/common/djangoapps/static_replace/management/commands/__init__.py b/common/djangoapps/static_replace/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/static_replace/management/commands/clear_collectstatic_cache.py b/common/djangoapps/static_replace/management/commands/clear_collectstatic_cache.py new file mode 100644 index 0000000000..60b7c58047 --- /dev/null +++ b/common/djangoapps/static_replace/management/commands/clear_collectstatic_cache.py @@ -0,0 +1,15 @@ +### +### Script for importing courseware from XML format +### + +from django.core.management.base import NoArgsCommand +from django.core.cache import get_cache + + +class Command(NoArgsCommand): + help = \ +'''Import the specified data directory into the default ModuleStore''' + + def handle_noargs(self, **options): + staticfiles_cache = get_cache('staticfiles') + staticfiles_cache.clear() diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py new file mode 100644 index 0000000000..f23610e1bd --- /dev/null +++ b/common/djangoapps/static_replace/test/test_static_replace.py @@ -0,0 +1,111 @@ +import re + +from nose.tools import assert_equals, assert_true, assert_false +from static_replace import (replace_static_urls, replace_course_urls, + _url_replace_regex) +from mock import patch, Mock +from xmodule.modulestore import Location +from xmodule.modulestore.mongo import MongoModuleStore +from xmodule.modulestore.xml import XMLModuleStore + +DATA_DIRECTORY = 'data_dir' +COURSE_ID = 'org/course/run' +NAMESPACE = Location('org', 'course', 'run', None, None) +STATIC_SOURCE = '"/static/file.png"' + + +def test_multi_replace(): + course_source = '"/course/file.png"' + + assert_equals( + replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY), + replace_static_urls(replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY), DATA_DIRECTORY) + ) + assert_equals( + replace_course_urls(course_source, COURSE_ID), + replace_course_urls(replace_course_urls(course_source, COURSE_ID), COURSE_ID) + ) + + +@patch('static_replace.staticfiles_storage') +def test_storage_url_exists(mock_storage): + mock_storage.exists.return_value = True + mock_storage.url.return_value = '/static/file.png' + + assert_equals('"/static/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY)) + mock_storage.exists.called_once_with('file.png') + mock_storage.url.called_once_with('data_dir/file.png') + + +@patch('static_replace.staticfiles_storage') +def test_storage_url_not_exists(mock_storage): + mock_storage.exists.return_value = False + mock_storage.url.return_value = '/static/data_dir/file.png' + + assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY)) + mock_storage.exists.called_once_with('file.png') + mock_storage.url.called_once_with('file.png') + + +@patch('static_replace.StaticContent') +@patch('static_replace.modulestore') +def test_mongo_filestore(mock_modulestore, mock_static_content): + + mock_modulestore.return_value = Mock(MongoModuleStore) + mock_static_content.convert_legacy_static_url.return_value = "c4x://mock_url" + + # No namespace => no change to path + assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY)) + + # Namespace => content url + assert_equals( + '"' + mock_static_content.convert_legacy_static_url.return_value + '"', + replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY, NAMESPACE) + ) + + mock_static_content.convert_legacy_static_url.assert_called_once_with('file.png', NAMESPACE) + + +@patch('static_replace.settings') +@patch('static_replace.modulestore') +@patch('static_replace.staticfiles_storage') +def test_data_dir_fallback(mock_storage, mock_modulestore, mock_settings): + mock_modulestore.return_value = Mock(XMLModuleStore) + mock_storage.url.side_effect = Exception + + mock_storage.exists.return_value = True + assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY)) + + mock_storage.exists.return_value = False + assert_equals('"/static/data_dir/file.png"', replace_static_urls(STATIC_SOURCE, DATA_DIRECTORY)) + + +def test_raw_static_check(): + """ + Make sure replace_static_urls leaves alone things that end in '.raw' + """ + path = '"/static/foo.png?raw"' + assert_equals(path, replace_static_urls(path, DATA_DIRECTORY)) + + text = 'text
          0: record['registration_error'] = registration.upload_error_message @@ -71,8 +71,7 @@ class Command(BaseCommand): record['needs_uploading'] = True output.append(record) - + # dump output: with open(outputfile, 'w') as outfile: dump(output, outfile, indent=2) - diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py index 463eec6b70..bad98b9d25 100644 --- a/common/djangoapps/student/management/commands/pearson_export_cdd.py +++ b/common/djangoapps/student/management/commands/pearson_export_cdd.py @@ -39,7 +39,7 @@ class Command(BaseCommand): ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store ]) - # define defaults, even thought 'store_true' shouldn't need them. + # define defaults, even thought 'store_true' shouldn't need them. # (call_command will set None as default value for all options that don't have one, # so one cannot rely on presence/absence of flags in that world.) option_list = BaseCommand.option_list + ( @@ -56,7 +56,7 @@ class Command(BaseCommand): ) def handle(self, **options): - # update time should use UTC in order to be comparable to the user_updated_at + # update time should use UTC in order to be comparable to the user_updated_at # field uploaded_at = datetime.utcnow() @@ -100,7 +100,7 @@ class Command(BaseCommand): extrasaction='ignore') writer.writeheader() for tcu in TestCenterUser.objects.order_by('id'): - if tcu.needs_uploading: # or dump_all + if tcu.needs_uploading: # or dump_all record = dict((csv_field, ensure_encoding(getattr(tcu, model_field))) for csv_field, model_field in Command.CSV_TO_MODEL_FIELDS.items()) diff --git a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py b/common/djangoapps/student/management/commands/pearson_import_conf_zip.py index d94c3ba863..d0b2938df0 100644 --- a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py +++ b/common/djangoapps/student/management/commands/pearson_import_conf_zip.py @@ -116,4 +116,3 @@ class Command(BaseCommand): tcuser.save() except TestCenterUser.DoesNotExist: Command.datadog_error(" Failed to find record for client_candidate_id {}".format(client_candidate_id), vcdcfile.name) - diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py index b59241240d..b10cf143a0 100644 --- a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py +++ b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py @@ -9,6 +9,7 @@ from student.views import course_from_id from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError + class Command(BaseCommand): option_list = BaseCommand.option_list + ( # registration info: @@ -16,23 +17,23 @@ class Command(BaseCommand): '--accommodation_request', action='store', dest='accommodation_request', - ), + ), make_option( '--accommodation_code', action='store', dest='accommodation_code', - ), + ), make_option( '--client_authorization_id', action='store', dest='client_authorization_id', - ), - # exam info: + ), + # exam info: make_option( '--exam_series_code', action='store', dest='exam_series_code', - ), + ), make_option( '--eligibility_appointment_date_first', action='store', @@ -51,32 +52,32 @@ class Command(BaseCommand): action='store', dest='authorization_id', help='ID we receive from Pearson for a particular authorization' - ), + ), make_option( '--upload_status', action='store', dest='upload_status', help='status value assigned by Pearson' - ), + ), make_option( '--upload_error_message', action='store', dest='upload_error_message', help='error message provided by Pearson on a failure.' - ), + ), # control values: make_option( '--ignore_registration_dates', action='store_true', dest='ignore_registration_dates', help='find exam info for course based on exam_series_code, even if the exam is not active.' - ), + ), make_option( '--create_dummy_exam', action='store_true', dest='create_dummy_exam', help='create dummy exam info for course, even if course exists' - ), + ), ) args = "" help = "Create or modify a TestCenterRegistration entry for a given Student" @@ -103,7 +104,7 @@ class Command(BaseCommand): testcenter_user = TestCenterUser.objects.get(user=student) except TestCenterUser.DoesNotExist: raise CommandError("User \"{}\" does not have an existing demographics record".format(username)) - + # get an "exam" object. Check to see if a course_id was specified, and use information from that: exam = None create_dummy_exam = 'create_dummy_exam' in our_options and our_options['create_dummy_exam'] @@ -115,14 +116,14 @@ class Command(BaseCommand): exam = examlist[0] if len(examlist) > 0 else None else: exam = course.current_test_center_exam - except ItemNotFoundError: + except ItemNotFoundError: pass else: - # otherwise use explicit values (so we don't have to define a course): + # otherwise use explicit values (so we don't have to define a course): exam_name = "Dummy Placeholder Name" - exam_info = { 'Exam_Series_Code': our_options['exam_series_code'], - 'First_Eligible_Appointment_Date' : our_options['eligibility_appointment_date_first'], - 'Last_Eligible_Appointment_Date' : our_options['eligibility_appointment_date_last'], + exam_info = {'Exam_Series_Code': our_options['exam_series_code'], + 'First_Eligible_Appointment_Date': our_options['eligibility_appointment_date_first'], + 'Last_Eligible_Appointment_Date': our_options['eligibility_appointment_date_last'], } exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info) # update option values for date_first and date_last to use YYYY-MM-DD format @@ -134,15 +135,15 @@ class Command(BaseCommand): raise CommandError("Exam for course_id {} does not exist".format(course_id)) exam_code = exam.exam_series_code - - UPDATE_FIELDS = ( 'accommodation_request', + + UPDATE_FIELDS = ('accommodation_request', 'accommodation_code', 'client_authorization_id', 'exam_series_code', 'eligibility_appointment_date_first', 'eligibility_appointment_date_last', ) - + # create and save the registration: needs_updating = False registrations = get_testcenter_registration(student, course_id, exam_code) @@ -152,29 +153,29 @@ class Command(BaseCommand): if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]: needs_updating = True; else: - accommodation_request = our_options.get('accommodation_request','') + accommodation_request = our_options.get('accommodation_request', '') registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) needs_updating = True - + if needs_updating: # first update the record with the new values, if any: for fieldname in UPDATE_FIELDS: - if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields: + if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields: registration.__setattr__(fieldname, our_options[fieldname]) - - # the registration form normally populates the data dict with + + # the registration form normally populates the data dict with # the accommodation request (if any). But here we want to # specify only those values that might change, so update the dict with existing # values. form_options = dict(our_options) for propname in TestCenterRegistrationForm.Meta.fields: - if propname not in form_options: + if propname not in form_options: form_options[propname] = registration.__getattribute__(propname) form = TestCenterRegistrationForm(instance=registration, data=form_options) if form.is_valid(): form.update_and_save() - print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code) + print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code) else: if (len(form.errors) > 0): print "Field Form errors encountered:" @@ -185,24 +186,22 @@ class Command(BaseCommand): print "Non-field Form errors encountered:" for nonfielderror in form.non_field_errors: print "Non-field Form Error: %s" % nonfielderror - + else: print "No changes necessary to make to existing user's registration." - + # override internal values: change_internal = False if 'exam_series_code' in our_options: exam_code = our_options['exam_series_code'] registration = get_testcenter_registration(student, course_id, exam_code)[0] - for internal_field in [ 'upload_error_message', 'upload_status', 'authorization_id']: + for internal_field in ['upload_error_message', 'upload_status', 'authorization_id']: if internal_field in our_options: registration.__setattr__(internal_field, our_options[internal_field]) change_internal = True - + if change_internal: print "Updated confirmation information in existing user's registration." registration.save() else: print "No changes necessary to make to confirmation information in existing user's registration." - - diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_user.py b/common/djangoapps/student/management/commands/pearson_make_tc_user.py index 87e0b4dadd..10ef0bd067 100644 --- a/common/djangoapps/student/management/commands/pearson_make_tc_user.py +++ b/common/djangoapps/student/management/commands/pearson_make_tc_user.py @@ -5,60 +5,61 @@ from django.core.management.base import BaseCommand, CommandError from student.models import TestCenterUser, TestCenterUserForm + class Command(BaseCommand): option_list = BaseCommand.option_list + ( - # demographics: + # demographics: make_option( '--first_name', action='store', dest='first_name', - ), + ), make_option( '--middle_name', action='store', dest='middle_name', - ), + ), make_option( '--last_name', action='store', dest='last_name', - ), + ), make_option( '--suffix', action='store', dest='suffix', - ), + ), make_option( '--salutation', action='store', dest='salutation', - ), + ), make_option( '--address_1', action='store', dest='address_1', - ), + ), make_option( '--address_2', action='store', dest='address_2', - ), + ), make_option( '--address_3', action='store', dest='address_3', - ), + ), make_option( '--city', action='store', dest='city', - ), + ), make_option( '--state', action='store', dest='state', help='Two letter code (e.g. MA)' - ), + ), make_option( '--postal_code', action='store', @@ -75,12 +76,12 @@ class Command(BaseCommand): action='store', dest='phone', help='Pretty free-form (parens, spaces, dashes), but no country code' - ), + ), make_option( '--extension', action='store', dest='extension', - ), + ), make_option( '--phone_country_code', action='store', @@ -92,7 +93,7 @@ class Command(BaseCommand): action='store', dest='fax', help='Pretty free-form (parens, spaces, dashes), but no country code' - ), + ), make_option( '--fax_country_code', action='store', @@ -103,26 +104,26 @@ class Command(BaseCommand): '--company_name', action='store', dest='company_name', - ), + ), # internal values: make_option( '--client_candidate_id', action='store', dest='client_candidate_id', help='ID we assign a user to identify them to Pearson' - ), + ), make_option( '--upload_status', action='store', dest='upload_status', help='status value assigned by Pearson' - ), + ), make_option( '--upload_error_message', action='store', dest='upload_error_message', help='error message provided by Pearson on a failure.' - ), + ), ) args = "" help = "Create or modify a TestCenterUser entry for a given Student" @@ -142,20 +143,20 @@ class Command(BaseCommand): student = User.objects.get(username=username) try: testcenter_user = TestCenterUser.objects.get(user=student) - needs_updating = testcenter_user.needs_update(our_options) + needs_updating = testcenter_user.needs_update(our_options) except TestCenterUser.DoesNotExist: # do additional initialization here: testcenter_user = TestCenterUser.create(student) needs_updating = True - + if needs_updating: - # the registration form normally populates the data dict with + # the registration form normally populates the data dict with # all values from the testcenter_user. But here we only want to # specify those values that change, so update the dict with existing # values. form_options = dict(our_options) for propname in TestCenterUser.user_provided_fields(): - if propname not in form_options: + if propname not in form_options: form_options[propname] = testcenter_user.__getattribute__(propname) form = TestCenterUserForm(instance=testcenter_user, data=form_options) if form.is_valid(): @@ -170,21 +171,20 @@ class Command(BaseCommand): errorlist.append("Non-field Form errors encountered:") for nonfielderror in form.non_field_errors: errorlist.append("Non-field Form Error: {}".format(nonfielderror)) - raise CommandError("\n".join(errorlist)) + raise CommandError("\n".join(errorlist)) else: print "No changes necessary to make to existing user's demographics." - + # override internal values: change_internal = False testcenter_user = TestCenterUser.objects.get(user=student) - for internal_field in [ 'upload_error_message', 'upload_status', 'client_candidate_id']: + for internal_field in ['upload_error_message', 'upload_status', 'client_candidate_id']: if internal_field in our_options: testcenter_user.__setattr__(internal_field, our_options[internal_field]) change_internal = True - + if change_internal: testcenter_user.save() print "Updated confirmation information in existing user's demographics." else: print "No changes necessary to make to confirmation information in existing user's demographics." - diff --git a/common/djangoapps/student/management/commands/pearson_transfer.py b/common/djangoapps/student/management/commands/pearson_transfer.py index 6811e1833d..5eded6484a 100644 --- a/common/djangoapps/student/management/commands/pearson_transfer.py +++ b/common/djangoapps/student/management/commands/pearson_transfer.py @@ -46,10 +46,10 @@ class Command(BaseCommand): if not hasattr(settings, value): raise CommandError('No entry in the AWS settings' '(env/auth.json) for {0}'.format(value)) - + # check additional required settings for import and export: if options['mode'] in ('export', 'both'): - for value in ['LOCAL_EXPORT','SFTP_EXPORT']: + for value in ['LOCAL_EXPORT', 'SFTP_EXPORT']: if value not in settings.PEARSON: raise CommandError('No entry in the PEARSON settings' '(env/auth.json) for {0}'.format(value)) @@ -57,9 +57,9 @@ class Command(BaseCommand): source_dir = settings.PEARSON['LOCAL_EXPORT'] if not os.path.isdir(source_dir): os.makedirs(source_dir) - + if options['mode'] in ('import', 'both'): - for value in ['LOCAL_IMPORT','SFTP_IMPORT']: + for value in ['LOCAL_IMPORT', 'SFTP_IMPORT']: if value not in settings.PEARSON: raise CommandError('No entry in the PEARSON settings' '(env/auth.json) for {0}'.format(value)) @@ -76,7 +76,7 @@ class Command(BaseCommand): t.connect(username=settings.PEARSON['SFTP_USERNAME'], password=settings.PEARSON['SFTP_PASSWORD']) sftp = paramiko.SFTPClient.from_transport(t) - + if mode == 'export': try: sftp.chdir(files_to) @@ -92,7 +92,7 @@ class Command(BaseCommand): except IOError: raise CommandError('SFTP source path does not exist: {}'.format(files_from)) for filename in sftp.listdir('.'): - # skip subdirectories + # skip subdirectories if not S_ISDIR(sftp.stat(filename).st_mode): sftp.get(filename, files_to + '/' + filename) # delete files from sftp server once they are successfully pulled off: @@ -112,7 +112,7 @@ class Command(BaseCommand): try: for filename in os.listdir(files_from): source_file = os.path.join(files_from, filename) - # use mode as name of directory into which to write files + # use mode as name of directory into which to write files dest_file = os.path.join(mode, filename) upload_file_to_s3(bucket, source_file, dest_file) if deleteAfterCopy: @@ -135,17 +135,17 @@ class Command(BaseCommand): k.set_contents_from_filename(source_file) def export_pearson(): - options = { 'dest-from-settings' : True } + options = {'dest-from-settings': True} call_command('pearson_export_cdd', **options) call_command('pearson_export_ead', **options) mode = 'export' - sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy = False) + sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy=False) s3(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=True) def import_pearson(): mode = 'import' try: - sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy = True) + sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy=True) s3(settings.PEARSON['LOCAL_IMPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=False) except Exception as e: dog_http_api.event('Pearson Import failure', str(e)) diff --git a/common/djangoapps/student/management/commands/tests/test_pearson.py b/common/djangoapps/student/management/commands/tests/test_pearson.py index 2f4878a09d..65d628fba0 100644 --- a/common/djangoapps/student/management/commands/tests/test_pearson.py +++ b/common/djangoapps/student/management/commands/tests/test_pearson.py @@ -7,6 +7,7 @@ import logging import os from tempfile import mkdtemp import cStringIO +import shutil import sys from django.test import TestCase @@ -17,30 +18,31 @@ from student.models import User, TestCenterRegistration, TestCenterUser, get_tes log = logging.getLogger(__name__) + def create_tc_user(username): user = User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass') options = { - 'first_name' : 'TestFirst', - 'last_name' : 'TestLast', - 'address_1' : 'Test Address', - 'city' : 'TestCity', - 'state' : 'Alberta', - 'postal_code' : 'A0B 1C2', - 'country' : 'CAN', - 'phone' : '252-1866', - 'phone_country_code' : '1', + 'first_name': 'TestFirst', + 'last_name': 'TestLast', + 'address_1': 'Test Address', + 'city': 'TestCity', + 'state': 'Alberta', + 'postal_code': 'A0B 1C2', + 'country': 'CAN', + 'phone': '252-1866', + 'phone_country_code': '1', } call_command('pearson_make_tc_user', username, **options) return TestCenterUser.objects.get(user=user) - - -def create_tc_registration(username, course_id = 'org1/course1/term1', exam_code = 'exam1', accommodation_code = None): - - options = { 'exam_series_code' : exam_code, - 'eligibility_appointment_date_first' : '2013-01-01T00:00', - 'eligibility_appointment_date_last' : '2013-12-31T23:59', - 'accommodation_code' : accommodation_code, - 'create_dummy_exam' : True, + + +def create_tc_registration(username, course_id='org1/course1/term1', exam_code='exam1', accommodation_code=None): + + options = {'exam_series_code': exam_code, + 'eligibility_appointment_date_first': '2013-01-01T00:00', + 'eligibility_appointment_date_last': '2013-12-31T23:59', + 'accommodation_code': accommodation_code, + 'create_dummy_exam': True, } call_command('pearson_make_tc_registration', username, course_id, **options) @@ -48,21 +50,23 @@ def create_tc_registration(username, course_id = 'org1/course1/term1', exam_code registrations = get_testcenter_registration(user, course_id, exam_code) return registrations[0] + def create_multiple_registrations(prefix='test'): username1 = '{}_multiple1'.format(prefix) create_tc_user(username1) create_tc_registration(username1) - create_tc_registration(username1, course_id = 'org1/course2/term1') - create_tc_registration(username1, exam_code = 'exam2') + create_tc_registration(username1, course_id='org1/course2/term1') + create_tc_registration(username1, exam_code='exam2') username2 = '{}_multiple2'.format(prefix) create_tc_user(username2) create_tc_registration(username2) username3 = '{}_multiple3'.format(prefix) create_tc_user(username3) - create_tc_registration(username3, course_id = 'org1/course2/term1') + create_tc_registration(username3, course_id='org1/course2/term1') username4 = '{}_multiple4'.format(prefix) create_tc_user(username4) - create_tc_registration(username4, exam_code = 'exam2') + create_tc_registration(username4, exam_code='exam2') + def get_command_error_text(*args, **options): stderr_string = None @@ -75,21 +79,22 @@ def get_command_error_text(*args, **options): # But these are actually translated into nice messages, # and sys.exit(1) is then called. For testing, we # want to catch what sys.exit throws, and get the - # relevant text either from stdout or stderr. + # relevant text either from stdout or stderr. if (why1.message > 0): stderr_string = sys.stderr.getvalue() else: raise why1 except Exception, why: raise why - + finally: sys.stderr = old_stderr - + if stderr_string is None: - raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) + raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) return stderr_string - + + def get_error_string_for_management_call(*args, **options): stdout_string = None old_stdout = sys.stdout @@ -103,7 +108,7 @@ def get_error_string_for_management_call(*args, **options): # But these are actually translated into nice messages, # and sys.exit(1) is then called. For testing, we # want to catch what sys.exit throws, and get the - # relevant text either from stdout or stderr. + # relevant text either from stdout or stderr. if (why1.message == 1): stdout_string = sys.stdout.getvalue() stderr_string = sys.stderr.getvalue() @@ -111,15 +116,15 @@ def get_error_string_for_management_call(*args, **options): raise why1 except Exception, why: raise why - + finally: sys.stdout = old_stdout sys.stderr = old_stderr - + if stdout_string is None: - raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) + raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) return stdout_string, stderr_string - + def get_file_info(dirpath): filelist = os.listdir(dirpath) @@ -132,43 +137,40 @@ def get_file_info(dirpath): numlines = len(filecontents) return filepath, numlines else: - raise Exception("Expected to find a single file in {}, but found {}".format(dirpath,filelist)) - + raise Exception("Expected to find a single file in {}, but found {}".format(dirpath, filelist)) + + class PearsonTestCase(TestCase): ''' Base class for tests running Pearson-related commands ''' - import_dir = mkdtemp(prefix="import") - export_dir = mkdtemp(prefix="export") - + def assertErrorContains(self, error_message, expected): self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected)) - + + def setUp(self): + self.import_dir = mkdtemp(prefix="import") + self.addCleanup(shutil.rmtree, self.import_dir) + self.export_dir = mkdtemp(prefix="export") + self.addCleanup(shutil.rmtree, self.export_dir) + def tearDown(self): - def delete_temp_dir(dirname): - if os.path.exists(dirname): - for filename in os.listdir(dirname): - os.remove(os.path.join(dirname, filename)) - os.rmdir(dirname) - - # clean up after any test data was dumped to temp directory - delete_temp_dir(self.import_dir) - delete_temp_dir(self.export_dir) - + pass # and clean up the database: # TestCenterUser.objects.all().delete() # TestCenterRegistration.objects.all().delete() + class PearsonCommandTestCase(PearsonTestCase): def test_missing_demographic_fields(self): - # We won't bother to test all details of form validation here. + # We won't bother to test all details of form validation here. # It is enough to show that it works here, but deal with test cases for the form # validation in the student tests, not these management tests. username = 'baduser' User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass') options = {} - error_string = get_command_error_text('pearson_make_tc_user', username, **options) + error_string = get_command_error_text('pearson_make_tc_user', username, **options) self.assertTrue(error_string.find('Field Form errors encountered:') >= 0) self.assertTrue(error_string.find('Field Form Error: city') >= 0) self.assertTrue(error_string.find('Field Form Error: first_name') >= 0) @@ -178,11 +180,11 @@ class PearsonCommandTestCase(PearsonTestCase): self.assertTrue(error_string.find('Field Form Error: phone') >= 0) self.assertTrue(error_string.find('Field Form Error: address_1') >= 0) self.assertErrorContains(error_string, 'Field Form Error: address_1') - + def test_create_good_testcenter_user(self): testcenter_user = create_tc_user("test_good_user") self.assertIsNotNone(testcenter_user) - + def test_create_good_testcenter_registration(self): username = 'test_good_registration' create_tc_user(username) @@ -192,21 +194,21 @@ class PearsonCommandTestCase(PearsonTestCase): def test_cdd_missing_option(self): error_string = get_command_error_text('pearson_export_cdd', **{}) self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used') - + def test_ead_missing_option(self): error_string = get_command_error_text('pearson_export_ead', **{}) self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used') def test_export_single_cdd(self): # before we generate any tc_users, we expect there to be nothing to output: - options = { 'dest-from-settings' : True } - with self.settings(PEARSON={ 'LOCAL_EXPORT' : self.export_dir }): + options = {'dest-from-settings': True} + with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): call_command('pearson_export_cdd', **options) (filepath, numlines) = get_file_info(self.export_dir) self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines") os.remove(filepath) - # generating a tc_user should result in a line in the output + # generating a tc_user should result in a line in the output username = 'test_single_cdd' create_tc_user(username) call_command('pearson_export_cdd', **options) @@ -221,23 +223,23 @@ class PearsonCommandTestCase(PearsonTestCase): os.remove(filepath) # if we modify the record, then it should be output again: - user_options = { 'first_name' : 'NewTestFirst', } + user_options = {'first_name': 'NewTestFirst', } call_command('pearson_make_tc_user', username, **user_options) call_command('pearson_export_cdd', **options) (filepath, numlines) = get_file_info(self.export_dir) self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line") os.remove(filepath) - + def test_export_single_ead(self): # before we generate any registrations, we expect there to be nothing to output: - options = { 'dest-from-settings' : True } - with self.settings(PEARSON={ 'LOCAL_EXPORT' : self.export_dir }): + options = {'dest-from-settings': True} + with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): call_command('pearson_export_ead', **options) (filepath, numlines) = get_file_info(self.export_dir) self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines") os.remove(filepath) - # generating a registration should result in a line in the output + # generating a registration should result in a line in the output username = 'test_single_ead' create_tc_user(username) create_tc_registration(username) @@ -251,7 +253,7 @@ class PearsonCommandTestCase(PearsonTestCase): (filepath, numlines) = get_file_info(self.export_dir) self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines") os.remove(filepath) - + # if we modify the record, then it should be output again: create_tc_registration(username, accommodation_code='EQPMNT') call_command('pearson_export_ead', **options) @@ -261,8 +263,8 @@ class PearsonCommandTestCase(PearsonTestCase): def test_export_multiple(self): create_multiple_registrations("export") - with self.settings(PEARSON={ 'LOCAL_EXPORT' : self.export_dir }): - options = { 'dest-from-settings' : True } + with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): + options = {'dest-from-settings': True} call_command('pearson_export_cdd', **options) (filepath, numlines) = get_file_info(self.export_dir) self.assertEquals(numlines, 5, "Expect cdd file to have four non-header lines: total was {}".format(numlines)) @@ -294,6 +296,7 @@ S3_BUCKET = 'edx-pearson-archive' AWS_ACCESS_KEY_ID = 'put yours here' AWS_SECRET_ACCESS_KEY = 'put yours here' + class PearsonTransferTestCase(PearsonTestCase): ''' Class for tests running Pearson transfers @@ -302,14 +305,14 @@ class PearsonTransferTestCase(PearsonTestCase): def test_transfer_config(self): with self.settings(DATADOG_API='FAKE_KEY'): # TODO: why is this failing with the wrong error message?! - stderrmsg = get_command_error_text('pearson_transfer', **{'mode' : 'garbage'}) + stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'}) self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') with self.settings(DATADOG_API='FAKE_KEY'): stderrmsg = get_command_error_text('pearson_transfer') self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') with self.settings(DATADOG_API='FAKE_KEY', - PEARSON={'LOCAL_EXPORT' : self.export_dir, - 'LOCAL_IMPORT' : self.import_dir }): + PEARSON={'LOCAL_EXPORT': self.export_dir, + 'LOCAL_IMPORT': self.import_dir}): stderrmsg = get_command_error_text('pearson_transfer') self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings') @@ -317,16 +320,16 @@ class PearsonTransferTestCase(PearsonTestCase): raise SkipTest() create_multiple_registrations('export_missing_dest') with self.settings(DATADOG_API='FAKE_KEY', - PEARSON={'LOCAL_EXPORT' : self.export_dir, - 'SFTP_EXPORT' : 'this/does/not/exist', - 'SFTP_HOSTNAME' : SFTP_HOSTNAME, - 'SFTP_USERNAME' : SFTP_USERNAME, - 'SFTP_PASSWORD' : SFTP_PASSWORD, - 'S3_BUCKET' : S3_BUCKET, + PEARSON={'LOCAL_EXPORT': self.export_dir, + 'SFTP_EXPORT': 'this/does/not/exist', + 'SFTP_HOSTNAME': SFTP_HOSTNAME, + 'SFTP_USERNAME': SFTP_USERNAME, + 'SFTP_PASSWORD': SFTP_PASSWORD, + 'S3_BUCKET': S3_BUCKET, }, - AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY): - options = { 'mode' : 'export'} + AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): + options = {'mode': 'export'} stderrmsg = get_command_error_text('pearson_transfer', **options) self.assertErrorContains(stderrmsg, 'Error: SFTP destination path does not exist') @@ -334,16 +337,16 @@ class PearsonTransferTestCase(PearsonTestCase): raise SkipTest() create_multiple_registrations("transfer_export") with self.settings(DATADOG_API='FAKE_KEY', - PEARSON={'LOCAL_EXPORT' : self.export_dir, - 'SFTP_EXPORT' : 'results/topvue', - 'SFTP_HOSTNAME' : SFTP_HOSTNAME, - 'SFTP_USERNAME' : SFTP_USERNAME, - 'SFTP_PASSWORD' : SFTP_PASSWORD, - 'S3_BUCKET' : S3_BUCKET, + PEARSON={'LOCAL_EXPORT': self.export_dir, + 'SFTP_EXPORT': 'results/topvue', + 'SFTP_HOSTNAME': SFTP_HOSTNAME, + 'SFTP_USERNAME': SFTP_USERNAME, + 'SFTP_PASSWORD': SFTP_PASSWORD, + 'S3_BUCKET': S3_BUCKET, }, - AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY): - options = { 'mode' : 'export'} + AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): + options = {'mode': 'export'} # call_command('pearson_transfer', **options) # # confirm that the export directory is still empty: # self.assertEqual(len(os.listdir(self.export_dir)), 0, "expected export directory to be empty") @@ -352,16 +355,16 @@ class PearsonTransferTestCase(PearsonTestCase): raise SkipTest() create_multiple_registrations('import_missing_src') with self.settings(DATADOG_API='FAKE_KEY', - PEARSON={'LOCAL_IMPORT' : self.import_dir, - 'SFTP_IMPORT' : 'this/does/not/exist', - 'SFTP_HOSTNAME' : SFTP_HOSTNAME, - 'SFTP_USERNAME' : SFTP_USERNAME, - 'SFTP_PASSWORD' : SFTP_PASSWORD, - 'S3_BUCKET' : S3_BUCKET, + PEARSON={'LOCAL_IMPORT': self.import_dir, + 'SFTP_IMPORT': 'this/does/not/exist', + 'SFTP_HOSTNAME': SFTP_HOSTNAME, + 'SFTP_USERNAME': SFTP_USERNAME, + 'SFTP_PASSWORD': SFTP_PASSWORD, + 'S3_BUCKET': S3_BUCKET, }, - AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY): - options = { 'mode' : 'import'} + AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): + options = {'mode': 'import'} stderrmsg = get_command_error_text('pearson_transfer', **options) self.assertErrorContains(stderrmsg, 'Error: SFTP source path does not exist') @@ -369,15 +372,15 @@ class PearsonTransferTestCase(PearsonTestCase): raise SkipTest() create_multiple_registrations('import_missing_src') with self.settings(DATADOG_API='FAKE_KEY', - PEARSON={'LOCAL_IMPORT' : self.import_dir, - 'SFTP_IMPORT' : 'results', - 'SFTP_HOSTNAME' : SFTP_HOSTNAME, - 'SFTP_USERNAME' : SFTP_USERNAME, - 'SFTP_PASSWORD' : SFTP_PASSWORD, - 'S3_BUCKET' : S3_BUCKET, + PEARSON={'LOCAL_IMPORT': self.import_dir, + 'SFTP_IMPORT': 'results', + 'SFTP_HOSTNAME': SFTP_HOSTNAME, + 'SFTP_USERNAME': SFTP_USERNAME, + 'SFTP_PASSWORD': SFTP_PASSWORD, + 'S3_BUCKET': S3_BUCKET, }, - AWS_ACCESS_KEY_ID = AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY = AWS_SECRET_ACCESS_KEY): - options = { 'mode' : 'import'} + AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): + options = {'mode': 'import'} call_command('pearson_transfer', **options) self.assertEqual(len(os.listdir(self.import_dir)), 0, "expected import directory to be empty") diff --git a/common/djangoapps/student/migrations/0020_add_test_center_user.py b/common/djangoapps/student/migrations/0020_add_test_center_user.py index e308e2d7e0..6c0bf5c4ee 100644 --- a/common/djangoapps/student/migrations/0020_add_test_center_user.py +++ b/common/djangoapps/student/migrations/0020_add_test_center_user.py @@ -185,4 +185,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0021_remove_askbot.py b/common/djangoapps/student/migrations/0021_remove_askbot.py index 83ad6791f2..8f76e5078c 100644 --- a/common/djangoapps/student/migrations/0021_remove_askbot.py +++ b/common/djangoapps/student/migrations/0021_remove_askbot.py @@ -36,7 +36,7 @@ class Migration(SchemaMigration): for column in ASKBOT_AUTH_USER_COLUMNS: db.delete_column('auth_user', column) except Exception as ex: - print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex) + print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex) def backwards(self, orm): raise RuntimeError("Cannot reverse this migration: there's no going back to Askbot.") diff --git a/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py b/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py index f7e2571685..769ad6737d 100644 --- a/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py +++ b/common/djangoapps/student/migrations/0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_.py @@ -152,4 +152,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0023_add_test_center_registration.py b/common/djangoapps/student/migrations/0023_add_test_center_registration.py index c5af38dd37..4c7de6dcd9 100644 --- a/common/djangoapps/student/migrations/0023_add_test_center_registration.py +++ b/common/djangoapps/student/migrations/0023_add_test_center_registration.py @@ -238,4 +238,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0024_add_allow_certificate.py b/common/djangoapps/student/migrations/0024_add_allow_certificate.py index fb3a97cd4b..56eccf8d70 100644 --- a/common/djangoapps/student/migrations/0024_add_allow_certificate.py +++ b/common/djangoapps/student/migrations/0024_add_allow_certificate.py @@ -169,4 +169,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 71d2177bd4..54bdd77297 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -107,6 +107,7 @@ class UserProfile(models.Model): TEST_CENTER_STATUS_ACCEPTED = "Accepted" TEST_CENTER_STATUS_ERROR = "Error" + class TestCenterUser(models.Model): """This is our representation of the User for in-person testing, and specifically for Pearson at this point. A few things to note: @@ -190,7 +191,7 @@ class TestCenterUser(models.Model): @staticmethod def user_provided_fields(): - return [ 'first_name', 'middle_name', 'last_name', 'suffix', 'salutation', + return ['first_name', 'middle_name', 'last_name', 'suffix', 'salutation', 'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country', 'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name'] @@ -208,7 +209,7 @@ class TestCenterUser(models.Model): @staticmethod def _generate_edx_id(prefix): NUM_DIGITS = 12 - return u"{}{:012}".format(prefix, randint(1, 10**NUM_DIGITS-1)) + return u"{}{:012}".format(prefix, randint(1, 10 ** NUM_DIGITS - 1)) @staticmethod def _generate_candidate_id(): @@ -237,10 +238,11 @@ class TestCenterUser(models.Model): def is_pending(self): return not self.is_accepted and not self.is_rejected + class TestCenterUserForm(ModelForm): class Meta: model = TestCenterUser - fields = ( 'first_name', 'middle_name', 'last_name', 'suffix', 'salutation', + fields = ('first_name', 'middle_name', 'last_name', 'suffix', 'salutation', 'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country', 'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name') @@ -313,7 +315,8 @@ ACCOMMODATION_CODES = ( ('SRSGNR', 'Separate Room and Sign Language Interpreter'), ) -ACCOMMODATION_CODE_DICT = { code : name for (code, name) in ACCOMMODATION_CODES } +ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES} + class TestCenterRegistration(models.Model): """ @@ -389,10 +392,10 @@ class TestCenterRegistration(models.Model): elif self.uploaded_at is None: return 'Add' elif self.registration_is_rejected: - # Assume that if the registration was rejected before, - # it is more likely this is the (first) correction + # Assume that if the registration was rejected before, + # it is more likely this is the (first) correction # than a second correction in flight before the first was - # processed. + # processed. return 'Add' else: # TODO: decide what to send when we have uploaded an initial version, @@ -417,7 +420,7 @@ class TestCenterRegistration(models.Model): @classmethod def create(cls, testcenter_user, exam, accommodation_request): - registration = cls(testcenter_user = testcenter_user) + registration = cls(testcenter_user=testcenter_user) registration.course_id = exam.course_id registration.accommodation_request = accommodation_request.strip() registration.exam_series_code = exam.exam_series_code @@ -501,7 +504,7 @@ class TestCenterRegistration(models.Model): return self.accommodation_code.split('*') def get_accommodation_names(self): - return [ ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes() ] + return [ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes()] @property def registration_signup_url(self): @@ -512,7 +515,7 @@ class TestCenterRegistration(models.Model): return "Accepted" elif self.demographics_is_rejected: return "Rejected" - else: + else: return "Pending" def accommodation_status(self): @@ -522,7 +525,7 @@ class TestCenterRegistration(models.Model): return "Accepted" elif self.accommodation_is_rejected: return "Rejected" - else: + else: return "Pending" def registration_status(self): @@ -532,12 +535,12 @@ class TestCenterRegistration(models.Model): return "Rejected" else: return "Pending" - + class TestCenterRegistrationForm(ModelForm): class Meta: model = TestCenterRegistration - fields = ( 'accommodation_request', 'accommodation_code' ) + fields = ('accommodation_request', 'accommodation_code') def clean_accommodation_request(self): code = self.cleaned_data['accommodation_request'] @@ -576,6 +579,7 @@ def get_testcenter_registration(user, course_id, exam_series_code): # Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html) get_testcenter_registration.__test__ = False + def unique_id_for_user(user): """ Return a unique id for a user, suitable for inserting into @@ -666,6 +670,7 @@ class CourseEnrollmentAllowed(models.Model): #### Helper methods for use from python manage.py shell and other classes. + def get_user_by_username_or_email(username_or_email): """ Return a User object, looking up by email if username_or_email contains a @@ -767,4 +772,3 @@ def update_user_information(sender, instance, created, **kwargs): log = logging.getLogger("mitx.discussion") log.error(unicode(e)) log.error("update user info to discussion failed for user with id: " + str(instance.id)) - diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index 8ce407bcd1..6a2d75e3d8 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -17,6 +17,7 @@ COURSE_2 = 'edx/full/6.002_Spring_2012' log = logging.getLogger(__name__) + class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" @@ -40,7 +41,7 @@ class CourseEndingTest(TestCase): {'status': 'processing', 'show_disabled_download_button': False, 'show_download_url': False, - 'show_survey_button': False,}) + 'show_survey_button': False, }) cert_status = {'status': 'unavailable'} self.assertEqual(_cert_info(user, course, cert_status), diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b583599e97..13ae83ea2f 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,12 +1,10 @@ import datetime import feedparser -#import itertools import json import logging import random import string import sys -#import time import urllib import uuid @@ -16,17 +14,19 @@ from django.contrib.auth import logout, authenticate, login from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required +from django.core.cache import cache from django.core.context_processors import csrf from django.core.mail import send_mail +from django.core.urlresolvers import reverse from django.core.validators import validate_email, validate_slug, ValidationError from django.db import IntegrityError -from django.http import HttpResponse, HttpResponseForbidden, Http404 +from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.shortcuts import redirect +from django_future.csrf import ensure_csrf_cookie, csrf_exempt + from mitxmako.shortcuts import render_to_response, render_to_string from bs4 import BeautifulSoup -from django.core.cache import cache -from django_future.csrf import ensure_csrf_cookie, csrf_exempt from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm, TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, @@ -38,18 +38,22 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location -#from datetime import date from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access +from courseware.models import StudentModuleCache +from courseware.views import get_module_for_descriptor, jump_to +from courseware.module_render import get_instance_module from statsd import statsd log = logging.getLogger("mitx.student") Article = namedtuple('Article', 'title url author image deck publication publish_date') + def csrf_token(context): ''' A csrf token that can be included in a form. ''' @@ -73,8 +77,8 @@ def index(request, extra_context={}, user=None): ''' # The course selection work is done in courseware.courses. - domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False - if domain==False: # do explicit check, because domain=None is valid + domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False + if domain == False: # do explicit check, because domain=None is valid domain = request.META.get('HTTP_HOST') courses = get_courses(None, domain=domain) @@ -97,6 +101,7 @@ import re day_pattern = re.compile('\s\d+,\s') multimonth_pattern = re.compile('\s?\-\s?\S+\s') + def get_date_for_press(publish_date): import datetime # strip off extra months, and just use the first: @@ -107,6 +112,7 @@ def get_date_for_press(publish_date): date = datetime.datetime.strptime(date, "%B, %Y") return date + def press(request): json_articles = cache.get("student_press_json_articles") if json_articles == None: @@ -148,6 +154,7 @@ def cert_info(user, course): return _cert_info(user, course, certificate_status_for_student(user, course.id)) + def _cert_info(user, course, cert_status): """ Implements the logic for cert_info -- split out for testing. @@ -175,7 +182,7 @@ def _cert_info(user, course, cert_status): d = {'status': status, 'show_download_url': status == 'ready', - 'show_disabled_download_button': status == 'generating',} + 'show_disabled_download_button': status == 'generating', } if (status in ('generating', 'ready', 'notpassing', 'restricted') and course.end_of_course_survey_url is not None): @@ -204,6 +211,7 @@ def _cert_info(user, course, cert_status): return d + @login_required @ensure_csrf_cookie def dashboard(request): @@ -237,9 +245,9 @@ def dashboard(request): show_courseware_links_for = frozenset(course.id for course in courses if has_access(request.user, course, 'load')) - cert_statuses = { course.id: cert_info(request.user, course) for course in courses} + cert_statuses = {course.id: cert_info(request.user, course) for course in courses} - exam_registrations = { course.id: exam_registration_info(request.user, course) for course in courses} + exam_registrations = {course.id: exam_registration_info(request.user, course) for course in courses} # Get the 3 most recent news top_news = _get_news(top=3) @@ -248,7 +256,7 @@ def dashboard(request): 'message': message, 'staff_access': staff_access, 'errored_courses': errored_courses, - 'show_courseware_links_for' : show_courseware_links_for, + 'show_courseware_links_for': show_courseware_links_for, 'cert_statuses': cert_statuses, 'news': top_news, 'exam_registrations': exam_registrations, @@ -312,7 +320,7 @@ def change_enrollment(request): 'error': 'enrollment in {} not allowed at this time' .format(course.display_name)} - org, course_num, run=course_id.split("/") + org, course_num, run = course_id.split("/") statsd.increment("common.student.enrollment", tags=["org:{0}".format(org), "course:{0}".format(course_num), @@ -326,7 +334,7 @@ def change_enrollment(request): enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id) enrollment.delete() - org, course_num, run=course_id.split("/") + org, course_num, run = course_id.split("/") statsd.increment("common.student.unenrollment", tags=["org:{0}".format(org), "course:{0}".format(course_num), @@ -345,7 +353,7 @@ def change_enrollment(request): def accounts_login(request, error=""): - return render_to_response('accounts_login.html', { 'error': error }) + return render_to_response('accounts_login.html', {'error': error}) @@ -377,7 +385,7 @@ def login_user(request, error=""): try: login(request, user) if request.POST.get('remember') == 'true': - request.session.set_expiry(None) # or change to 604800 for 7 days + request.session.set_expiry(604800) log.debug("Setting user session to never expire") else: request.session.set_expiry(0) @@ -424,6 +432,7 @@ def change_setting(request): return HttpResponse(json.dumps({'success': True, 'location': up.location, })) + def _do_create_account(post_vars): """ Given cleaned post variables, create the User and UserProfile objects, as well as the @@ -551,7 +560,7 @@ def create_account(request, post_override=None): # Ok, looks like everything is legit. Create the account. ret = _do_create_account(post_vars) - if isinstance(ret,HttpResponse): # if there was an error then return that + if isinstance(ret, HttpResponse): # if there was an error then return that return ret (user, profile, registration) = ret @@ -591,7 +600,7 @@ def create_account(request, post_override=None): eamap.user = login_user eamap.dtsignup = datetime.datetime.now() eamap.save() - log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'],eamap)) + log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap)) if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): log.debug('bypassing activation email') @@ -603,6 +612,7 @@ def create_account(request, post_override=None): js = {'success': True} return HttpResponse(json.dumps(js), mimetype="application/json") + def exam_registration_info(user, course): """ Returns a Registration object if the user is currently registered for a current exam of the course. Returns None if the user is not registered, or if there is no @@ -620,6 +630,7 @@ def exam_registration_info(user, course): registration = None return registration + @login_required @ensure_csrf_cookie def begin_exam_registration(request, course_id): @@ -663,6 +674,7 @@ def begin_exam_registration(request, course_id): return render_to_response('test_center_register.html', context) + @ensure_csrf_cookie def create_exam_registration(request, post_override=None): ''' @@ -725,7 +737,7 @@ def create_exam_registration(request, post_override=None): # this registration screen. else: - accommodation_request = post_vars.get('accommodation_request','') + accommodation_request = post_vars.get('accommodation_request', '') registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) needs_saving = True log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id)) @@ -834,16 +846,17 @@ def password_reset(request): form = PasswordResetForm(request.POST) if form.is_valid(): - form.save(use_https = request.is_secure(), - from_email = settings.DEFAULT_FROM_EMAIL, - request = request, - domain_override = request.get_host()) - return HttpResponse(json.dumps({'success':True, + form.save(use_https=request.is_secure(), + from_email=settings.DEFAULT_FROM_EMAIL, + request=request, + domain_override=request.get_host()) + return HttpResponse(json.dumps({'success': True, 'value': render_to_string('registration/password_reset_done.html', {})})) else: return HttpResponse(json.dumps({'success': False, 'error': 'Invalid e-mail'})) + @ensure_csrf_cookie def reactivation_email(request): ''' Send an e-mail to reactivate a deactivated account, or to @@ -856,6 +869,7 @@ def reactivation_email(request): 'error': 'No inactive user with this e-mail exists'})) return reactivation_email_for_user(user) + def reactivation_email_for_user(user): reg = Registration.objects.get(user=user) @@ -996,11 +1010,11 @@ def pending_name_changes(request): changes = list(PendingNameChange.objects.all()) js = {'students': [{'new_name': c.new_name, - 'rationale':c.rationale, - 'old_name':UserProfile.objects.get(user=c.user).name, - 'email':c.user.email, - 'uid':c.user.id, - 'cid':c.id} for c in changes]} + 'rationale': c.rationale, + 'old_name': UserProfile.objects.get(user=c.user).name, + 'email': c.user.email, + 'uid': c.user.id, + 'cid': c.id} for c in changes]} return render_to_response('name_changes.html', js) @@ -1055,25 +1069,134 @@ def accept_name_change(request): return accept_name_change_by_id(int(request.POST['id'])) -# TODO: This is a giant kludge to give Pearson something to test against ASAP. -# Will need to get replaced by something that actually ties into TestCenterUser @csrf_exempt def test_center_login(request): - if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'): - raise Http404 - - client_candidate_id = request.POST.get("clientCandidateID") - # registration_id = request.POST.get("registrationID") - exit_url = request.POST.get("exitURL") + # errors are returned by navigating to the error_url, adding a query parameter named "code" + # which contains the error code describing the exceptional condition. + def makeErrorURL(error_url, error_code): + log.error("generating error URL with error code {}".format(error_code)) + return "{}?code={}".format(error_url, error_code); + + # get provided error URL, which will be used as a known prefix for returning error messages to the + # Pearson shell. error_url = request.POST.get("errorURL") + # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson + # with the code we calculate for the same parameters. + if 'code' not in request.POST: + return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode")); + code = request.POST.get("code") + + # calculate SHA for query string + # TODO: figure out how to get the original query string, so we can hash it and compare. + + + if 'clientCandidateID' not in request.POST: + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")); + client_candidate_id = request.POST.get("clientCandidateID") + + # TODO: check remaining parameters, and maybe at least log if they're not matching + # expected values.... + # registration_id = request.POST.get("registrationID") + # exit_url = request.POST.get("exitURL") + + # find testcenter_user that matches the provided ID: + try: + testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) + except TestCenterUser.DoesNotExist: + log.error("not able to find demographics for cand ID {}".format(client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")); + + # find testcenter_registration that matches the provided exam code: + # Note that we could rely in future on either the registrationId or the exam code, + # or possibly both. But for now we know what to do with an ExamSeriesCode, + # while we currently have no record of RegistrationID values at all. + if 'vueExamSeriesCode' not in request.POST: + # we are not allowed to make up a new error code, according to Pearson, + # so instead of "missingExamSeriesCode", we use a valid one that is + # inaccurate but at least distinct. (Sigh.) + log.error("missing exam series code for cand ID {}".format(client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")); + exam_series_code = request.POST.get('vueExamSeriesCode') + # special case for supporting test user: + if client_candidate_id == "edX003671291147" and exam_series_code != '6002x001': + log.warning("test user {} using unexpected exam code {}, coercing to 6002x001".format(client_candidate_id, exam_series_code)) + exam_series_code = '6002x001' + + registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) + if not registrations: + log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")); + + # TODO: figure out what to do if there are more than one registrations.... + # for now, just take the first... + registration = registrations[0] + + course_id = registration.course_id + course = course_from_id(course_id) # assume it will be found.... + if not course: + log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")); + exam = course.get_test_center_exam(exam_series_code) + if not exam: + log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")); + location = exam.exam_url + log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) + + # check if the test has already been taken + timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) + if not timelimit_descriptor: + log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); + + timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, + timelimit_descriptor, depth=None) + timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, + timelimit_module_cache, course_id, position=None) + if not timelimit_module.category == 'timelimit': + log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); + + if timelimit_module and timelimit_module.has_ended: + log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) + return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")); + + # check if we need to provide an accommodation: + time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME', + 'ET30MN' : 'ADD30MIN', + 'ETDBTM' : 'ADDDOUBLE', } + + time_accommodation_code = None + for code in registration.get_accommodation_codes(): + if code in time_accommodation_mapping: + time_accommodation_code = time_accommodation_mapping[code] + # special, hard-coded client ID used by Pearson shell for testing: if client_candidate_id == "edX003671291147": - user = authenticate(username=settings.PEARSON_TEST_USER, - password=settings.PEARSON_TEST_PASSWORD) - login(request, user) - return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/') - else: - return HttpResponseForbidden() + time_accommodation_code = 'TESTING' + + if time_accommodation_code: + timelimit_module.accommodation_code = time_accommodation_code + instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache) + instance_module.state = timelimit_module.get_instance_state() + instance_module.save() + log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) + + # UGLY HACK!!! + # Login assumes that authentication has occurred, and that there is a + # backend annotation on the user object, indicating which backend + # against which the user was authenticated. We're authenticating here + # against the registration entry, and assuming that the request given + # this information is correct, we allow the user to be logged in + # without a password. This could all be formalized in a backend object + # that does the above checking. + # TODO: (brian) create a backend class to do this. + # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) + testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") + login(request, testcenteruser.user) + + # And start the test: + return jump_to(request, course_id, location) def _get_news(top=None): diff --git a/lms/djangoapps/terrain/__init__.py b/common/djangoapps/terrain/__init__.py similarity index 89% rename from lms/djangoapps/terrain/__init__.py rename to common/djangoapps/terrain/__init__.py index dd6869e7fd..3445a01d17 100644 --- a/lms/djangoapps/terrain/__init__.py +++ b/common/djangoapps/terrain/__init__.py @@ -3,4 +3,4 @@ # See https://groups.google.com/forum/?fromgroups=#!msg/lettuce-users/5VyU9B4HcX8/USgbGIJdS5QJ from terrain.browser import * from terrain.steps import * -from terrain.factories import * \ No newline at end of file +from terrain.factories import * diff --git a/lms/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py similarity index 79% rename from lms/djangoapps/terrain/browser.py rename to common/djangoapps/terrain/browser.py index 1c2d401680..8c2a8ba7a5 100644 --- a/lms/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -8,20 +8,24 @@ logger.info("Loading the lettuce acceptance testing terrain file...") from django.core.management import call_command + @before.harvest def initial_setup(server): - # Launch firefox + # Launch the browser app (choose one of these below) world.browser = Browser('chrome') + # world.browser = Browser('firefox') + @before.each_scenario def reset_data(scenario): - # Clean out the django test database defined in the + # Clean out the django test database defined in the # envs/acceptance.py file: mitx_all/db/test_mitx.db logger.debug("Flushing the test database...") call_command('flush', interactive=False) + @after.all def teardown_browser(total): # Quit firefox world.browser.quit() - pass \ No newline at end of file + pass diff --git a/lms/djangoapps/terrain/factories.py b/common/djangoapps/terrain/factories.py similarity index 92% rename from lms/djangoapps/terrain/factories.py rename to common/djangoapps/terrain/factories.py index 377ce54d56..bb7ae012c8 100644 --- a/lms/djangoapps/terrain/factories.py +++ b/common/djangoapps/terrain/factories.py @@ -1,4 +1,5 @@ from student.models import User, UserProfile, Registration +from django.contrib.auth.models import Group from datetime import datetime from factory import Factory from xmodule.modulestore import Location @@ -7,6 +8,13 @@ from time import gmtime from uuid import uuid4 from xmodule.timeparse import stringify_time + +class GroupFactory(Factory): + FACTORY_FOR = Group + + name = 'staff_MITx/999/Robot_Super_Course' + + class UserProfileFactory(Factory): FACTORY_FOR = UserProfile @@ -17,12 +25,14 @@ class UserProfileFactory(Factory): mailing_address = None goals = 'World domination' + class RegistrationFactory(Factory): FACTORY_FOR = Registration user = None activation_key = uuid4().hex + class UserFactory(Factory): FACTORY_FOR = User @@ -37,12 +47,15 @@ class UserFactory(Factory): last_login = datetime(2012, 1, 1) date_joined = datetime(2011, 1, 1) -def XMODULE_COURSE_CREATION(class_to_create, **kwargs): + +def XMODULE_COURSE_CREATION(class_to_create, **kwargs): return XModuleCourseFactory._create(class_to_create, **kwargs) + def XMODULE_ITEM_CREATION(class_to_create, **kwargs): return XModuleItemFactory._create(class_to_create, **kwargs) + class XModuleCourseFactory(Factory): """ Factory for XModule courses. @@ -58,7 +71,7 @@ class XModuleCourseFactory(Factory): org = kwargs.get('org') number = kwargs.get('number') display_name = kwargs.get('display_name') - location = Location('i4x', org, number, + location = Location('i4x', org, number, 'course', Location.clean(display_name)) store = modulestore('direct') @@ -72,20 +85,22 @@ class XModuleCourseFactory(Factory): new_course.metadata['data_dir'] = uuid4().hex new_course.metadata['start'] = stringify_time(gmtime()) - new_course.tabs = [{"type": "courseware"}, + new_course.tabs = [{"type": "courseware"}, {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"}] # Update the data in the mongo datastore - store.update_metadata(new_course.location.url(), new_course.own_metadata) + store.update_metadata(new_course.location.url(), new_course.own_metadata) return new_course + class Course: pass + class CourseFactory(XModuleCourseFactory): FACTORY_FOR = Course @@ -94,6 +109,7 @@ class CourseFactory(XModuleCourseFactory): number = '999' display_name = 'Robot Super Course' + class XModuleItemFactory(Factory): """ Factory for XModule items. @@ -110,7 +126,7 @@ class XModuleItemFactory(Factory): """ DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] - + parent_location = Location(kwargs.get('parent_location')) template = Location(kwargs.get('template')) display_name = kwargs.get('display_name') @@ -137,12 +153,14 @@ class XModuleItemFactory(Factory): return new_item + class Item: pass + class ItemFactory(XModuleItemFactory): FACTORY_FOR = Item parent_location = 'i4x://MITx/999/course/Robot_Super_Course' template = 'i4x://edx/templates/chapter/Empty' - display_name = 'Section One' \ No newline at end of file + display_name = 'Section One' diff --git a/lms/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py similarity index 90% rename from lms/djangoapps/terrain/steps.py rename to common/djangoapps/terrain/steps.py index 6824fa16ce..88fba697b2 100644 --- a/lms/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,8 +1,6 @@ from lettuce import world, step from factories import * -from django.core.management import call_command from lettuce.django import django_url -from django.conf import settings from django.contrib.auth.models import User from student.models import CourseEnrollment from urllib import quote_plus @@ -15,79 +13,107 @@ import os.path from logging import getLogger logger = getLogger(__name__) + @step(u'I wait (?:for )?"(\d+)" seconds?$') def wait(step, seconds): time.sleep(float(seconds)) + +@step('I reload the page$') +def reload_the_page(step): + world.browser.reload() + + @step('I (?:visit|access|open) the homepage$') def i_visit_the_homepage(step): world.browser.visit(django_url('/')) assert world.browser.is_element_present_by_css('header.global', 10) + @step(u'I (?:visit|access|open) the dashboard$') def i_visit_the_dashboard(step): world.browser.visit(django_url('/dashboard')) assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): assert world.browser.is_element_present_by_css('section.container.dashboard', 5) assert world.browser.title == 'Dashboard' + @step(u'I (?:visit|access|open) the courses page$') def i_am_on_the_courses_page(step): world.browser.visit(django_url('/courses')) assert world.browser.is_element_present_by_css('section.courses') + @step(u'I press the "([^"]*)" button$') def and_i_press_the_button(step, value): button_css = 'input[value="%s"]' % value world.browser.find_by_css(button_css).first.click() + @step(u'I click the link with the text "([^"]*)"$') def click_the_link_with_the_text_group1(step, linktext): world.browser.find_link_by_text(linktext).first.click() + @step('I should see that the path is "([^"]*)"$') def i_should_see_that_the_path_is(step, path): assert world.browser.url == django_url(path) + @step(u'the page title should be "([^"]*)"$') def the_page_title_should_be(step, title): assert_equals(world.browser.title, title) + @step('I am a logged in user$') def i_am_logged_in_user(step): create_user('robot') - log_in('robot@edx.org','test') + log_in('robot@edx.org', 'test') + @step('I am not logged in$') def i_am_not_logged_in(step): world.browser.cookies.delete() + @step('I am registered for a course$') def i_am_registered_for_a_course(step): create_user('robot') u = User.objects.get(username='robot') CourseEnrollment.objects.get_or_create(user=u, course_id='MITx/6.002x/2012_Fall') + @step('I am registered for course "([^"]*)"$') def i_am_registered_for_course_by_id(step, course_id): register_by_course_id(course_id) + @step('I am staff for course "([^"]*)"$') def i_am_staff_for_course_by_id(step, course_id): register_by_course_id(course_id, True) + @step('I log in$') def i_log_in(step): - log_in('robot@edx.org','test') + log_in('robot@edx.org', 'test') + @step(u'I am an edX user$') def i_am_an_edx_user(step): create_user('robot') #### helper functions + +@world.absorb +def scroll_to_bottom(): + # Maximize the browser + world.browser.execute_script("window.scrollTo(0, screen.height);") + + @world.absorb def create_user(uname): portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') @@ -100,11 +126,12 @@ def create_user(uname): user_profile = UserProfileFactory(user=portal_user) + @world.absorb -def log_in(email, password): +def log_in(email, password): world.browser.cookies.delete() world.browser.visit(django_url('/')) - world.browser.is_element_present_by_css('header.global', 10) + world.browser.is_element_present_by_css('header.global', 10) world.browser.click_link_by_href('#login-modal') login_form = world.browser.find_by_css('form#login_form') login_form.find_by_name('email').fill(email) @@ -114,15 +141,17 @@ def log_in(email, password): # wait for the page to redraw assert world.browser.is_element_present_by_css('.content-wrapper', 10) + @world.absorb def register_by_course_id(course_id, is_staff=False): create_user('robot') u = User.objects.get(username='robot') if is_staff: - u.is_staff=True - u.save() + u.is_staff = True + u.save() CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + @world.absorb def save_the_html(path='/tmp'): u = world.browser.url @@ -132,6 +161,7 @@ def save_the_html(path='/tmp'): f.write(html) f.close + @world.absorb def save_the_course_content(path='/tmp'): html = world.browser.html.encode('ascii', 'ignore') @@ -140,12 +170,12 @@ def save_the_course_content(path='/tmp'): # get rid of the header, we only want to compare the body soup.head.decompose() - # for now, remove the data-id attributes, because they are + # for now, remove the data-id attributes, because they are # causing mismatches between cms-master and master for item in soup.find_all(attrs={'data-id': re.compile('.*')}): del item['data-id'] - # we also need to remove them from unrendered problems, + # we also need to remove them from unrendered problems, # where they are contained in the text of divs instead of # in attributes of tags # Be careful of whether or not it was the last attribute @@ -164,7 +194,7 @@ def save_the_course_content(path='/tmp'): # use string slicing to grab everything after 'courseware/' in the URL u = world.browser.url - section_url = u[u.find('courseware/')+11:] + section_url = u[u.find('courseware/') + 11:] if not os.path.exists(path): os.makedirs(path) diff --git a/common/djangoapps/track/migrations/0001_initial.py b/common/djangoapps/track/migrations/0001_initial.py index 0546203cf8..6ec146dd10 100644 --- a/common/djangoapps/track/migrations/0001_initial.py +++ b/common/djangoapps/track/migrations/0001_initial.py @@ -45,4 +45,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['track'] \ No newline at end of file + complete_apps = ['track'] diff --git a/common/djangoapps/track/migrations/0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch.py b/common/djangoapps/track/migrations/0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch.py index 4c73aa3bfd..0bb0cde42e 100644 --- a/common/djangoapps/track/migrations/0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch.py +++ b/common/djangoapps/track/migrations/0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch.py @@ -48,4 +48,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['track'] \ No newline at end of file + complete_apps = ['track'] diff --git a/common/djangoapps/track/models.py b/common/djangoapps/track/models.py index dfdf7a0558..b6a16706c1 100644 --- a/common/djangoapps/track/models.py +++ b/common/djangoapps/track/models.py @@ -2,21 +2,20 @@ from django.db import models from django.db import models + class TrackingLog(models.Model): - dtcreated = models.DateTimeField('creation date',auto_now_add=True) - username = models.CharField(max_length=32,blank=True) - ip = models.CharField(max_length=32,blank=True) + dtcreated = models.DateTimeField('creation date', auto_now_add=True) + username = models.CharField(max_length=32, blank=True) + ip = models.CharField(max_length=32, blank=True) event_source = models.CharField(max_length=32) - event_type = models.CharField(max_length=512,blank=True) + event_type = models.CharField(max_length=512, blank=True) event = models.TextField(blank=True) - agent = models.CharField(max_length=256,blank=True) - page = models.CharField(max_length=512,blank=True,null=True) + agent = models.CharField(max_length=256, blank=True) + page = models.CharField(max_length=512, blank=True, null=True) time = models.DateTimeField('event time') - host = models.CharField(max_length=64,blank=True) + host = models.CharField(max_length=64, blank=True) def __unicode__(self): s = "[%s] %s@%s: %s | %s | %s | %s" % (self.time, self.username, self.ip, self.event_source, self.event_type, self.page, self.event) return s - - diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index 54bd476799..ae3a1dcb3e 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -17,19 +17,21 @@ from track.models import TrackingLog log = logging.getLogger("tracking") -LOGFIELDS = ['username','ip','event_source','event_type','event','agent','page','time','host'] +LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', 'page', 'time', 'host'] + def log_event(event): event_str = json.dumps(event) log.info(event_str[:settings.TRACK_MAX_EVENT]) if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): event['time'] = dateutil.parser.parse(event['time']) - tldat = TrackingLog(**dict( (x,event[x]) for x in LOGFIELDS )) + tldat = TrackingLog(**dict((x, event[x]) for x in LOGFIELDS)) try: tldat.save() except Exception as err: log.exception(err) + def user_track(request): try: # TODO: Do the same for many of the optional META parameters username = request.user.username @@ -87,13 +89,14 @@ def server_track(request, event_type, event, page=None): "host": request.META['SERVER_NAME'], } - if event_type.startswith("/event_logs") and request.user.is_staff: # don't log + if event_type.startswith("/event_logs") and request.user.is_staff: # don't log return log_event(event) + @login_required @ensure_csrf_cookie -def view_tracking_log(request,args=''): +def view_tracking_log(request, args=''): if not request.user.is_staff: return redirect('/') nlen = 100 @@ -104,16 +107,15 @@ def view_tracking_log(request,args=''): nlen = int(arg) if arg.startswith('username='): username = arg[9:] - + record_instances = TrackingLog.objects.all().order_by('-time') if username: record_instances = record_instances.filter(username=username) record_instances = record_instances[0:nlen] - + # fix dtstamp fmt = '%a %d-%b-%y %H:%M:%S' # "%Y-%m-%d %H:%M:%S %Z%z" for rinst in record_instances: rinst.dtstr = rinst.time.replace(tzinfo=pytz.utc).astimezone(pytz.timezone('US/Eastern')).strftime(fmt) - return render_to_response('tracking_log.html',{'records':record_instances}) - + return render_to_response('tracking_log.html', {'records': record_instances}) diff --git a/common/djangoapps/util/cache.py b/common/djangoapps/util/cache.py index 89b5dffd5e..8ab1b06acd 100644 --- a/common/djangoapps/util/cache.py +++ b/common/djangoapps/util/cache.py @@ -58,4 +58,3 @@ def cache_if_anonymous(view_func): return view_func(request, *args, **kwargs) return _decorated - diff --git a/common/djangoapps/util/converters.py b/common/djangoapps/util/converters.py index 7f96dc6c30..ec2d29ecfa 100644 --- a/common/djangoapps/util/converters.py +++ b/common/djangoapps/util/converters.py @@ -1,7 +1,9 @@ -import time, datetime +import time +import datetime import re import calendar + def time_to_date(time_obj): """ Convert a time.time_struct to a true universal time (can pass to js Date constructor) @@ -9,16 +11,20 @@ def time_to_date(time_obj): # TODO change to using the isoformat() function on datetime. js date can parse those return calendar.timegm(time_obj) * 1000 + def jsdate_to_time(field): """ Convert a universal time (iso format) or msec since epoch to a time obj """ if field is None: return field - elif isinstance(field, basestring): # iso format but ignores time zone assuming it's Z - d=datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable + elif isinstance(field, basestring): + # ISO format but ignores time zone assuming it's Z. + d = datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable return d.utctimetuple() - elif isinstance(field, int) or isinstance(field, float): + elif isinstance(field, (int, long, float)): return time.gmtime(field / 1000) elif isinstance(field, time.struct_time): - return field \ No newline at end of file + return field + else: + raise ValueError("Couldn't convert %r to time" % field) diff --git a/common/djangoapps/util/json_request.py b/common/djangoapps/util/json_request.py index 4beff7bdc8..840a8282f9 100644 --- a/common/djangoapps/util/json_request.py +++ b/common/djangoapps/util/json_request.py @@ -13,7 +13,7 @@ def expect_json(view_function): def expect_json_with_cloned_request(request, *args, **kwargs): # cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information # e.g. 'charset', so we can't do a direct string compare - if request.META.get('CONTENT_TYPE','').lower().startswith("application/json"): + if request.META.get('CONTENT_TYPE', '').lower().startswith("application/json"): cloned_request = copy.copy(request) cloned_request.POST = cloned_request.POST.copy() cloned_request.POST.update(json.loads(request.body)) diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index 0ccdd03301..cece37757b 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -93,6 +93,7 @@ def accepts(request, media_type): accept = parse_accept_header(request.META.get("HTTP_ACCEPT", "")) return media_type in [t for (t, p, q) in accept] + def debug_request(request): """Return a pretty printed version of the request""" diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 5c19a2f1d7..7b19c27553 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -2,16 +2,17 @@ import re import json import logging import time +import static_replace from django.conf import settings from functools import wraps -from static_replace import replace_urls from mitxmako.shortcuts import render_to_string from xmodule.seq_module import SequenceModule from xmodule.vertical_module import VerticalModule log = logging.getLogger("mitx.xmodule_modifiers") + def wrap_xmodule(get_html, module, template, context=None): """ Wraps the results of get_html in a standard
          with identifying @@ -32,7 +33,7 @@ def wrap_xmodule(get_html, module, template, context=None): def _get_html(): context.update({ 'content': get_html(), - 'display_name' : module.metadata.get('display_name') if module.metadata is not None else None, + 'display_name': module.metadata.get('display_name') if module.metadata is not None else None, 'class_': module.__class__.__name__, 'module_name': module.js_module_name }) @@ -49,10 +50,11 @@ def replace_course_urls(get_html, course_id): """ @wraps(get_html) def _get_html(): - return replace_urls(get_html(), staticfiles_prefix='/courses/'+course_id, replace_prefix='/course/') + return static_replace.replace_course_urls(get_html(), course_id) return _get_html -def replace_static_urls(get_html, prefix, course_namespace=None): + +def replace_static_urls(get_html, data_dir, course_namespace=None): """ Updates the supplied module with a new get_html function that wraps the old get_html function and substitutes urls of the form /static/... @@ -61,7 +63,7 @@ def replace_static_urls(get_html, prefix, course_namespace=None): @wraps(get_html) def _get_html(): - return replace_urls(get_html(), staticfiles_prefix=prefix, course_namespace = course_namespace) + return static_replace.replace_static_urls(get_html(), data_dir, course_namespace) return _get_html @@ -99,7 +101,7 @@ def add_histogram(get_html, module, user): @wraps(get_html) def _get_html(): - if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead + if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead return get_html() module_id = module.id @@ -115,35 +117,35 @@ def add_histogram(get_html, module, user): # doesn't like symlinks) filepath = filename data_dir = osfs.root_path.rsplit('/')[-1] - giturl = module.metadata.get('giturl','https://github.com/MITx') - edit_link = "%s/%s/tree/master/%s" % (giturl,data_dir,filepath) + giturl = module.metadata.get('giturl', 'https://github.com/MITx') + edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath) else: edit_link = False # Need to define all the variables that are about to be used giturl = "" data_dir = "" - source_file = module.metadata.get('source_file','') # source used to generate the problem XML, eg latex or word + source_file = module.metadata.get('source_file', '') # source used to generate the problem XML, eg latex or word # useful to indicate to staff if problem has been released or not # TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here now = time.gmtime() is_released = "unknown" - mstart = getattr(module.descriptor,'start') + mstart = getattr(module.descriptor, 'start') if mstart is not None: is_released = "Yes!" if (now > mstart) else "Not yet" staff_context = {'definition': module.definition.get('data'), 'metadata': json.dumps(module.metadata, indent=4), 'location': module.location, - 'xqa_key': module.metadata.get('xqa_key',''), - 'source_file' : source_file, - 'source_url': '%s/%s/tree/master/%s' % (giturl,data_dir,source_file), + 'xqa_key': module.metadata.get('xqa_key', ''), + 'source_file': source_file, + 'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file), 'category': str(module.__class__.__name__), # Template uses element_id in js function names, so can't allow dashes - 'element_id': module.location.html_id().replace('-','_'), + 'element_id': module.location.html_id().replace('-', '_'), 'edit_link': edit_link, 'user': user, - 'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'), + 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'), 'histogram': json.dumps(histogram), 'render_histogram': render_histogram, 'module_content': get_html(), @@ -152,4 +154,3 @@ def add_histogram(get_html, module, user): return render_to_string("staff_problem_info.html", staff_context) return _get_html - diff --git a/common/lib/capa/capa/calc.py b/common/lib/capa/capa/calc.py index 40ac14308e..0f062d17d5 100644 --- a/common/lib/capa/capa/calc.py +++ b/common/lib/capa/capa/calc.py @@ -121,9 +121,9 @@ def evaluator(variables, functions, string, cs=False): # confusing. They may also conflict with variables if we ever allow e.g. # 5R instead of 5*R suffixes = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, - 'T': 1e12,# 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24, + 'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24, 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, - 'n': 1e-9, 'p': 1e-12}# ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24} + 'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24} def super_float(text): ''' Like float, but with si extensions. 1k goes to 1000''' diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 4b0faa91a1..9b8bbd7288 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -75,7 +75,7 @@ global_context = {'random': random, 'draganddrop': verifiers.draganddrop} # These should be removed from HTML output, including all subelements -html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"] +html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"] log = logging.getLogger('mitx.' + __name__) @@ -453,7 +453,7 @@ class LoncapaProblem(object): exec code in context, context except Exception as err: log.exception("Error while execing script code: " + code) - msg = "Error while executing script code: %s" % str(err).replace('<','<') + msg = "Error while executing script code: %s" % str(err).replace('<', '<') raise responsetypes.LoncapaProblemError(msg) finally: sys.path = original_path @@ -502,7 +502,7 @@ class LoncapaProblem(object): 'id': problemtree.get('id'), 'feedback': {'message': msg, 'hint': hint, - 'hintmode': hintmode,}} + 'hintmode': hintmode, }} input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) the_input = input_type_cls(self.system, problemtree, state) diff --git a/common/lib/capa/capa/chem/__init__.py b/common/lib/capa/capa/chem/__init__.py index 8b13789179..e69de29bb2 100644 --- a/common/lib/capa/capa/chem/__init__.py +++ b/common/lib/capa/capa/chem/__init__.py @@ -1 +0,0 @@ - diff --git a/common/lib/capa/capa/chem/chemcalc.py b/common/lib/capa/capa/chem/chemcalc.py index 389e688cf4..5b80005044 100644 --- a/common/lib/capa/capa/chem/chemcalc.py +++ b/common/lib/capa/capa/chem/chemcalc.py @@ -17,17 +17,17 @@ from nltk.tree import Tree ARROWS = ('<->', '->') ## Defines a simple pyparsing tokenizer for chemical equations -elements = ['Ac','Ag','Al','Am','Ar','As','At','Au','B','Ba','Be', - 'Bh','Bi','Bk','Br','C','Ca','Cd','Ce','Cf','Cl','Cm', - 'Cn','Co','Cr','Cs','Cu','Db','Ds','Dy','Er','Es','Eu', - 'F','Fe','Fl','Fm','Fr','Ga','Gd','Ge','H','He','Hf', - 'Hg','Ho','Hs','I','In','Ir','K','Kr','La','Li','Lr', - 'Lu','Lv','Md','Mg','Mn','Mo','Mt','N','Na','Nb','Nd', - 'Ne','Ni','No','Np','O','Os','P','Pa','Pb','Pd','Pm', - 'Po','Pr','Pt','Pu','Ra','Rb','Re','Rf','Rg','Rh','Rn', - 'Ru','S','Sb','Sc','Se','Sg','Si','Sm','Sn','Sr','Ta', - 'Tb','Tc','Te','Th','Ti','Tl','Tm','U','Uuo','Uup', - 'Uus','Uut','V','W','Xe','Y','Yb','Zn','Zr'] +elements = ['Ac', 'Ag', 'Al', 'Am', 'Ar', 'As', 'At', 'Au', 'B', 'Ba', 'Be', + 'Bh', 'Bi', 'Bk', 'Br', 'C', 'Ca', 'Cd', 'Ce', 'Cf', 'Cl', 'Cm', + 'Cn', 'Co', 'Cr', 'Cs', 'Cu', 'Db', 'Ds', 'Dy', 'Er', 'Es', 'Eu', + 'F', 'Fe', 'Fl', 'Fm', 'Fr', 'Ga', 'Gd', 'Ge', 'H', 'He', 'Hf', + 'Hg', 'Ho', 'Hs', 'I', 'In', 'Ir', 'K', 'Kr', 'La', 'Li', 'Lr', + 'Lu', 'Lv', 'Md', 'Mg', 'Mn', 'Mo', 'Mt', 'N', 'Na', 'Nb', 'Nd', + 'Ne', 'Ni', 'No', 'Np', 'O', 'Os', 'P', 'Pa', 'Pb', 'Pd', 'Pm', + 'Po', 'Pr', 'Pt', 'Pu', 'Ra', 'Rb', 'Re', 'Rf', 'Rg', 'Rh', 'Rn', + 'Ru', 'S', 'Sb', 'Sc', 'Se', 'Sg', 'Si', 'Sm', 'Sn', 'Sr', 'Ta', + 'Tb', 'Tc', 'Te', 'Th', 'Ti', 'Tl', 'Tm', 'U', 'Uuo', 'Uup', + 'Uus', 'Uut', 'V', 'W', 'Xe', 'Y', 'Yb', 'Zn', 'Zr'] digits = map(str, range(10)) symbols = list("[](){}^+-/") phases = ["(s)", "(l)", "(g)", "(aq)"] @@ -252,7 +252,7 @@ def _get_final_tree(s): ''' tokenized = tokenizer.parseString(s) parsed = parser.parse(tokenized) - merged = _merge_children(parsed, {'S','group'}) + merged = _merge_children(parsed, {'S', 'group'}) final = _clean_parse_tree(merged) return final diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index c7386219b1..a78b10d07a 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -3,6 +3,7 @@ # # Used by responsetypes and capa_problem + class CorrectMap(object): """ Stores map between answer_id and response evaluation result for each question @@ -152,6 +153,3 @@ class CorrectMap(object): if not isinstance(other_cmap, CorrectMap): raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap) self.cmap.update(other_cmap.get_dict()) - - - diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py index ef1044e8b1..a925a5970d 100644 --- a/common/lib/capa/capa/customrender.py +++ b/common/lib/capa/capa/customrender.py @@ -22,6 +22,8 @@ log = logging.getLogger('mitx.' + __name__) registry = TagRegistry() #----------------------------------------------------------------------------- + + class MathRenderer(object): tags = ['math'] @@ -77,6 +79,7 @@ registry.register(MathRenderer) #----------------------------------------------------------------------------- + class SolutionRenderer(object): ''' A solution is just a ... which is given an ID, that is used for displaying an @@ -97,4 +100,3 @@ class SolutionRenderer(object): return etree.XML(html) registry.register(SolutionRenderer) - diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0b0e86ce66..951104501a 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -54,6 +54,7 @@ log = logging.getLogger('mitx.' + __name__) registry = TagRegistry() + class Attribute(object): """ Allows specifying required and optional attributes for input types. @@ -413,7 +414,7 @@ class JavascriptInput(InputTypeBase): return [Attribute('params', None), Attribute('problem_state', None), Attribute('display_class', None), - Attribute('display_file', None),] + Attribute('display_file', None), ] def setup(self): @@ -477,12 +478,13 @@ class TextLine(InputTypeBase): def _extra_context(self): return {'do_math': self.do_math, - 'preprocessor': self.preprocessor,} + 'preprocessor': self.preprocessor, } registry.register(TextLine) #----------------------------------------------------------------------------- + class FileSubmission(InputTypeBase): """ Upload some files (e.g. for programming assignments) @@ -508,7 +510,7 @@ class FileSubmission(InputTypeBase): Convert the list of allowed files to a convenient format. """ return [Attribute('allowed_files', '[]', transform=cls.parse_files), - Attribute('required_files', '[]', transform=cls.parse_files),] + Attribute('required_files', '[]', transform=cls.parse_files), ] def setup(self): """ @@ -524,7 +526,7 @@ class FileSubmission(InputTypeBase): self.msg = FileSubmission.submitted_msg def _extra_context(self): - return {'queue_len': self.queue_len,} + return {'queue_len': self.queue_len, } return context registry.register(FileSubmission) @@ -582,7 +584,7 @@ class CodeInput(InputTypeBase): def _extra_context(self): """Defined queue_len, add it """ - return {'queue_len': self.queue_len,} + return {'queue_len': self.queue_len, } registry.register(CodeInput) @@ -606,7 +608,7 @@ class Schematic(InputTypeBase): Attribute('parts', None), Attribute('analyses', None), Attribute('initial_value', None), - Attribute('submit_analyses', None),] + Attribute('submit_analyses', None), ] return context @@ -614,6 +616,7 @@ registry.register(Schematic) #----------------------------------------------------------------------------- + class ImageInput(InputTypeBase): """ Clickable image as an input field. Element should specify the image source, height, @@ -635,7 +638,7 @@ class ImageInput(InputTypeBase): """ return [Attribute('src'), Attribute('height'), - Attribute('width'),] + Attribute('width'), ] def setup(self): @@ -660,6 +663,7 @@ registry.register(ImageInput) #----------------------------------------------------------------------------- + class Crystallography(InputTypeBase): """ An input for crystallography -- user selects 3 points on the axes, and we get a plane. @@ -728,18 +732,19 @@ class ChemicalEquationInput(InputTypeBase): """ Can set size of text field. """ - return [Attribute('size', '20'),] + return [Attribute('size', '20'), ] def _extra_context(self): """ TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded. """ - return {'previewer': '/static/js/capa/chemical_equation_preview.js',} + return {'previewer': '/static/js/capa/chemical_equation_preview.js', } registry.register(ChemicalEquationInput) #----------------------------------------------------------------------------- + class DragAndDropInput(InputTypeBase): """ Input for drag and drop problems. Allows student to drag and drop images and @@ -829,3 +834,108 @@ class DragAndDropInput(InputTypeBase): registry.register(DragAndDropInput) #-------------------------------------------------------------------------------------------------------------------- + + +class EditAMoleculeInput(InputTypeBase): + """ + An input type for edit-a-molecule. Integrates with the molecule editor java applet. + + Example: + + + + options: size -- width of the textbox. + """ + + template = "editamolecule.html" + tags = ['editamoleculeinput'] + + @classmethod + def get_attributes(cls): + """ + Can set size of text field. + """ + return [Attribute('file'), + Attribute('missing', None)] + + def _extra_context(self): + """ + """ + context = { + 'applet_loader': '/static/js/capa/editamolecule.js', + } + + return context + +registry.register(EditAMoleculeInput) + +#----------------------------------------------------------------------------- + +class DesignProtein2dInput(InputTypeBase): + """ + An input type for design of a protein in 2D. Integrates with the Protex java applet. + + Example: + + + """ + + template = "designprotein2dinput.html" + tags = ['designprotein2dinput'] + + @classmethod + def get_attributes(cls): + """ + Note: width, hight, and target_shape are required. + """ + return [Attribute('width'), + Attribute('height'), + Attribute('target_shape') + ] + + def _extra_context(self): + """ + """ + context = { + 'applet_loader': '/static/js/capa/design-protein-2d.js', + } + + return context + +registry.register(DesignProtein2dInput) + +#----------------------------------------------------------------------------- + +class EditAGeneInput(InputTypeBase): + """ + An input type for editing a gene. Integrates with the genex java applet. + + Example: + + + """ + + template = "editageneinput.html" + tags = ['editageneinput'] + + @classmethod + def get_attributes(cls): + """ + Note: width, hight, and dna_sequencee are required. + """ + return [Attribute('width'), + Attribute('height'), + Attribute('dna_sequence') + ] + + def _extra_context(self): + """ + """ + context = { + 'applet_loader': '/static/js/capa/edit-a-gene.js', + } + + return context + +registry.register(EditAGeneInput) + diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 5f0e1639b2..a1a4e6b65e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -186,9 +186,9 @@ class LoncapaResponse(object): tree = etree.Element('span') # problem author can make this span display:inline - if self.xml.get('inline',''): - tree.set('class','inline') - + if self.xml.get('inline', ''): + tree.set('class', 'inline') + for item in self.xml: # call provided procedure to do the rendering item_xhtml = renderer(item) @@ -632,8 +632,14 @@ class MultipleChoiceResponse(LoncapaResponse): # define correct choices (after calling secondary setup) xml = self.xml - cxml = xml.xpath('//*[@id=$id]//choice[@correct="true"]', id=xml.get('id')) - self.correct_choices = [contextualize_text(choice.get('name'), self.context) for choice in cxml] + cxml = xml.xpath('//*[@id=$id]//choice', id=xml.get('id')) + + # contextualize correct attribute and then select ones for which + # correct = "true" + self.correct_choices = [ + contextualize_text(choice.get('name'), self.context) + for choice in cxml + if contextualize_text(choice.get('correct'), self.context) == "true"] def mc_setup_response(self): ''' @@ -875,7 +881,8 @@ def sympy_check2(): allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput', 'vsepr_input', - 'drag_and_drop_input'] + 'drag_and_drop_input', 'editamoleculeinput', + 'designprotein2dinput', 'editageneinput'] def setup_response(self): xml = self.xml @@ -998,7 +1005,7 @@ def sympy_check2(): self.context['debug'] = self.system.DEBUG # exec the check function - if type(self.code) == str: + if isinstance(self.code, basestring): try: exec self.code in self.context['global_context'], self.context correct = self.context['correct'] @@ -1294,7 +1301,7 @@ class CodeResponse(LoncapaResponse): # State associated with the queueing request queuestate = {'key': queuekey, - 'time': qtime,} + 'time': qtime, } cmap = CorrectMap() if error: diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html index becd2a330a..dd177dc920 100644 --- a/common/lib/capa/capa/templates/chemicalequationinput.html +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -31,7 +31,6 @@
          -

          % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index 457d9e7817..b9d8164321 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -1,23 +1,35 @@
          -
          - % if status == 'unsubmitted': - - % elif status == 'correct': - - % elif status == 'incorrect': - - % elif status == 'incomplete': - - % endif -
          % for choice_id, choice_description in choices: - + /> + + ${choice_description} + + + % endfor
          diff --git a/common/lib/capa/capa/templates/codeinput.html b/common/lib/capa/capa/templates/codeinput.html index 5c2ff2aca5..eb8cad0d70 100644 --- a/common/lib/capa/capa/templates/codeinput.html +++ b/common/lib/capa/capa/templates/codeinput.html @@ -50,6 +50,7 @@ }, smartIndent: false }); + $("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))}); });
          diff --git a/common/lib/capa/capa/templates/designprotein2dinput.html b/common/lib/capa/capa/templates/designprotein2dinput.html new file mode 100644 index 0000000000..6733566ab9 --- /dev/null +++ b/common/lib/capa/capa/templates/designprotein2dinput.html @@ -0,0 +1,35 @@ +
          +
          +
          + + % if status == 'unsubmitted': +
          + % elif status == 'correct': +
          + % elif status == 'incorrect': +
          + % elif status == 'incomplete': +
          + % endif + +
          + + + +

          + % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

          + +

          + % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
          +% endif +
          diff --git a/common/lib/capa/capa/templates/editageneinput.html b/common/lib/capa/capa/templates/editageneinput.html new file mode 100644 index 0000000000..8dd4fa89d1 --- /dev/null +++ b/common/lib/capa/capa/templates/editageneinput.html @@ -0,0 +1,39 @@ +
          +
          + + % if status == 'unsubmitted': +
          + % elif status == 'correct': +
          + % elif status == 'incorrect': +
          + % elif status == 'incomplete': +
          + % endif + + + + + + Applet failed to run. No Java plug-in was found. + + + + +

          + % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

          + +

          + % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
          +% endif +
          diff --git a/common/lib/capa/capa/templates/editamolecule.html b/common/lib/capa/capa/templates/editamolecule.html new file mode 100644 index 0000000000..5658c26e22 --- /dev/null +++ b/common/lib/capa/capa/templates/editamolecule.html @@ -0,0 +1,43 @@ +
          +
          + + % if status == 'unsubmitted': +
          + % elif status == 'correct': +
          + % elif status == 'incorrect': +
          + % elif status == 'incomplete': +
          + % endif + +
          +
          + +
          + + + + + +

          + +

          + % if status == 'unsubmitted': + unanswered + % elif status == 'correct': + correct + % elif status == 'incorrect': + incorrect + % elif status == 'incomplete': + incomplete + % endif +

          +

          + + + + % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
          + % endif +
          diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index b06975f6ce..89cb5a5ee9 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -8,6 +8,7 @@ import xml.sax.saxutils as saxutils TEST_DIR = os.path.dirname(os.path.realpath(__file__)) + def tst_render_template(template, context): """ A test version of render to template. Renders to the repr of the context, completely ignoring @@ -25,7 +26,7 @@ test_system = Mock( user=Mock(), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), debug=True, - xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10}, + xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), - anonymous_student_id = 'student' + anonymous_student_id='student' ) diff --git a/common/lib/capa/capa/tests/response_xml_factory.py b/common/lib/capa/capa/tests/response_xml_factory.py new file mode 100644 index 0000000000..fe918ec5db --- /dev/null +++ b/common/lib/capa/capa/tests/response_xml_factory.py @@ -0,0 +1,668 @@ +from lxml import etree +from abc import ABCMeta, abstractmethod + +class ResponseXMLFactory(object): + """ Abstract base class for capa response XML factories. + Subclasses override create_response_element and + create_input_element to produce XML of particular response types""" + + __metaclass__ = ABCMeta + + @abstractmethod + def create_response_element(self, **kwargs): + """ Subclasses override to return an etree element + representing the capa response XML + (e.g. ). + + The tree should NOT contain any input elements + (such as ) as these will be added later.""" + return None + + @abstractmethod + def create_input_element(self, **kwargs): + """ Subclasses override this to return an etree element + representing the capa input XML (such as )""" + return None + + def build_xml(self, **kwargs): + """ Construct an XML string for a capa response + based on **kwargs. + + **kwargs is a dictionary that will be passed + to create_response_element() and create_input_element(). + See the subclasses below for other keyword arguments + you can specify. + + For all response types, **kwargs can contain: + + *question_text*: The text of the question to display, + wrapped in

          tags. + + *explanation_text*: The detailed explanation that will + be shown if the user answers incorrectly. + + *script*: The embedded Python script (a string) + + *num_responses*: The number of responses to create [DEFAULT: 1] + + *num_inputs*: The number of input elements + to create [DEFAULT: 1] + + Returns a string representation of the XML tree. + """ + + # Retrieve keyward arguments + question_text = kwargs.get('question_text', '') + explanation_text = kwargs.get('explanation_text', '') + script = kwargs.get('script', None) + num_responses = kwargs.get('num_responses', 1) + num_inputs = kwargs.get('num_inputs', 1) + + # The root is + root = etree.Element("problem") + + # Add a script if there is one + if script: + script_element = etree.SubElement(root, "script") + script_element.set("type", "loncapa/python") + script_element.text = str(script) + + # The problem has a child

          with question text + question = etree.SubElement(root, "p") + question.text = question_text + + # Add the response(s) + for i in range(0, int(num_responses)): + response_element = self.create_response_element(**kwargs) + root.append(response_element) + + # Add input elements + for j in range(0, int(num_inputs)): + input_element = self.create_input_element(**kwargs) + if not (None == input_element): + response_element.append(input_element) + + # The problem has an explanation of the solution + if explanation_text: + explanation = etree.SubElement(root, "solution") + explanation_div = etree.SubElement(explanation, "div") + explanation_div.set("class", "detailed-solution") + explanation_div.text = explanation_text + + return etree.tostring(root) + + @staticmethod + def textline_input_xml(**kwargs): + """ Create a XML element + + Uses **kwargs: + + *math_display*: If True, then includes a MathJax display of user input + + *size*: An integer representing the width of the text line + """ + math_display = kwargs.get('math_display', False) + size = kwargs.get('size', None) + + input_element = etree.Element('textline') + + if math_display: + input_element.set('math', '1') + + if size: + input_element.set('size', str(size)) + + return input_element + + @staticmethod + def choicegroup_input_xml(**kwargs): + """ Create a XML element + + Uses **kwargs: + + *choice_type*: Can be "checkbox", "radio", or "multiple" + + *choices*: List of True/False values indicating whether + a particular choice is correct or not. + Users must choose *all* correct options in order + to be marked correct. + DEFAULT: [True] + + *choice_names": List of strings identifying the choices. + If specified, you must ensure that + len(choice_names) == len(choices) + """ + # Names of group elements + group_element_names = {'checkbox': 'checkboxgroup', + 'radio': 'radiogroup', + 'multiple': 'choicegroup' } + + # Retrieve **kwargs + choices = kwargs.get('choices', [True]) + choice_type = kwargs.get('choice_type', 'multiple') + choice_names = kwargs.get('choice_names', [None] * len(choices)) + + # Create the , , or element + assert(choice_type in group_element_names) + group_element = etree.Element(group_element_names[choice_type]) + + # Create the elements + for (correct_val, name) in zip(choices, choice_names): + choice_element = etree.SubElement(group_element, "choice") + choice_element.set("correct", "true" if correct_val else "false") + + # Add some text describing the choice + etree.SubElement(choice_element, "startouttext") + etree.text = "Choice description" + etree.SubElement(choice_element, "endouttext") + + # Add a name identifying the choice, if one exists + if name: + choice_element.set("name", str(name)) + + return group_element + + +class NumericalResponseXMLFactory(ResponseXMLFactory): + """ Factory for producing XML trees """ + + def create_response_element(self, **kwargs): + """ Create a XML element. + Uses **kwarg keys: + + *answer*: The correct answer (e.g. "5") + + *tolerance*: The tolerance within which a response + is considered correct. Can be a decimal (e.g. "0.01") + or percentage (e.g. "2%") + """ + + answer = kwargs.get('answer', None) + tolerance = kwargs.get('tolerance', None) + + response_element = etree.Element('numericalresponse') + + if answer: + response_element.set('answer', str(answer)) + + if tolerance: + responseparam_element = etree.SubElement(response_element, 'responseparam') + responseparam_element.set('type', 'tolerance') + responseparam_element.set('default', str(tolerance)) + + return response_element + + def create_input_element(self, **kwargs): + return ResponseXMLFactory.textline_input_xml(**kwargs) + + +class CustomResponseXMLFactory(ResponseXMLFactory): + """ Factory for producing XML trees """ + + def create_response_element(self, **kwargs): + """ Create a XML element. + + Uses **kwargs: + + *cfn*: the Python code to run. Can be inline code, + or the name of a function defined in earlier - - -

          Hints can be provided to students, based on the last response given, as well as the history of responses given. Here is an example of a hint produced by a Formula Response problem.

          - -

          -What is the equation of the line which passess through ($x1,$y1) and -($x2,$y2)?

          - -

          The correct answer is $answer. A common error is to invert the equation for the slope. Enter -$wrongans to see a hint.

          - - - - - - y = - - - - - You have inverted the slope in the question. - - - - - diff --git a/common/lib/capa/capa/tests/test_files/imageresponse.xml b/common/lib/capa/capa/tests/test_files/imageresponse.xml deleted file mode 100644 index 41c9f01218..0000000000 --- a/common/lib/capa/capa/tests/test_files/imageresponse.xml +++ /dev/null @@ -1,40 +0,0 @@ - -

          -Two skiers are on frictionless black diamond ski slopes. -Hello

          - - - -Click on the image where the top skier will stop momentarily if the top skier starts from rest. - -Click on the image where the lower skier will stop momentarily if the lower skier starts from rest. - -Click on either of the two positions as discussed previously. - -Click on either of the two positions as discussed previously. - -Click on either of the two positions as discussed previously. - -

          Use conservation of energy.

          -
          -
          - - - - - - - -Click on either of the two positions as discussed previously. - -Click on either of the two positions as discussed previously. - - -Click on either of the two positions as discussed previously. - -

          Use conservation of energy.

          -
          -
          - - -
          diff --git a/common/lib/capa/capa/tests/test_files/javascriptresponse.xml b/common/lib/capa/capa/tests/test_files/javascriptresponse.xml deleted file mode 100644 index 439866e62c..0000000000 --- a/common/lib/capa/capa/tests/test_files/javascriptresponse.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/common/lib/capa/capa/tests/test_files/js/.gitignore b/common/lib/capa/capa/tests/test_files/js/.gitignore new file mode 100644 index 0000000000..d2910668f2 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/js/.gitignore @@ -0,0 +1,4 @@ +test_problem_display.js +test_problem_generator.js +test_problem_grader.js +xproblem.js \ No newline at end of file diff --git a/common/lib/capa/capa/tests/test_files/js/compiled/c9a9cd4242d84c924fe5f8324e9ae79d.js b/common/lib/capa/capa/tests/test_files/js/compiled/c9a9cd4242d84c924fe5f8324e9ae79d.js deleted file mode 100644 index 6670c6a09a..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/compiled/c9a9cd4242d84c924fe5f8324e9ae79d.js +++ /dev/null @@ -1,50 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var MinimaxProblemDisplay, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - MinimaxProblemDisplay = (function(_super) { - - __extends(MinimaxProblemDisplay, _super); - - function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); - } - - MinimaxProblemDisplay.prototype.render = function() {}; - - MinimaxProblemDisplay.prototype.createSubmission = function() { - var id, value, _ref, _results; - this.newSubmission = {}; - if (this.submission != null) { - _ref = this.submission; - _results = []; - for (id in _ref) { - value = _ref[id]; - _results.push(this.newSubmission[id] = value); - } - return _results; - } - }; - - MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { - return this.newSubmission; - }; - - return MinimaxProblemDisplay; - - })(XProblemDisplay); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.TestProblemDisplay = TestProblemDisplay; - -}).call(this); -; diff --git a/common/lib/capa/capa/tests/test_files/js/compiled/javascriptresponse.js b/common/lib/capa/capa/tests/test_files/js/compiled/javascriptresponse.js deleted file mode 100644 index 6670c6a09a..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/compiled/javascriptresponse.js +++ /dev/null @@ -1,50 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var MinimaxProblemDisplay, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - MinimaxProblemDisplay = (function(_super) { - - __extends(MinimaxProblemDisplay, _super); - - function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); - } - - MinimaxProblemDisplay.prototype.render = function() {}; - - MinimaxProblemDisplay.prototype.createSubmission = function() { - var id, value, _ref, _results; - this.newSubmission = {}; - if (this.submission != null) { - _ref = this.submission; - _results = []; - for (id in _ref) { - value = _ref[id]; - _results.push(this.newSubmission[id] = value); - } - return _results; - } - }; - - MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { - return this.newSubmission; - }; - - return MinimaxProblemDisplay; - - })(XProblemDisplay); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.TestProblemDisplay = TestProblemDisplay; - -}).call(this); -; diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_display.js b/common/lib/capa/capa/tests/test_files/js/test_problem_display.js deleted file mode 100644 index 35b619c6ec..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_display.js +++ /dev/null @@ -1,49 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var MinimaxProblemDisplay, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - MinimaxProblemDisplay = (function(_super) { - - __extends(MinimaxProblemDisplay, _super); - - function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); - } - - MinimaxProblemDisplay.prototype.render = function() {}; - - MinimaxProblemDisplay.prototype.createSubmission = function() { - var id, value, _ref, _results; - this.newSubmission = {}; - if (this.submission != null) { - _ref = this.submission; - _results = []; - for (id in _ref) { - value = _ref[id]; - _results.push(this.newSubmission[id] = value); - } - return _results; - } - }; - - MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { - return this.newSubmission; - }; - - return MinimaxProblemDisplay; - - })(XProblemDisplay); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.TestProblemDisplay = TestProblemDisplay; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js b/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js deleted file mode 100644 index b2f01ed252..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js +++ /dev/null @@ -1,29 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var TestProblemGenerator, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - TestProblemGenerator = (function(_super) { - - __extends(TestProblemGenerator, _super); - - function TestProblemGenerator(seed, parameters) { - this.parameters = parameters != null ? parameters : {}; - TestProblemGenerator.__super__.constructor.call(this, seed, this.parameters); - } - - TestProblemGenerator.prototype.generate = function() { - this.problemState.value = this.parameters.value; - return this.problemState; - }; - - return TestProblemGenerator; - - })(XProblemGenerator); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.generatorClass = TestProblemGenerator; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js b/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js deleted file mode 100644 index 34dfff35cc..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js +++ /dev/null @@ -1,50 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var TestProblemGrader, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - TestProblemGrader = (function(_super) { - - __extends(TestProblemGrader, _super); - - function TestProblemGrader(submission, problemState, parameters) { - this.submission = submission; - this.problemState = problemState; - this.parameters = parameters != null ? parameters : {}; - TestProblemGrader.__super__.constructor.call(this, this.submission, this.problemState, this.parameters); - } - - TestProblemGrader.prototype.solve = function() { - return this.solution = { - 0: this.problemState.value - }; - }; - - TestProblemGrader.prototype.grade = function() { - var allCorrect, id, value, valueCorrect, _ref; - if (!(this.solution != null)) { - this.solve(); - } - allCorrect = true; - _ref = this.solution; - for (id in _ref) { - value = _ref[id]; - valueCorrect = this.submission != null ? value === this.submission[id] : false; - this.evaluation[id] = valueCorrect; - if (!valueCorrect) { - allCorrect = false; - } - } - return allCorrect; - }; - - return TestProblemGrader; - - })(XProblemGrader); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.graderClass = TestProblemGrader; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/js/xproblem.js b/common/lib/capa/capa/tests/test_files/js/xproblem.js deleted file mode 100644 index 512cf22739..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/xproblem.js +++ /dev/null @@ -1,78 +0,0 @@ -// Generated by CoffeeScript 1.3.3 -(function() { - var XProblemDisplay, XProblemGenerator, XProblemGrader, root; - - XProblemGenerator = (function() { - - function XProblemGenerator(seed, parameters) { - this.parameters = parameters != null ? parameters : {}; - this.random = new MersenneTwister(seed); - this.problemState = {}; - } - - XProblemGenerator.prototype.generate = function() { - return console.error("Abstract method called: XProblemGenerator.generate"); - }; - - return XProblemGenerator; - - })(); - - XProblemDisplay = (function() { - - function XProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - } - - XProblemDisplay.prototype.render = function() { - return console.error("Abstract method called: XProblemDisplay.render"); - }; - - XProblemDisplay.prototype.updateSubmission = function() { - return this.submissionField.val(JSON.stringify(this.getCurrentSubmission())); - }; - - XProblemDisplay.prototype.getCurrentSubmission = function() { - return console.error("Abstract method called: XProblemDisplay.getCurrentSubmission"); - }; - - return XProblemDisplay; - - })(); - - XProblemGrader = (function() { - - function XProblemGrader(submission, problemState, parameters) { - this.submission = submission; - this.problemState = problemState; - this.parameters = parameters != null ? parameters : {}; - this.solution = null; - this.evaluation = {}; - } - - XProblemGrader.prototype.solve = function() { - return console.error("Abstract method called: XProblemGrader.solve"); - }; - - XProblemGrader.prototype.grade = function() { - return console.error("Abstract method called: XProblemGrader.grade"); - }; - - return XProblemGrader; - - })(); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.XProblemGenerator = XProblemGenerator; - - root.XProblemDisplay = XProblemDisplay; - - root.XProblemGrader = XProblemGrader; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/multi_bare.xml b/common/lib/capa/capa/tests/test_files/multi_bare.xml deleted file mode 100644 index 20bc8f853d..0000000000 --- a/common/lib/capa/capa/tests/test_files/multi_bare.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - This is foil One. - - - This is foil Two. - - - This is foil Three. - - - This is foil Four. - - - This is foil Five. - - - - diff --git a/common/lib/capa/capa/tests/test_files/multichoice.xml b/common/lib/capa/capa/tests/test_files/multichoice.xml deleted file mode 100644 index 60bf02ec59..0000000000 --- a/common/lib/capa/capa/tests/test_files/multichoice.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - This is foil One. - - - This is foil Two. - - - This is foil Three. - - - This is foil Four. - - - This is foil Five. - - - - diff --git a/common/lib/capa/capa/tests/test_files/optionresponse.xml b/common/lib/capa/capa/tests/test_files/optionresponse.xml deleted file mode 100644 index 99a17e8fac..0000000000 --- a/common/lib/capa/capa/tests/test_files/optionresponse.xml +++ /dev/null @@ -1,63 +0,0 @@ - - -

          -Why do bicycles benefit from having larger wheels when going up a bump as shown in the picture?
          -Assume that for both bicycles:
          -1.) The tires have equal air pressure.
          -2.) The bicycles never leave the contact with the bump.
          -3.) The bicycles have the same mass. The bicycle tires (regardless of size) have the same mass.
          -

          -
          - -
            -
          • - -

            The bicycles with larger wheels have more time to go over the bump. This decreases the magnitude of the force needed to lift the bicycle.

            -
            - - -
          • -
          • - -

            The bicycles with larger wheels always have a smaller vertical displacement regardless of speed.

            -
            - - -
          • -
          • - -

            The bicycles with larger wheels experience a force backward with less magnitude for the same amount of time.

            -
            - - -
          • -
          • - -

            The bicycles with larger wheels experience a force backward with less magnitude for a greater amount of time.

            -
            - - -
          • -
          • - -

            The bicycles with larger wheels have more kinetic energy turned into gravitational potential energy.

            -
            - - -
          • -
          • - -

            The bicycles with larger wheels have more rotational kinetic energy, so the horizontal velocity of the biker changes less.

            -
            - - -
          • -
          - - -
          -
          -
          -
          -
          -
          diff --git a/common/lib/capa/capa/tests/test_files/stringresponse_with_hint.xml b/common/lib/capa/capa/tests/test_files/stringresponse_with_hint.xml deleted file mode 100644 index 86efdf0f18..0000000000 --- a/common/lib/capa/capa/tests/test_files/stringresponse_with_hint.xml +++ /dev/null @@ -1,25 +0,0 @@ - -

          Example: String Response Problem

          -
          -
          - - Which US state has Lansing as its capital? - - - - - - - - - The state capital of Wisconsin is Madison. - - - The state capital of Minnesota is St. Paul. - - - The state you are looking for is also known as the 'Great Lakes State' - - - -
          diff --git a/common/lib/capa/capa/tests/test_files/symbolicresponse.xml b/common/lib/capa/capa/tests/test_files/symbolicresponse.xml deleted file mode 100644 index 4dc2bc9d7b..0000000000 --- a/common/lib/capa/capa/tests/test_files/symbolicresponse.xml +++ /dev/null @@ -1,29 +0,0 @@ - - -

          Example: Symbolic Math Response Problem

          - -

          -A symbolic math response problem presents one or more symbolic math -input fields for input. Correctness of input is evaluated based on -the symbolic properties of the expression entered. The student enters -text, but sees a proper symbolic rendition of the entered formula, in -real time, next to the input box. -

          - -

          This is a correct answer which may be entered below:

          -

          cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]

          - - - Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax] - and give the resulting \(2 \times 2\) matrix.
          - Your input should be typed in as a list of lists, eg [[1,2],[3,4]].
          - [mathjax]U=[/mathjax] - - -
          -
          - -
          -
          diff --git a/common/lib/capa/capa/tests/test_files/truefalse.xml b/common/lib/capa/capa/tests/test_files/truefalse.xml deleted file mode 100644 index 60018f7a2d..0000000000 --- a/common/lib/capa/capa/tests/test_files/truefalse.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - This is foil One. - - - This is foil Two. - - - This is foil Three. - - - This is foil Four. - - - This is foil Five. - - - - diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 6c282baf95..4a5ea5c429 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -31,6 +31,7 @@ lookup_tag = inputtypes.registry.get_class_for_tag def quote_attr(s): return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes + class OptionInputTest(unittest.TestCase): ''' Make sure option inputs work @@ -100,7 +101,7 @@ class ChoiceGroupTest(unittest.TestCase): 'input_type': expected_input_type, 'choices': [('foil1', 'This is foil One.'), ('foil2', 'This is foil Two.'), - ('foil3', 'This is foil Three.'),], + ('foil3', 'This is foil Three.'), ], 'name_array_suffix': expected_suffix, # what is this for?? } @@ -137,7 +138,7 @@ class JavascriptInputTest(unittest.TestCase): element = etree.fromstring(xml_str) - state = {'value': '3',} + state = {'value': '3', } the_input = lookup_tag('javascriptinput')(test_system, element, state) context = the_input._get_render_context() @@ -149,7 +150,7 @@ class JavascriptInputTest(unittest.TestCase): 'params': params, 'display_file': display_file, 'display_class': display_class, - 'problem_state': problem_state,} + 'problem_state': problem_state, } self.assertEqual(context, expected) @@ -165,7 +166,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) - state = {'value': 'BumbleBee',} + state = {'value': 'BumbleBee', } the_input = lookup_tag('textline')(test_system, element, state) context = the_input._get_render_context() @@ -193,7 +194,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) - state = {'value': 'BumbleBee',} + state = {'value': 'BumbleBee', } the_input = lookup_tag('textline')(test_system, element, state) context = the_input._get_render_context() @@ -231,7 +232,7 @@ class FileSubmissionTest(unittest.TestCase): state = {'value': 'BumbleBee.py', 'status': 'incomplete', - 'feedback' : {'message': '3'}, } + 'feedback': {'message': '3'}, } input_class = lookup_tag('filesubmission') the_input = input_class(test_system, element, state) @@ -275,7 +276,7 @@ class CodeInputTest(unittest.TestCase): state = {'value': 'print "good evening"', 'status': 'incomplete', - 'feedback' : {'message': '3'}, } + 'feedback': {'message': '3'}, } input_class = lookup_tag('codeinput') the_input = input_class(test_system, element, state) @@ -488,7 +489,7 @@ class ChemicalEquationTest(unittest.TestCase): element = etree.fromstring(xml_str) - state = {'value': 'H2OYeah',} + state = {'value': 'H2OYeah', } the_input = lookup_tag('chemicalequationinput')(test_system, element, state) context = the_input._get_render_context() diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 9eecef3986..33b84d213d 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -16,92 +16,151 @@ from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat -class MultiChoiceTest(unittest.TestCase): - def test_MC_grade(self): - multichoice_file = os.path.dirname(__file__) + "/test_files/multichoice.xml" - test_lcp = lcp.LoncapaProblem(open(multichoice_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_foil3'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - false_answers = {'1_2_1': 'choice_foil2'} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') +class ResponseTest(unittest.TestCase): + """ Base class for tests of capa responses.""" + + xml_factory_class = None - def test_MC_bare_grades(self): - multichoice_file = os.path.dirname(__file__) + "/test_files/multi_bare.xml" - test_lcp = lcp.LoncapaProblem(open(multichoice_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_2'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - false_answers = {'1_2_1': 'choice_1'} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') + def setUp(self): + if self.xml_factory_class: + self.xml_factory = self.xml_factory_class() - def test_TF_grade(self): - truefalse_file = os.path.dirname(__file__) + "/test_files/truefalse.xml" - test_lcp = lcp.LoncapaProblem(open(truefalse_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': ['choice_foil2', 'choice_foil1']} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - false_answers = {'1_2_1': ['choice_foil1']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') - false_answers = {'1_2_1': ['choice_foil1', 'choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') - false_answers = {'1_2_1': ['choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') - false_answers = {'1_2_1': ['choice_foil1', 'choice_foil2', 'choice_foil3']} - self.assertEquals(test_lcp.grade_answers(false_answers).get_correctness('1_2_1'), 'incorrect') + def build_problem(self, **kwargs): + xml = self.xml_factory.build_xml(**kwargs) + return lcp.LoncapaProblem(xml, '1', system=test_system) + + def assert_grade(self, problem, submission, expected_correctness): + input_dict = {'1_2_1': submission} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness) + + def assert_multiple_grade(self, problem, correct_answers, incorrect_answers): + for input_str in correct_answers: + result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') + self.assertEqual(result, 'correct', + msg="%s should be marked correct" % str(input_str)) + + for input_str in incorrect_answers: + result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') + self.assertEqual(result, 'incorrect', + msg="%s should be marked incorrect" % str(input_str)) + +class MultiChoiceResponseTest(ResponseTest): + from response_xml_factory import MultipleChoiceResponseXMLFactory + xml_factory_class = MultipleChoiceResponseXMLFactory + + def test_multiple_choice_grade(self): + problem = self.build_problem(choices=[False, True, False]) + + # Ensure that we get the expected grades + self.assert_grade(problem, 'choice_0', 'incorrect') + self.assert_grade(problem, 'choice_1', 'correct') + self.assert_grade(problem, 'choice_2', 'incorrect') + + def test_named_multiple_choice_grade(self): + problem = self.build_problem(choices=[False, True, False], + choice_names=["foil_1", "foil_2", "foil_3"]) + + # Ensure that we get the expected grades + self.assert_grade(problem, 'choice_foil_1', 'incorrect') + self.assert_grade(problem, 'choice_foil_2', 'correct') + self.assert_grade(problem, 'choice_foil_3', 'incorrect') -class ImageResponseTest(unittest.TestCase): - def test_ir_grade(self): - imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml" - test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system) - # testing regions only - correct_answers = { - #regions - '1_2_1': '(490,11)-(556,98)', - '1_2_2': '(242,202)-(296,276)', - '1_2_3': '(490,11)-(556,98);(242,202)-(296,276)', - '1_2_4': '(490,11)-(556,98);(242,202)-(296,276)', - '1_2_5': '(490,11)-(556,98);(242,202)-(296,276)', - #testing regions and rectanges - '1_3_1': 'rectangle="(490,11)-(556,98)" \ - regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_2': 'rectangle="(490,11)-(556,98)" \ - regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_3': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_4': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"', - '1_3_5': 'regions="[[[10,10], [20,10], [20, 30]]]"', - '1_3_6': 'regions="[[10,10], [30,30], [15, 15]]"', - '1_3_7': 'regions="[[10,10], [30,30], [10, 30], [30, 10]]"', - } - test_answers = { - '1_2_1': '[500,20]', - '1_2_2': '[250,300]', - '1_2_3': '[500,20]', - '1_2_4': '[250,250]', - '1_2_5': '[10,10]', +class TrueFalseResponseTest(ResponseTest): + from response_xml_factory import TrueFalseResponseXMLFactory + xml_factory_class = TrueFalseResponseXMLFactory - '1_3_1': '[500,20]', - '1_3_2': '[15,15]', - '1_3_3': '[500,20]', - '1_3_4': '[115,115]', - '1_3_5': '[15,15]', - '1_3_6': '[20,20]', - '1_3_7': '[20,15]', - } + def test_true_false_grade(self): + problem = self.build_problem(choices=[False, True, True]) - # regions - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_3'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_4'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_5'), 'incorrect') + # Check the results + # Mark correct if and only if ALL (and only) correct choices selected + self.assert_grade(problem, 'choice_0', 'incorrect') + self.assert_grade(problem, 'choice_1', 'incorrect') + self.assert_grade(problem, 'choice_2', 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_1', 'choice_2'], 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_2'], 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect') + self.assert_grade(problem, ['choice_1', 'choice_2'], 'correct') - # regions and rectangles - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_2'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_3'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_4'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_5'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_6'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_7'), 'correct') + # Invalid choices should be marked incorrect (we have no choice 3) + self.assert_grade(problem, 'choice_3', 'incorrect') + self.assert_grade(problem, 'not_a_choice', 'incorrect') + + def test_named_true_false_grade(self): + problem = self.build_problem(choices=[False, True, True], + choice_names=['foil_1','foil_2','foil_3']) + + # Check the results + # Mark correct if and only if ALL (and only) correct chocies selected + self.assert_grade(problem, 'choice_foil_1', 'incorrect') + self.assert_grade(problem, 'choice_foil_2', 'incorrect') + self.assert_grade(problem, 'choice_foil_3', 'incorrect') + self.assert_grade(problem, ['choice_foil_1', 'choice_foil_2', 'choice_foil_3'], 'incorrect') + self.assert_grade(problem, ['choice_foil_1', 'choice_foil_3'], 'incorrect') + self.assert_grade(problem, ['choice_foil_1', 'choice_foil_2'], 'incorrect') + self.assert_grade(problem, ['choice_foil_2', 'choice_foil_3'], 'correct') + + # Invalid choices should be marked incorrect + self.assert_grade(problem, 'choice_foil_4', 'incorrect') + self.assert_grade(problem, 'not_a_choice', 'incorrect') + +class ImageResponseTest(ResponseTest): + from response_xml_factory import ImageResponseXMLFactory + xml_factory_class = ImageResponseXMLFactory + + def test_rectangle_grade(self): + # Define a rectangle with corners (10,10) and (20,20) + problem = self.build_problem(rectangle="(10,10)-(20,20)") + + # Anything inside the rectangle (and along the borders) is correct + # Everything else is incorrect + correct_inputs = ["[12,19]", "[10,10]", "[20,20]", + "[10,15]", "[20,15]", "[15,10]", "[15,20]"] + incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_multiple_rectangles_grade(self): + # Define two rectangles + rectangle_str = "(10,10)-(20,20);(100,100)-(200,200)" + + # Expect that only points inside the rectangles are marked correct + problem = self.build_problem(rectangle=rectangle_str) + correct_inputs = ["[12,19]", "[120, 130]"] + incorrect_inputs = ["[4,6]", "[25,15]", "[15,40]", "[15,4]", + "[50,55]", "[300, 14]", "[120, 400]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_region_grade(self): + # Define a triangular region with corners (0,0), (5,10), and (0, 10) + region_str = "[ [1,1], [5,10], [0,10] ]" + + # Expect that only points inside the triangle are marked correct + problem = self.build_problem(regions=region_str) + correct_inputs = ["[2,4]", "[1,3]"] + incorrect_inputs = ["[0,0]", "[3,5]", "[5,15]", "[30, 12]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_multiple_regions_grade(self): + # Define multiple regions that the user can select + region_str="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]" + + # Expect that only points inside the regions are marked correct + problem = self.build_problem(regions=region_str) + correct_inputs = ["[15,12]", "[110,112]"] + incorrect_inputs = ["[0,0]", "[600,300]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + + def test_region_and_rectangle_grade(self): + rectangle_str = "(100,100)-(200,200)" + region_str="[[10,10], [20,10], [20, 30]]" + + # Expect that only points inside the rectangle or region are marked correct + problem = self.build_problem(regions=region_str, rectangle=rectangle_str) + correct_inputs = ["[13,12]", "[110,112]"] + incorrect_inputs = ["[0,0]", "[600,300]"] + self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) class SymbolicResponseTest(unittest.TestCase): @@ -194,60 +253,165 @@ class SymbolicResponseTest(unittest.TestCase): self.assertEquals(test_lcp.grade_answers(wrong_answers).get_correctness('1_2_1'), 'incorrect') -class OptionResponseTest(unittest.TestCase): - ''' - Run this with +class OptionResponseTest(ResponseTest): + from response_xml_factory import OptionResponseXMLFactory + xml_factory_class = OptionResponseXMLFactory - python manage.py test courseware.OptionResponseTest - ''' - def test_or_grade(self): - optionresponse_file = os.path.dirname(__file__) + "/test_files/optionresponse.xml" - test_lcp = lcp.LoncapaProblem(open(optionresponse_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'True', - '1_2_2': 'False'} - test_answers = {'1_2_1': 'True', - '1_2_2': 'True', - } - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect') + def test_grade(self): + problem = self.build_problem(options=["first", "second", "third"], + correct_option="second") + + # Assert that we get the expected grades + self.assert_grade(problem, "first", "incorrect") + self.assert_grade(problem, "second", "correct") + self.assert_grade(problem, "third", "incorrect") + + # Options not in the list should be marked incorrect + self.assert_grade(problem, "invalid_option", "incorrect") -class FormulaResponseWithHintTest(unittest.TestCase): - ''' - Test Formula response problem with a hint - This problem also uses calc. - ''' - def test_or_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/formularesponse_with_hint.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': '2.5*x-5.0'} - test_answers = {'1_2_1': '0.4*x-5.0'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - cmap = test_lcp.grade_answers(test_answers) - self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect') - self.assertTrue('You have inverted' in cmap.get_hint('1_2_1')) +class FormulaResponseTest(ResponseTest): + from response_xml_factory import FormulaResponseXMLFactory + xml_factory_class = FormulaResponseXMLFactory + + def test_grade(self): + # Sample variables x and y in the range [-10, 10] + sample_dict = {'x': (-10, 10), 'y': (-10, 10)} + + # The expected solution is numerically equivalent to x+2y + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="x+2*y") + + # Expect an equivalent formula to be marked correct + # 2x - x + y + y = x + 2y + input_formula = "2*x - x + y + y" + self.assert_grade(problem, input_formula, "correct") + + # Expect an incorrect formula to be marked incorrect + # x + y != x + 2y + input_formula = "x + y" + self.assert_grade(problem, input_formula, "incorrect") + + def test_hint(self): + # Sample variables x and y in the range [-10, 10] + sample_dict = {'x': (-10, 10), 'y': (-10,10) } + + # Give a hint if the user leaves off the coefficient + # or leaves out x + hints = [('x + 3*y', 'y_coefficient', 'Check the coefficient of y'), + ('2*y', 'missing_x', 'Try including the variable x')] -class StringResponseWithHintTest(unittest.TestCase): - ''' - Test String response problem with a hint - ''' - def test_or_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/stringresponse_with_hint.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'Michigan'} - test_answers = {'1_2_1': 'Minnesota'} - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') - cmap = test_lcp.grade_answers(test_answers) - self.assertEquals(cmap.get_correctness('1_2_1'), 'incorrect') - self.assertTrue('St. Paul' in cmap.get_hint('1_2_1')) + # The expected solution is numerically equivalent to x+2y + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="x+2*y", + hints=hints) + + # Expect to receive a hint if we add an extra y + input_dict = {'1_2_1': "x + 2*y + y"} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + 'Check the coefficient of y') + + # Expect to receive a hint if we leave out x + input_dict = {'1_2_1': "2*y"} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + 'Try including the variable x') -class CodeResponseTest(unittest.TestCase): - ''' - Test CodeResponse - TODO: Add tests for external grader messages - ''' + def test_script(self): + # Calculate the answer using a script + script = "calculated_ans = 'x+x'" + + # Sample x in the range [-10,10] + sample_dict = {'x': (-10, 10)} + + # The expected solution is numerically equivalent to 2*x + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="$calculated_ans", + script=script) + + # Expect that the inputs are graded correctly + self.assert_grade(problem, '2*x', 'correct') + self.assert_grade(problem, '3*x', 'incorrect') + + +class StringResponseTest(ResponseTest): + from response_xml_factory import StringResponseXMLFactory + xml_factory_class = StringResponseXMLFactory + + + def test_case_sensitive(self): + problem = self.build_problem(answer="Second", case_sensitive=True) + + # Exact string should be correct + self.assert_grade(problem, "Second", "correct") + + # Other strings and the lowercase version of the string are incorrect + self.assert_grade(problem, "Other String", "incorrect") + self.assert_grade(problem, "second", "incorrect") + + def test_case_insensitive(self): + problem = self.build_problem(answer="Second", case_sensitive=False) + + # Both versions of the string should be allowed, regardless + # of capitalization + self.assert_grade(problem, "Second", "correct") + self.assert_grade(problem, "second", "correct") + + # Other strings are not allowed + self.assert_grade(problem, "Other String", "incorrect") + + def test_hints(self): + hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), + ("minnesota", "minn", "The state capital of Minnesota is St. Paul")] + + problem = self.build_problem(answer="Michigan", + case_sensitive=False, + hints=hints) + + # We should get a hint for Wisconsin + input_dict = {'1_2_1': 'Wisconsin'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + "The state capital of Wisconsin is Madison") + + # We should get a hint for Minnesota + input_dict = {'1_2_1': 'Minnesota'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), + "The state capital of Minnesota is St. Paul") + + # We should NOT get a hint for Michigan (the correct answer) + input_dict = {'1_2_1': 'Michigan'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), "") + + # We should NOT get a hint for any other string + input_dict = {'1_2_1': 'California'} + correct_map = problem.grade_answers(input_dict) + self.assertEquals(correct_map.get_hint('1_2_1'), "") + +class CodeResponseTest(ResponseTest): + from response_xml_factory import CodeResponseXMLFactory + xml_factory_class = CodeResponseXMLFactory + + def setUp(self): + super(CodeResponseTest, self).setUp() + + grader_payload = json.dumps({"grader": "ps04/grade_square.py"}) + self.problem = self.build_problem(initial_display="def square(x):", + answer_display="answer", + grader_payload=grader_payload, + num_responses=2) + @staticmethod def make_queuestate(key, time): timestr = datetime.strftime(time, dateformat) @@ -257,171 +421,354 @@ class CodeResponseTest(unittest.TestCase): """ Simple test of whether LoncapaProblem knows when it's been queued """ - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as input_file: - test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system) - answer_ids = sorted(test_lcp.get_question_answers()) + answer_ids = sorted(self.problem.get_question_answers()) - # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state - cmap = CorrectMap() - for answer_id in answer_ids: - cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) - test_lcp.correct_map.update(cmap) + # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state + cmap = CorrectMap() + for answer_id in answer_ids: + cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) + self.problem.correct_map.update(cmap) - self.assertEquals(test_lcp.is_queued(), False) + self.assertEquals(self.problem.is_queued(), False) - # Now we queue the LCP - cmap = CorrectMap() - for i, answer_id in enumerate(answer_ids): - queuestate = CodeResponseTest.make_queuestate(i, datetime.now()) - cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) - test_lcp.correct_map.update(cmap) + # Now we queue the LCP + cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuestate = CodeResponseTest.make_queuestate(i, datetime.now()) + cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) + self.problem.correct_map.update(cmap) - self.assertEquals(test_lcp.is_queued(), True) + self.assertEquals(self.problem.is_queued(), True) def test_update_score(self): ''' Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem ''' - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as input_file: - test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system) + answer_ids = sorted(self.problem.get_question_answers()) - answer_ids = sorted(test_lcp.get_question_answers()) + # CodeResponse requires internal CorrectMap state. Build it now in the queued state + old_cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuekey = 1000 + i + queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now()) + old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) - # CodeResponse requires internal CorrectMap state. Build it now in the queued state - old_cmap = CorrectMap() + # Message format common to external graders + grader_msg = 'MESSAGE' # Must be valid XML + correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg}) + incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg}) + + xserver_msgs = {'correct': correct_score_msg, + 'incorrect': incorrect_score_msg, } + + # Incorrect queuekey, state should not be updated + for correctness in ['correct', 'incorrect']: + self.problem.correct_map = CorrectMap() + self.problem.correct_map.update(old_cmap) # Deep copy + + self.problem.update_score(xserver_msgs[correctness], queuekey=0) + self.assertEquals(self.problem.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison + + for answer_id in answer_ids: + self.assertTrue(self.problem.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered + + # Correct queuekey, state should be updated + for correctness in ['correct', 'incorrect']: for i, answer_id in enumerate(answer_ids): - queuekey = 1000 + i - queuestate = CodeResponseTest.make_queuestate(1000+i, datetime.now()) - old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) + self.problem.correct_map = CorrectMap() + self.problem.correct_map.update(old_cmap) - # Message format common to external graders - grader_msg = 'MESSAGE' # Must be valid XML - correct_score_msg = json.dumps({'correct':True, 'score':1, 'msg': grader_msg}) - incorrect_score_msg = json.dumps({'correct':False, 'score':0, 'msg': grader_msg}) + new_cmap = CorrectMap() + new_cmap.update(old_cmap) + npoints = 1 if correctness == 'correct' else 0 + new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None) - xserver_msgs = {'correct': correct_score_msg, - 'incorrect': incorrect_score_msg,} + self.problem.update_score(xserver_msgs[correctness], queuekey=1000 + i) + self.assertEquals(self.problem.correct_map.get_dict(), new_cmap.get_dict()) - # Incorrect queuekey, state should not be updated - for correctness in ['correct', 'incorrect']: - test_lcp.correct_map = CorrectMap() - test_lcp.correct_map.update(old_cmap) # Deep copy - - test_lcp.update_score(xserver_msgs[correctness], queuekey=0) - self.assertEquals(test_lcp.correct_map.get_dict(), old_cmap.get_dict()) # Deep comparison - - for answer_id in answer_ids: - self.assertTrue(test_lcp.correct_map.is_queued(answer_id)) # Should be still queued, since message undelivered - - # Correct queuekey, state should be updated - for correctness in ['correct', 'incorrect']: - for i, answer_id in enumerate(answer_ids): - test_lcp.correct_map = CorrectMap() - test_lcp.correct_map.update(old_cmap) - - new_cmap = CorrectMap() - new_cmap.update(old_cmap) - npoints = 1 if correctness=='correct' else 0 - new_cmap.set(answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None) - - test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i) - self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict()) - - for j, test_id in enumerate(answer_ids): - if j == i: - self.assertFalse(test_lcp.correct_map.is_queued(test_id)) # Should be dequeued, message delivered - else: - self.assertTrue(test_lcp.correct_map.is_queued(test_id)) # Should be queued, message undelivered + for j, test_id in enumerate(answer_ids): + if j == i: + self.assertFalse(self.problem.correct_map.is_queued(test_id)) # Should be dequeued, message delivered + else: + self.assertTrue(self.problem.correct_map.is_queued(test_id)) # Should be queued, message undelivered def test_recentmost_queuetime(self): ''' Test whether the LoncapaProblem knows about the time of queue requests ''' - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as input_file: - test_lcp = lcp.LoncapaProblem(input_file.read(), '1', system=test_system) + answer_ids = sorted(self.problem.get_question_answers()) - answer_ids = sorted(test_lcp.get_question_answers()) + # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state + cmap = CorrectMap() + for answer_id in answer_ids: + cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) + self.problem.correct_map.update(cmap) - # CodeResponse requires internal CorrectMap state. Build it now in the unqueued state - cmap = CorrectMap() - for answer_id in answer_ids: - cmap.update(CorrectMap(answer_id=answer_id, queuestate=None)) - test_lcp.correct_map.update(cmap) + self.assertEquals(self.problem.get_recentmost_queuetime(), None) - self.assertEquals(test_lcp.get_recentmost_queuetime(), None) + # CodeResponse requires internal CorrectMap state. Build it now in the queued state + cmap = CorrectMap() + for i, answer_id in enumerate(answer_ids): + queuekey = 1000 + i + latest_timestamp = datetime.now() + queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp) + cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) + self.problem.correct_map.update(cmap) - # CodeResponse requires internal CorrectMap state. Build it now in the queued state - cmap = CorrectMap() - for i, answer_id in enumerate(answer_ids): - queuekey = 1000 + i - latest_timestamp = datetime.now() - queuestate = CodeResponseTest.make_queuestate(1000+i, latest_timestamp) - cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) - test_lcp.correct_map.update(cmap) + # Queue state only tracks up to second + latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat) - # Queue state only tracks up to second - latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat) + self.assertEquals(self.problem.get_recentmost_queuetime(), latest_timestamp) - self.assertEquals(test_lcp.get_recentmost_queuetime(), latest_timestamp) + def test_convert_files_to_filenames(self): + ''' + Test whether file objects are converted to filenames without altering other structures + ''' + problem_file = os.path.join(os.path.dirname(__file__), "test_files/filename_convert_test.txt") + with open(problem_file) as fp: + answers_with_file = {'1_2_1': 'String-based answer', + '1_3_1': ['answer1', 'answer2', 'answer3'], + '1_4_1': [fp, fp]} + answers_converted = convert_files_to_filenames(answers_with_file) + self.assertEquals(answers_converted['1_2_1'], 'String-based answer') + self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) + self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) - def test_convert_files_to_filenames(self): - ''' - Test whether file objects are converted to filenames without altering other structures - ''' - problem_file = os.path.join(os.path.dirname(__file__), "test_files/coderesponse.xml") - with open(problem_file) as fp: - answers_with_file = {'1_2_1': 'String-based answer', - '1_3_1': ['answer1', 'answer2', 'answer3'], - '1_4_1': [fp, fp]} - answers_converted = convert_files_to_filenames(answers_with_file) - self.assertEquals(answers_converted['1_2_1'], 'String-based answer') - self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) - self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) +class ChoiceResponseTest(ResponseTest): + from response_xml_factory import ChoiceResponseXMLFactory + xml_factory_class = ChoiceResponseXMLFactory + + def test_radio_group_grade(self): + problem = self.build_problem(choice_type='radio', + choices=[False, True, False]) + + # Check that we get the expected results + self.assert_grade(problem, 'choice_0', 'incorrect') + self.assert_grade(problem, 'choice_1', 'correct') + self.assert_grade(problem, 'choice_2', 'incorrect') + + # No choice 3 exists --> mark incorrect + self.assert_grade(problem, 'choice_3', 'incorrect') -class ChoiceResponseTest(unittest.TestCase): + def test_checkbox_group_grade(self): + problem = self.build_problem(choice_type='checkbox', + choices=[False, True, True]) - def test_cr_rb_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/choiceresponse_radio.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_2', - '1_3_1': ['choice_2', 'choice_3']} - test_answers = {'1_2_1': 'choice_2', - '1_3_1': 'choice_2', - } - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect') + # Check that we get the expected results + # (correct if and only if BOTH correct choices chosen) + self.assert_grade(problem, ['choice_1', 'choice_2'], 'correct') + self.assert_grade(problem, 'choice_1', 'incorrect') + self.assert_grade(problem, 'choice_2', 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_1'], 'incorrect') + self.assert_grade(problem, ['choice_0', 'choice_2'], 'incorrect') - def test_cr_cb_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/choiceresponse_checkbox.xml" - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': 'choice_2', - '1_3_1': ['choice_2', 'choice_3'], - '1_4_1': ['choice_2', 'choice_3']} - test_answers = {'1_2_1': 'choice_2', - '1_3_1': 'choice_2', - '1_4_1': ['choice_2', 'choice_3'], - } - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_4_1'), 'correct') + # No choice 3 exists --> mark incorrect + self.assert_grade(problem, 'choice_3', 'incorrect') -class JavascriptResponseTest(unittest.TestCase): - def test_jr_grade(self): - problem_file = os.path.dirname(__file__) + "/test_files/javascriptresponse.xml" +class JavascriptResponseTest(ResponseTest): + from response_xml_factory import JavascriptResponseXMLFactory + xml_factory_class = JavascriptResponseXMLFactory + + def test_grade(self): + # Compile coffee files into javascript used by the response coffee_file_path = os.path.dirname(__file__) + "/test_files/js/*.coffee" os.system("coffee -c %s" % (coffee_file_path)) - test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=test_system) - correct_answers = {'1_2_1': json.dumps({0: 4})} - incorrect_answers = {'1_2_1': json.dumps({0: 5})} - self.assertEquals(test_lcp.grade_answers(incorrect_answers).get_correctness('1_2_1'), 'incorrect') - self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct') + problem = self.build_problem(generator_src="test_problem_generator.js", + grader_src="test_problem_grader.js", + display_class="TestProblemDisplay", + display_src="test_problem_display.js", + param_dict={'value': '4'}) + # Test that we get graded correctly + self.assert_grade(problem, json.dumps({0:4}), "correct") + self.assert_grade(problem, json.dumps({0:5}), "incorrect") + +class NumericalResponseTest(ResponseTest): + from response_xml_factory import NumericalResponseXMLFactory + xml_factory_class = NumericalResponseXMLFactory + + def test_grade_exact(self): + problem = self.build_problem(question_text="What is 2 + 2?", + explanation="The answer is 4", + answer=4) + correct_responses = ["4", "4.0", "4.00"] + incorrect_responses = ["", "3.9", "4.1", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + + def test_grade_decimal_tolerance(self): + problem = self.build_problem(question_text="What is 2 + 2 approximately?", + explanation="The answer is 4", + answer=4, + tolerance=0.1) + correct_responses = ["4.0", "4.00", "4.09", "3.91"] + incorrect_responses = ["", "4.11", "3.89", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_percent_tolerance(self): + problem = self.build_problem(question_text="What is 2 + 2 approximately?", + explanation="The answer is 4", + answer=4, + tolerance="10%") + correct_responses = ["4.0", "4.3", "3.7", "4.30", "3.70"] + incorrect_responses = ["", "4.5", "3.5", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_with_script(self): + script_text = "computed_response = math.sqrt(4)" + problem = self.build_problem(question_text="What is sqrt(4)?", + explanation="The answer is 2", + answer="$computed_response", + script=script_text) + correct_responses = ["2", "2.0"] + incorrect_responses = ["", "2.01", "1.99", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + def test_grade_with_script_and_tolerance(self): + script_text = "computed_response = math.sqrt(4)" + problem = self.build_problem(question_text="What is sqrt(4)?", + explanation="The answer is 2", + answer="$computed_response", + tolerance="0.1", + script=script_text) + correct_responses = ["2", "2.0", "2.05", "1.95"] + incorrect_responses = ["", "2.11", "1.89", "0"] + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + + +class CustomResponseTest(ResponseTest): + from response_xml_factory import CustomResponseXMLFactory + xml_factory_class = CustomResponseXMLFactory + + def test_inline_code(self): + + # For inline code, we directly modify global context variables + # 'answers' is a list of answers provided to us + # 'correct' is a list we fill in with True/False + # 'expect' is given to us (if provided in the XML) + inline_script = """correct[0] = 'correct' if (answers['1_2_1'] == expect) else 'incorrect'""" + problem = self.build_problem(answer=inline_script, expect="42") + + # Check results + self.assert_grade(problem, '42', 'correct') + self.assert_grade(problem, '0', 'incorrect') + + def test_inline_message(self): + + # Inline code can update the global messages list + # to pass messages to the CorrectMap for a particular input + inline_script = """messages[0] = "Test Message" """ + problem = self.build_problem(answer=inline_script) + + input_dict = {'1_2_1': '0'} + msg = problem.grade_answers(input_dict).get_msg('1_2_1') + self.assertEqual(msg, "Test Message") + + def test_function_code(self): + + # For function code, we pass in three arguments: + # + # 'expect' is the expect attribute of the + # + # 'answer_given' is the answer the student gave (if there is just one input) + # or an ordered list of answers (if there are multiple inputs) + # + # 'student_answers' is a dictionary of answers by input ID + # + # + # The function should return a dict of the form + # { 'ok': BOOL, 'msg': STRING } + # + script = """def check_func(expect, answer_given, student_answers): + return {'ok': answer_given == expect, 'msg': 'Message text'}""" + + problem = self.build_problem(script=script, cfn="check_func", expect="42") + + # Correct answer + input_dict = {'1_2_1': '42'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + msg = correct_map.get_msg('1_2_1') + + self.assertEqual(correctness, 'correct') + self.assertEqual(msg, "Message text\n") + + # Incorrect answer + input_dict = {'1_2_1': '0'} + correct_map = problem.grade_answers(input_dict) + + correctness = correct_map.get_correctness('1_2_1') + msg = correct_map.get_msg('1_2_1') + + self.assertEqual(correctness, 'incorrect') + self.assertEqual(msg, "Message text\n") + + def test_multiple_inputs(self): + # When given multiple inputs, the 'answer_given' argument + # to the check_func() is a list of inputs + # The sample script below marks the problem as correct + # if and only if it receives answer_given=[1,2,3] + # (or string values ['1','2','3']) + script = """def check_func(expect, answer_given, student_answers): + check1 = (int(answer_given[0]) == 1) + check2 = (int(answer_given[1]) == 2) + check3 = (int(answer_given[2]) == 3) + return {'ok': (check1 and check2 and check3), 'msg': 'Message text'}""" + + problem = self.build_problem(script=script, + cfn="check_func", num_inputs=3) + + # Grade the inputs (one input incorrect) + input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' } + correct_map = problem.grade_answers(input_dict) + + # Everything marked incorrect + self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect') + self.assertEqual(correct_map.get_correctness('1_2_2'), 'incorrect') + self.assertEqual(correct_map.get_correctness('1_2_3'), 'incorrect') + + # Grade the inputs (everything correct) + input_dict = {'1_2_1': '1', '1_2_2': '2', '1_2_3': '3' } + correct_map = problem.grade_answers(input_dict) + + # Everything marked incorrect + self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') + self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct') + self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct') + + +class SchematicResponseTest(ResponseTest): + from response_xml_factory import SchematicResponseXMLFactory + xml_factory_class = SchematicResponseXMLFactory + + def test_grade(self): + + # Most of the schematic-specific work is handled elsewhere + # (in client-side JavaScript) + # The is responsible only for executing the + # Python code in with *submission* (list) + # in the global context. + + # To test that the context is set up correctly, + # we create a script that sets *correct* to true + # if and only if we find the *submission* (list) + script="correct = ['correct' if 'test' in submission[0] else 'incorrect']" + problem = self.build_problem(answer=script) + + # The actual dictionary would contain schematic information + # sent from the JavaScript simulation + submission_dict = {'test': 'test'} + input_dict = { '1_2_1': json.dumps(submission_dict) } + correct_map = problem.grade_answers(input_dict) + + # Expect that the problem is graded as true + # (That is, our script verifies that the context + # is what we expect) + self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct') diff --git a/common/lib/capa/capa/util.py b/common/lib/capa/capa/util.py index 0df58c216f..a0f25c4947 100644 --- a/common/lib/capa/capa/util.py +++ b/common/lib/capa/capa/util.py @@ -51,15 +51,17 @@ def convert_files_to_filenames(answers): new_answers = dict() for answer_id in answers.keys(): answer = answers[answer_id] - if is_list_of_files(answer): # Files are stored as a list, even if one file + if is_list_of_files(answer): # Files are stored as a list, even if one file new_answers[answer_id] = [f.name for f in answer] else: new_answers[answer_id] = answers[answer_id] return new_answers + def is_list_of_files(files): return isinstance(files, list) and all(is_file(f) for f in files) + def is_file(file_to_test): ''' Duck typing to check if 'file_to_test' is a File object @@ -79,11 +81,10 @@ def find_with_default(node, path, default): Returns: node.find(path).text if the find succeeds, default otherwise. - + """ v = node.find(path) if v is not None: return v.text else: return default - diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/capa/capa/verifiers/draganddrop.py index eb91208923..239ff2b9a4 100644 --- a/common/lib/capa/capa/verifiers/draganddrop.py +++ b/common/lib/capa/capa/verifiers/draganddrop.py @@ -111,7 +111,7 @@ class DragAndDrop(object): Returns: bool. ''' for draggable in self.excess_draggables: - if not self.excess_draggables[draggable]: + if self.excess_draggables[draggable]: return False # user answer has more draggables than correct answer # Number of draggables in user_groups may be differ that in @@ -304,8 +304,13 @@ class DragAndDrop(object): user_answer = json.loads(user_answer) - # check if we have draggables that are not in correct answer: - self.excess_draggables = {} + # This dictionary will hold a key for each draggable the user placed on + # the image. The value is True if that draggable is not mentioned in any + # correct_answer entries. If the draggable is mentioned in at least one + # correct_answer entry, the value is False. + # default to consider every user answer excess until proven otherwise. + self.excess_draggables = dict((users_draggable.keys()[0],True) + for users_draggable in user_answer['draggables']) # create identical data structures from user answer and correct answer for i in xrange(0, len(correct_answer)): @@ -322,11 +327,8 @@ class DragAndDrop(object): self.user_groups[groupname].append(draggable_name) self.user_positions[groupname]['user'].append( draggable_dict[draggable_name]) - self.excess_draggables[draggable_name] = True - else: - self.excess_draggables[draggable_name] = \ - self.excess_draggables.get(draggable_name, False) - + # proved that this is not excess + self.excess_draggables[draggable_name] = False def grade(user_input, correct_answer): """ Creates DragAndDrop instance from user_input and correct_answer and diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/capa/capa/verifiers/tests_draganddrop.py index 9b1b15ce0c..bcd024fa89 100644 --- a/common/lib/capa/capa/verifiers/tests_draganddrop.py +++ b/common/lib/capa/capa/verifiers/tests_draganddrop.py @@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase): correct_answer = {'1': 't1', 'name_with_icon': 't2'} self.assertTrue(draganddrop.grade(user_input, correct_answer)) + def test_expect_no_actions_wrong(self): + user_input = '{"draggables": [{"1": "t1"}, \ + {"name_with_icon": "t2"}]}' + correct_answer = [] + self.assertFalse(draganddrop.grade(user_input, correct_answer)) + + def test_expect_no_actions_right(self): + user_input = '{"draggables": []}' + correct_answer = [] + self.assertTrue(draganddrop.grade(user_input, correct_answer)) + + def test_targets_false(self): user_input = '{"draggables": [{"1": "t1"}, \ {"name_with_icon": "t2"}]}' diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index 798867955b..8dbe2c84aa 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -10,6 +10,7 @@ import requests log = logging.getLogger('mitx.' + __name__) dateformat = '%Y%m%d%H%M%S' + def make_hashkey(seed): ''' Generate a string key by hashing @@ -29,9 +30,9 @@ def make_xheader(lms_callback_url, lms_key, queue_name): 'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string) } """ - return json.dumps({ 'lms_callback_url': lms_callback_url, + return json.dumps({'lms_callback_url': lms_callback_url, 'lms_key': lms_key, - 'queue_name': queue_name }) + 'queue_name': queue_name}) def parse_xreply(xreply): @@ -96,18 +97,18 @@ class XQueueInterface(object): def _login(self): - payload = { 'username': self.auth['username'], - 'password': self.auth['password'] } + payload = {'username': self.auth['username'], + 'password': self.auth['password']} return self._http_post(self.url + '/xqueue/login/', payload) def _send_to_queue(self, header, body, files_to_upload): payload = {'xqueue_header': header, - 'xqueue_body' : body} + 'xqueue_body': body} files = {} if files_to_upload is not None: for f in files_to_upload: - files.update({ f.name: f }) + files.update({f.name: f}) return self._http_post(self.url + '/xqueue/submit/', payload, files=files) diff --git a/common/lib/capa/setup.py b/common/lib/capa/setup.py index 15b3015930..9c724dec8b 100644 --- a/common/lib/capa/setup.py +++ b/common/lib/capa/setup.py @@ -4,5 +4,5 @@ setup( name="capa", version="0.1", packages=find_packages(exclude=["tests"]), - install_requires=['distribute', 'pyparsing'], + install_requires=['distribute==0.6.34', 'pyparsing==1.5.6'], ) diff --git a/common/lib/logsettings.py b/common/lib/logsettings.py index 664f7e9601..8fc2bb9db1 100644 --- a/common/lib/logsettings.py +++ b/common/lib/logsettings.py @@ -5,6 +5,7 @@ from logging.handlers import SysLogHandler LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + def get_logger_config(log_dir, logging_env="no_env", tracking_filename="tracking.log", @@ -13,7 +14,8 @@ def get_logger_config(log_dir, syslog_addr=None, debug=False, local_loglevel='INFO', - console_loglevel=None): + console_loglevel=None, + service_variant=None): """ @@ -38,14 +40,21 @@ def get_logger_config(log_dir, if console_loglevel is None or console_loglevel not in LOG_LEVELS: console_loglevel = 'DEBUG' if debug else 'INFO' + if service_variant is None: + # default to a blank string so that if SERVICE_VARIANT is not + # set we will not log to a sub directory + service_variant = '' + hostname = platform.node().split(".")[0] - syslog_format = ("[%(name)s][env:{logging_env}] %(levelname)s " + syslog_format = ("[service_variant={service_variant}]" + "[%(name)s][env:{logging_env}] %(levelname)s " "[{hostname} %(process)d] [%(filename)s:%(lineno)d] " - "- %(message)s").format( - logging_env=logging_env, hostname=hostname) + "- %(message)s").format(service_variant=service_variant, + logging_env=logging_env, + hostname=hostname) handlers = ['console', 'local'] if debug else ['console', - 'syslogger-remote', 'local'] + 'syslogger-remote', 'local'] logger_config = { 'version': 1, @@ -78,11 +87,6 @@ def get_logger_config(log_dir, } }, 'loggers': { - 'django': { - 'handlers': handlers, - 'propagate': True, - 'level': 'INFO' - }, 'tracking': { 'handlers': ['tracking'], 'level': 'DEBUG', @@ -93,16 +97,6 @@ def get_logger_config(log_dir, 'level': 'DEBUG', 'propagate': False }, - 'mitx': { - 'handlers': handlers, - 'level': 'DEBUG', - 'propagate': False - }, - 'keyedcache': { - 'handlers': handlers, - 'level': 'DEBUG', - 'propagate': False - }, } } @@ -128,6 +122,9 @@ def get_logger_config(log_dir, }, }) else: + # for production environments we will only + # log INFO and up + logger_config['loggers']['']['level'] = 'INFO' logger_config['handlers'].update({ 'local': { 'level': local_loglevel, diff --git a/common/lib/sample-post.py b/common/lib/sample-post.py new file mode 100644 index 0000000000..a4985689bf --- /dev/null +++ b/common/lib/sample-post.py @@ -0,0 +1,71 @@ +# A simple script demonstrating how to have an external program post problem +# responses to an edx server. +# +# ***** NOTE ***** +# This is not intended as a stable public API. In fact, it is almost certainly +# going to change. If you use this for some reason, be prepared to change your +# code. +# +# We will be working to define a stable public API for external programs. We +# don't have have one yet (Feb 2013). + + +import requests +import sys +import getpass + +def prompt(msg, default=None, safe=False): + d = ' [{0}]'.format(default) if default is not None else '' + prompt = 'Enter {msg}{default}: '.format(msg=msg, default=d) + if not safe: + print prompt + x = sys.stdin.readline().strip() + else: + x = getpass.getpass(prompt=prompt) + if x == '' and default is not None: + return default + return x + +server = 'https://www.edx.org' +course_id = 'HarvardX/PH207x/2012_Fall' +location = 'i4x://HarvardX/PH207x/problem/ex_practice_2' + +#server = prompt('Server (no trailing slash)', 'http://127.0.0.1:8000') +#course_id = prompt('Course id', 'MITx/7012x/2013_Spring') +#location = prompt('problem location', 'i4x://MITx/7012x/problem/example_upload_answer') +value = prompt('value to upload') + +username = prompt('username on server', 'victor@edx.org') +password = prompt('password', 'abc123', safe=True) + +print "get csrf cookie" +session = requests.session() +r = session.get(server + '/') +r.raise_for_status() + +# print session.cookies + +# for some reason, the server expects a header containing the csrf cookie, not just the +# cookie itself. +session.headers['X-CSRFToken'] = session.cookies['csrftoken'] +# for https, need a referer header +session.headers['Referer'] = server + '/' +login_url = '/'.join([server, 'login']) + +print "log in" +r = session.post(login_url, {'email': 'victor@edx.org', 'password': 'Secret!', 'remember': 'false'}) +#print "request headers: ", r.request.headers +#print "response headers: ", r.headers +r.raise_for_status() + +url = '/'.join([server, 'courses', course_id, 'modx', location, 'problem_check']) +data = {'input_{0}_2_1'.format(location.replace('/','-').replace(':','').replace('--','-')): value} +#data = {'input_i4x-MITx-7012x-problem-example_upload_answer_2_1': value} + +print "Posting to '{0}': {1}".format(url, data) + +r = session.post(url, data) +r.raise_for_status() + +print ("To see the uploaded answer, go to {server}/courses/{course_id}/jump_to/{location}" + .format(server=server, course_id=course_id, location=location)) diff --git a/common/lib/supertrace.py b/common/lib/supertrace.py index e17cd7a8ba..83dfa12031 100644 --- a/common/lib/supertrace.py +++ b/common/lib/supertrace.py @@ -3,7 +3,8 @@ A handy util to print a django-debug-screen-like stack trace with values of local variables. """ -import sys, traceback +import sys +import traceback from django.utils.encoding import smart_unicode @@ -48,5 +49,3 @@ def supertrace(max_len=160): print s except: print "" - - diff --git a/common/lib/tempdir.py b/common/lib/tempdir.py new file mode 100644 index 0000000000..0acd92ba33 --- /dev/null +++ b/common/lib/tempdir.py @@ -0,0 +1,17 @@ +"""Make temporary directories nicely.""" + +import atexit +import os.path +import shutil +import tempfile + +def mkdtemp_clean(suffix="", prefix="tmp", dir=None): + """Just like mkdtemp, but the directory will be deleted when the process ends.""" + the_dir = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir) + atexit.register(cleanup_tempdir, the_dir) + return the_dir + +def cleanup_tempdir(the_dir): + """Called on process exit to remove a temp directory.""" + if os.path.exists(the_dir): + shutil.rmtree(the_dir) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 29227c3188..ec369420cd 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -20,19 +20,24 @@ setup( "book = xmodule.backcompat_module:TranslateCustomTagDescriptor", "chapter = xmodule.seq_module:SequenceDescriptor", "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor", + "conditional = xmodule.conditional_module:ConditionalDescriptor", "course = xmodule.course_module:CourseDescriptor", "customtag = xmodule.template_module:CustomTagDescriptor", "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", "html = xmodule.html_module:HtmlDescriptor", "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", "error = xmodule.error_module:ErrorDescriptor", + "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", "problem = xmodule.capa_module:CapaDescriptor", "problemset = xmodule.seq_module:SequenceDescriptor", + "randomize = xmodule.randomize_module:RandomizeDescriptor", "section = xmodule.backcompat_module:SemanticSectionDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor", "video = xmodule.video_module:VideoDescriptor", + "videoalpha = xmodule.videoalpha_module:VideoAlphaDescriptor", "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", "videosequence = xmodule.seq_module:SequenceDescriptor", "discussion = xmodule.discussion_module:DiscussionDescriptor", @@ -40,7 +45,8 @@ setup( "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor", - "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor" - ] + "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", + "foldit = xmodule.foldit_module:FolditDescriptor", + ] } ) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 12456bc7d7..537d864127 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -51,7 +51,7 @@ class ABTestModule(XModule): def get_shared_state(self): return json.dumps({'group': self.group}) - + def get_child_descriptors(self): active_locations = set(self.definition['data']['group_content'][self.group]) return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations] @@ -171,7 +171,7 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): group_elem.append(etree.fromstring(child.export_to_xml(resource_fs))) return xml_object - - + + def has_dynamic_children(self): return True diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index f33da6e3a4..ade5cc6ef6 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -2,6 +2,7 @@ import cgi import datetime import dateutil import dateutil.parser +import hashlib import json import logging import traceback @@ -25,6 +26,24 @@ log = logging.getLogger("mitx.courseware") #----------------------------------------------------------------------------- TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') +# Generated this many different variants of problems with rerandomize=per_student +NUM_RANDOMIZATION_BINS = 20 + + +def randomization_bin(seed, problem_id): + """ + Pick a randomization bin for the problem given the user's seed and a problem id. + + We do this because we only want e.g. 20 randomizations of a problem to make analytics + interesting. To avoid having sets of students that always get the same problems, + we'll combine the system's per-student seed with the problem id in picking the bin. + """ + h = hashlib.sha1() + h.update(str(seed)) + h.update(str(problem_id)) + # get the first few digits of the hash, convert to an int, then mod. + return int(h.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS + def only_one(lst, default="", process=lambda x: x): """ @@ -138,13 +157,9 @@ class CapaModule(XModule): if self.rerandomize == 'never': self.seed = 1 - elif self.rerandomize == "per_student" and hasattr(self.system, 'id'): - # TODO: This line is badly broken: - # (1) We're passing student ID to xmodule. - # (2) There aren't bins of students. -- we only want 10 or 20 randomizations, and want to assign students - # to these bins, and may not want cohorts. So e.g. hash(your-id, problem_id) % num_bins. - # - analytics really needs small number of bins. - self.seed = system.id + elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): + # see comment on randomization_bin + self.seed = randomization_bin(system.seed, self.location.url) else: self.seed = None @@ -270,7 +285,7 @@ class CapaModule(XModule): # Next, generate a fresh LoncapaProblem self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - state=None, # Tabula rasa + state=None, # Tabula rasa seed=self.seed, system=self.system) # Prepend a scary warning to the student @@ -289,7 +304,7 @@ class CapaModule(XModule): html = warning try: html += self.lcp.get_html() - except Exception, err: # Couldn't do it. Give up + except Exception, err: # Couldn't do it. Give up log.exception(err) raise @@ -302,7 +317,7 @@ class CapaModule(XModule): # check button is context-specific. # Put a "Check" button if unlimited attempts or still some left - if self.max_attempts is None or self.attempts < self.max_attempts-1: + if self.max_attempts is None or self.attempts < self.max_attempts - 1: check_button = "Check" else: # Will be final check so let user know that @@ -318,6 +333,12 @@ class CapaModule(XModule): reset_button = False save_button = False + # If attempts=0 then show just check and reset buttons; this is for survey questions using capa + if self.max_attempts==0: + check_button = False + reset_button = True + save_button = True + # User submitted a problem, and hasn't reset. We don't want # more submissions. if self.lcp.done and self.rerandomize == "always": @@ -356,7 +377,7 @@ class CapaModule(XModule): id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
          " # now do the substitutions which are filesystem based, e.g. '/static/' prefixes - return self.system.replace_urls(html, self.metadata['data_dir'], course_namespace=self.location) + return self.system.replace_urls(html) def handle_ajax(self, dispatch, get): ''' @@ -389,38 +410,62 @@ class CapaModule(XModule): }) return json.dumps(d, cls=ComplexEncoder) + def is_past_due(self): + """ + Is it now past this problem's due date, including grace period? + """ + return (self.close_date is not None and + datetime.datetime.utcnow() > self.close_date) + def closed(self): ''' Is the student still allowed to submit answers? ''' if self.attempts == self.max_attempts: return True - if self.close_date is not None and datetime.datetime.utcnow() > self.close_date: + if self.is_past_due(): return True return False + def is_completed(self): + # used by conditional module + # return self.answer_available() + return self.lcp.done + + def is_attempted(self): + # used by conditional module + return self.attempts > 0 + + def is_correct(self): + """True if full points""" + d = self.get_score() + return d['score'] == d['total'] + def answer_available(self): - ''' Is the user allowed to see an answer? + ''' + Is the user allowed to see an answer? ''' if self.show_answer == '': return False - - if self.show_answer == "never": + elif self.show_answer == "never": return False - - # Admins can see the answer, unless the problem explicitly prevents it - if self.system.user_is_staff: + elif self.system.user_is_staff: + # This is after the 'never' check because admins can see the answer + # unless the problem explicitly prevents it return True - - if self.show_answer == 'attempted': + elif self.show_answer == 'attempted': return self.attempts > 0 - - if self.show_answer == 'answered': + elif self.show_answer == 'answered': + # NOTE: this is slightly different from 'attempted' -- resetting the problems + # makes lcp.done False, but leaves attempts unchanged. return self.lcp.done - - if self.show_answer == 'closed': + elif self.show_answer == 'closed': return self.closed() + elif self.show_answer == 'finished': + return self.closed() or self.is_correct() - if self.show_answer == 'always': + elif self.show_answer == 'past_due': + return self.is_past_due() + elif self.show_answer == 'always': return True return False @@ -461,7 +506,7 @@ class CapaModule(XModule): new_answers = dict() for answer_id in answers: try: - new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)} + new_answer = {answer_id: self.system.replace_urls(answers[answer_id])} except TypeError: log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) new_answer = {answer_id: answers[answer_id]} @@ -532,9 +577,9 @@ class CapaModule(XModule): current_time = datetime.datetime.now() prev_submit_time = self.lcp.get_recentmost_queuetime() waittime_between_requests = self.system.xqueue['waittime'] - if (current_time-prev_submit_time).total_seconds() < waittime_between_requests: + if (current_time - prev_submit_time).total_seconds() < waittime_between_requests: msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests - return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback try: old_state = self.lcp.get_state() @@ -567,7 +612,7 @@ class CapaModule(XModule): event_info['attempts'] = self.attempts self.system.track_function('save_problem_check', event_info) - if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback + if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback self.system.psychometrics_handler(self.get_instance_state()) # render problem into HTML @@ -591,11 +636,11 @@ class CapaModule(XModule): event_info['answers'] = answers # Too late. Cannot submit - if self.closed(): + if self.closed() and not self.max_attempts==0: event_info['failure'] = 'closed' self.system.track_function('save_problem_fail', event_info) return {'success': False, - 'error': "Problem is closed"} + 'msg': "Problem is closed"} # Problem submitted. Student should reset before saving # again. @@ -603,13 +648,16 @@ class CapaModule(XModule): event_info['failure'] = 'done' self.system.track_function('save_problem_fail', event_info) return {'success': False, - 'error': "Problem needs to be reset prior to save."} + 'msg': "Problem needs to be reset prior to save"} self.lcp.student_answers = answers - # TODO: should this be save_problem_fail? Looks like success to me... - self.system.track_function('save_problem_fail', event_info) - return {'success': True} + self.system.track_function('save_problem_success', event_info) + msg = "Your answers have been saved" + if not self.max_attempts==0: + msg += " but not graded. Hit 'Check' to grade them." + return {'success': True, + 'msg': msg} def reset_problem(self, get): ''' Changes problem state to unfinished -- removes student answers, @@ -669,18 +717,18 @@ class CapaDescriptor(RawDescriptor): # TODO (vshnayder): do problems have any other metadata? Do they # actually use type and points? metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points') - + def get_context(self): _context = RawDescriptor.get_context(self) - _context.update({'markdown': self.metadata.get('markdown', '')}) + _context.update({'markdown': self.metadata.get('markdown', ''), + 'enable_markdown' : 'markdown' in self.metadata}) return _context - + @property def editable_metadata_fields(self): - """Remove metadata from the editable fields since it has its own editor""" - subset = super(CapaDescriptor,self).editable_metadata_fields - if 'markdown' in subset: - subset.remove('markdown') + """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user.""" + subset = [field for field in super(CapaDescriptor,self).editable_metadata_fields + if field not in ['markdown', 'empty']] return subset diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index aa4b1f18ad..659590b5b4 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -1,39 +1,22 @@ -import copy -from fs.errors import ResourceNotFoundError -import itertools import json import logging from lxml import etree -from lxml.html import rewrite_links -from path import path -import os -import sys from pkg_resources import resource_string -from .capa_module import only_one, ComplexEncoder -from .editing_module import EditingDescriptor -from .html_checker import check_html -from progress import Progress -from .stringify import stringify_children +from xmodule.raw_module import RawDescriptor from .x_module import XModule -from .xml_module import XmlDescriptor -from xmodule.modulestore import Location -import self_assessment_module -import open_ended_module -from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError -from .stringify import stringify_children +from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor log = logging.getLogger("mitx.courseware") -# Set the default number of max attempts. Should be 1 for production -# Set higher for debugging/testing -# attempts specified in xml definition overrides this. -MAX_ATTEMPTS = 10000 -# Set maximum available number of points. -# Overriden by max_score specified in xml. -MAX_SCORE = 1 +VERSION_TUPLES = ( + ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module), +) + +DEFAULT_VERSION = 1 +DEFAULT_VERSION = str(DEFAULT_VERSION) class CombinedOpenEndedModule(XModule): """ @@ -114,445 +97,78 @@ class CombinedOpenEndedModule(XModule): """ + self.system = system + self.system.set('location', location) + # Load instance state if instance_state is not None: instance_state = json.loads(instance_state) else: instance_state = {} - #We need to set the location here so the child modules can use it - system.set('location', location) + self.version = self.metadata.get('version', DEFAULT_VERSION) + version_error_string = "Version of combined open ended module {0} is not correct. Going with version {1}" + if not isinstance(self.version, basestring): + try: + self.version = str(self.version) + except: + #This is a dev_facing_error + log.info(version_error_string.format(self.version, DEFAULT_VERSION)) + self.version = DEFAULT_VERSION - #Tells the system which xml definition to load - self.current_task_number = instance_state.get('current_task_number', 0) - #This loads the states of the individual children - self.task_states = instance_state.get('task_states', []) - #Overall state of the combined open ended module - self.state = instance_state.get('state', self.INITIAL) + versions = [i[0] for i in VERSION_TUPLES] + descriptors = [i[1] for i in VERSION_TUPLES] + modules = [i[2] for i in VERSION_TUPLES] - self.attempts = instance_state.get('attempts', 0) - - #Allow reset is true if student has failed the criteria to move to the next child task - self.allow_reset = instance_state.get('ready_to_reset', False) - self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) - - # Used for progress / grading. Currently get credit just for - # completion (doesn't matter if you self-assessed correct/incorrect). - self._max_score = int(self.metadata.get('max_score', MAX_SCORE)) - - rubric_renderer = CombinedOpenEndedRubric(system, True) try: - rubric_feedback = rubric_renderer.render_rubric(stringify_children(definition['rubric'])) - except RubricParsingError: - log.error("Failed to parse rubric in location: {1}".format(location)) - raise - #Static data is passed to the child modules to render - self.static_data = { - 'max_score': self._max_score, - 'max_attempts': self.max_attempts, - 'prompt': definition['prompt'], - 'rubric': definition['rubric'], - 'display_name': self.display_name + version_index = versions.index(self.version) + except: + #This is a dev_facing_error + log.error(version_error_string.format(self.version, DEFAULT_VERSION)) + self.version = DEFAULT_VERSION + version_index = versions.index(self.version) + + static_data = { + 'rewrite_content_links' : self.rewrite_content_links, } - self.task_xml = definition['task_xml'] - self.setup_next_task() - - def get_tag_name(self, xml): - """ - Gets the tag name of a given xml block. - Input: XML string - Output: The name of the root tag - """ - tag = etree.fromstring(xml).tag - return tag - - def overwrite_state(self, current_task_state): - """ - Overwrites an instance state and sets the latest response to the current response. This is used - to ensure that the student response is carried over from the first child to the rest. - Input: Task state json string - Output: Task state json string - """ - last_response_data = self.get_last_response(self.current_task_number - 1) - last_response = last_response_data['response'] - - loaded_task_state = json.loads(current_task_state) - if loaded_task_state['state'] == self.INITIAL: - loaded_task_state['state'] = self.ASSESSING - loaded_task_state['created'] = True - loaded_task_state['history'].append({'answer': last_response}) - current_task_state = json.dumps(loaded_task_state) - return current_task_state - - def child_modules(self): - """ - Returns the constructors associated with the child modules in a dictionary. This makes writing functions - simpler (saves code duplication) - Input: None - Output: A dictionary of dictionaries containing the descriptor functions and module functions - """ - child_modules = { - 'openended': open_ended_module.OpenEndedModule, - 'selfassessment': self_assessment_module.SelfAssessmentModule, - } - child_descriptors = { - 'openended': open_ended_module.OpenEndedDescriptor, - 'selfassessment': self_assessment_module.SelfAssessmentDescriptor, - } - children = { - 'modules': child_modules, - 'descriptors': child_descriptors, - } - return children - - def setup_next_task(self, reset=False): - """ - Sets up the next task for the module. Creates an instance state if none exists, carries over the answer - from the last instance state to the next if needed. - Input: A boolean indicating whether or not the reset function is calling. - Output: Boolean True (not useful right now) - """ - current_task_state = None - if len(self.task_states) > self.current_task_number: - current_task_state = self.task_states[self.current_task_number] - - self.current_task_xml = self.task_xml[self.current_task_number] - - if self.current_task_number > 0: - self.allow_reset = self.check_allow_reset() - if self.allow_reset: - self.current_task_number = self.current_task_number - 1 - - current_task_type = self.get_tag_name(self.current_task_xml) - - children = self.child_modules() - child_task_module = children['modules'][current_task_type] - - self.current_task_descriptor = children['descriptors'][current_task_type](self.system) - - #This is the xml object created from the xml definition of the current task - etree_xml = etree.fromstring(self.current_task_xml) - - #This sends the etree_xml object through the descriptor module of the current task, and - #returns the xml parsed by the descriptor - self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system) - if current_task_state is None and self.current_task_number == 0: - self.current_task = child_task_module(self.system, self.location, - self.current_task_parsed_xml, self.current_task_descriptor, self.static_data) - self.task_states.append(self.current_task.get_instance_state()) - self.state = self.ASSESSING - elif current_task_state is None and self.current_task_number > 0: - last_response_data = self.get_last_response(self.current_task_number - 1) - last_response = last_response_data['response'] - current_task_state=json.dumps({ - 'state' : self.ASSESSING, - 'version' : self.STATE_VERSION, - 'max_score' : self._max_score, - 'attempts' : 0, - 'created' : True, - 'history' : [{'answer' : str(last_response)}], - }) - self.current_task = child_task_module(self.system, self.location, - self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=current_task_state) - self.task_states.append(self.current_task.get_instance_state()) - self.state = self.ASSESSING - else: - if self.current_task_number > 0 and not reset: - current_task_state = self.overwrite_state(current_task_state) - self.current_task = child_task_module(self.system, self.location, - self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=current_task_state) - - log.debug(current_task_state) - return True - - def check_allow_reset(self): - """ - Checks to see if the student has passed the criteria to move to the next module. If not, sets - allow_reset to true and halts the student progress through the tasks. - Input: None - Output: the allow_reset attribute of the current module. - """ - if not self.allow_reset: - if self.current_task_number > 0: - last_response_data = self.get_last_response(self.current_task_number - 1) - current_response_data = self.get_current_attributes(self.current_task_number) - - if(current_response_data['min_score_to_attempt'] > last_response_data['score'] - or current_response_data['max_score_to_attempt'] < last_response_data['score']): - self.state = self.DONE - self.allow_reset = True - - return self.allow_reset - - def get_context(self): - """ - Generates a context dictionary that is used to render html. - Input: None - Output: A dictionary that can be rendered into the combined open ended template. - """ - task_html = self.get_html_base() - #set context variables and render template - - context = { - 'items': [{'content': task_html}], - 'ajax_url': self.system.ajax_url, - 'allow_reset': self.allow_reset, - 'state': self.state, - 'task_count': len(self.task_xml), - 'task_number': self.current_task_number + 1, - 'status': self.get_status(), - 'display_name': self.display_name - } - - return context + self.child_descriptor = descriptors[version_index](self.system) + self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']), self.system) + self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor, + instance_state = json.dumps(instance_state), metadata = self.metadata, static_data= static_data) def get_html(self): - """ - Gets HTML for rendering. - Input: None - Output: rendered html - """ - context = self.get_context() - html = self.system.render_template('combined_open_ended.html', context) - return html - - def get_html_nonsystem(self): - """ - Gets HTML for rendering via AJAX. Does not use system, because system contains some additional - html, which is not appropriate for returning via ajax calls. - Input: None - Output: HTML rendered directly via Mako - """ - context = self.get_context() - html = self.system.render_template('combined_open_ended.html', context) - return html - - def get_html_base(self): - """ - Gets the HTML associated with the current child task - Input: None - Output: Child task HTML - """ - self.update_task_states() - html = self.current_task.get_html(self.system) - return_html = rewrite_links(html, self.rewrite_content_links) - return return_html - - def get_current_attributes(self, task_number): - """ - Gets the min and max score to attempt attributes of the specified task. - Input: The number of the task. - Output: The minimum and maximum scores needed to move on to the specified task. - """ - task_xml = self.task_xml[task_number] - etree_xml = etree.fromstring(task_xml) - min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0)) - max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score)) - return {'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt} - - def get_last_response(self, task_number): - """ - Returns data associated with the specified task number, such as the last response, score, etc. - Input: The number of the task. - Output: A dictionary that contains information about the specified task. - """ - last_response = "" - task_state = self.task_states[task_number] - task_xml = self.task_xml[task_number] - task_type = self.get_tag_name(task_xml) - - children = self.child_modules() - - task_descriptor = children['descriptors'][task_type](self.system) - etree_xml = etree.fromstring(task_xml) - - min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0)) - max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score)) - - task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system) - task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor, - self.static_data, instance_state=task_state) - last_response = task.latest_answer() - last_score = task.latest_score() - last_post_assessment = task.latest_post_assessment(self.system) - last_post_feedback = "" - if task_type == "openended": - last_post_assessment = task.latest_post_assessment(self.system, short_feedback=False, join_feedback=False) - if isinstance(last_post_assessment, list): - eval_list = [] - for i in xrange(0, len(last_post_assessment)): - eval_list.append(task.format_feedback_with_evaluation(self.system, last_post_assessment[i])) - last_post_evaluation = "".join(eval_list) - else: - last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment) - last_post_assessment = last_post_evaluation - last_correctness = task.is_last_response_correct() - max_score = task.max_score() - state = task.state - last_response_dict = { - 'response': last_response, - 'score': last_score, - 'post_assessment': last_post_assessment, - 'type': task_type, - 'max_score': max_score, - 'state': state, - 'human_state': task.HUMAN_NAMES[state], - 'correct': last_correctness, - 'min_score_to_attempt': min_score_to_attempt, - 'max_score_to_attempt': max_score_to_attempt, - } - - return last_response_dict - - def update_task_states(self): - """ - Updates the task state of the combined open ended module with the task state of the current child module. - Input: None - Output: boolean indicating whether or not the task state changed. - """ - changed = False - if not self.allow_reset: - self.task_states[self.current_task_number] = self.current_task.get_instance_state() - current_task_state = json.loads(self.task_states[self.current_task_number]) - if current_task_state['state'] == self.DONE: - self.current_task_number += 1 - if self.current_task_number >= (len(self.task_xml)): - self.state = self.DONE - self.current_task_number = len(self.task_xml) - 1 - else: - self.state = self.INITIAL - changed = True - self.setup_next_task() - return changed - - def update_task_states_ajax(self, return_html): - """ - Runs the update task states function for ajax calls. Currently the same as update_task_states - Input: The html returned by the handle_ajax function of the child - Output: New html that should be rendered - """ - changed = self.update_task_states() - if changed: - #return_html=self.get_html() - pass - return return_html - - def get_results(self, get): - """ - Gets the results of a given grader via ajax. - Input: AJAX get dictionary - Output: Dictionary to be rendered via ajax that contains the result html. - """ - task_number = int(get['task_number']) - self.update_task_states() - response_dict = self.get_last_response(task_number) - context = {'results': response_dict['post_assessment'], 'task_number': task_number + 1} - html = self.system.render_template('combined_open_ended_results.html', context) - return {'html': html, 'success': True} + return self.child_module.get_html() def handle_ajax(self, dispatch, get): - """ - This is called by courseware.module_render, to handle an AJAX call. - "get" is request.POST. - - Returns a json dictionary: - { 'progress_changed' : True/False, - 'progress': 'none'/'in_progress'/'done', - } - """ - - handlers = { - 'next_problem': self.next_problem, - 'reset': self.reset, - 'get_results': self.get_results - } - - if dispatch not in handlers: - return_html = self.current_task.handle_ajax(dispatch, get, self.system) - return self.update_task_states_ajax(return_html) - - d = handlers[dispatch](get) - return json.dumps(d, cls=ComplexEncoder) - - def next_problem(self, get): - """ - Called via ajax to advance to the next problem. - Input: AJAX get request. - Output: Dictionary to be rendered - """ - self.update_task_states() - return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset} - - def reset(self, get): - """ - If resetting is allowed, reset the state of the combined open ended module. - Input: AJAX get dictionary - Output: AJAX dictionary to tbe rendered - """ - if self.state != self.DONE: - if not self.allow_reset: - return self.out_of_sync_error(get) - - if self.attempts > self.max_attempts: - return { - 'success': False, - 'error': 'Too many attempts.' - } - self.state = self.INITIAL - self.allow_reset = False - for i in xrange(0, len(self.task_xml)): - self.current_task_number = i - self.setup_next_task(reset=True) - self.current_task.reset(self.system) - self.task_states[self.current_task_number] = self.current_task.get_instance_state() - self.current_task_number = 0 - self.allow_reset = False - self.setup_next_task() - return {'success': True, 'html': self.get_html_nonsystem()} + return self.child_module.handle_ajax(dispatch, get) def get_instance_state(self): - """ - Returns the current instance state. The module can be recreated from the instance state. - Input: None - Output: A dictionary containing the instance state. - """ + return self.child_module.get_instance_state() - state = { - 'version': self.STATE_VERSION, - 'current_task_number': self.current_task_number, - 'state': self.state, - 'task_states': self.task_states, - 'attempts': self.attempts, - 'ready_to_reset': self.allow_reset, - } + def get_score(self): + return self.child_module.get_score() - return json.dumps(state) + def max_score(self): + return self.child_module.max_score() - def get_status(self): - """ - Gets the status panel to be displayed at the top right. - Input: None - Output: The status html to be rendered - """ - status = [] - for i in xrange(0, self.current_task_number + 1): - task_data = self.get_last_response(i) - task_data.update({'task_number': i + 1}) - status.append(task_data) - context = {'status_list': status} - status_html = self.system.render_template("combined_open_ended_status.html", context) + def get_progress(self): + return self.child_module.get_progress() - return status_html + @property + def due_date(self): + return self.child_module.due_date + + @property + def display_name(self): + return self.child_module.display_name -class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): +class CombinedOpenEndedDescriptor(RawDescriptor): """ Module for adding combined open ended questions """ - mako_template = "widgets/html-edit.html" + mako_template = "widgets/raw-edit.html" module_class = CombinedOpenEndedModule filename_extension = "xml" @@ -560,47 +176,3 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): has_score = True template_dir_name = "combinedopenended" - js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} - js_module_name = "HTMLEditingDescriptor" - - @classmethod - def definition_from_xml(cls, xml_object, system): - """ - Pull out the individual tasks, the rubric, and the prompt, and parse - - Returns: - { - 'rubric': 'some-html', - 'prompt': 'some-html', - 'task_xml': dictionary of xml strings, - } - """ - expected_children = ['task', 'rubric', 'prompt'] - for child in expected_children: - if len(xml_object.xpath(child)) == 0: - raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child)) - - def parse_task(k): - """Assumes that xml_object has child k""" - return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))] - - def parse(k): - """Assumes that xml_object has child k""" - return xml_object.xpath(k)[0] - - return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')} - - - def definition_to_xml(self, resource_fs): - '''Return an xml element representing this definition.''' - elt = etree.Element('combinedopenended') - - def add_child(k): - child_str = '<{tag}>{body}'.format(tag=k, body=self.definition[k]) - child_node = etree.fromstring(child_str) - elt.append(child_node) - - for child in ['task']: - add_child(child) - - return elt diff --git a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/combined_open_ended_rubric.py deleted file mode 100644 index 4380e32d5b..0000000000 --- a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -from lxml import etree - -log=logging.getLogger(__name__) - -class RubricParsingError(Exception): - pass - -class CombinedOpenEndedRubric(object): - - def __init__ (self, system, view_only = False): - self.has_score = False - self.view_only = view_only - self.system = system - - def render_rubric(self, rubric_xml): - ''' - render_rubric: takes in an xml string and outputs the corresponding - html for that xml, given the type of rubric we're generating - Input: - rubric_xml: an string that has not been parsed into xml that - represents this particular rubric - Output: - html: the html that corresponds to the xml given - ''' - try: - rubric_categories = self.extract_categories(rubric_xml) - html = self.system.render_template('open_ended_rubric.html', - {'categories' : rubric_categories, - 'has_score': self.has_score, - 'view_only': self.view_only}) - except: - raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml)) - return html - - def extract_categories(self, element): - ''' - Contstruct a list of categories such that the structure looks like: - [ { category: "Category 1 Name", - options: [{text: "Option 1 Name", points: 0}, {text:"Option 2 Name", points: 5}] - }, - { category: "Category 2 Name", - options: [{text: "Option 1 Name", points: 0}, - {text: "Option 2 Name", points: 1}, - {text: "Option 3 Name", points: 2]}] - - ''' - if isinstance(element, basestring): - element = etree.fromstring(element) - categories = [] - for category in element: - if category.tag != 'category': - raise RubricParsingError("[extract_categories] Expected a tag: got {0} instead".format(category.tag)) - else: - categories.append(self.extract_category(category)) - return categories - - - def extract_category(self, category): - ''' - construct an individual category - {category: "Category 1 Name", - options: [{text: "Option 1 text", points: 1}, - {text: "Option 2 text", points: 2}]} - - all sorting and auto-point generation occurs in this function - ''' - descriptionxml = category[0] - optionsxml = category[1:] - scorexml = category[1] - score = None - if scorexml.tag == 'score': - score_text = scorexml.text - optionsxml = category[2:] - score = int(score_text) - self.has_score = True - # if we are missing the score tag and we are expecting one - elif self.has_score: - raise RubricParsingError("[extract_category] Category {0} is missing a score".format(descriptionxml.text)) - - - # parse description - if descriptionxml.tag != 'description': - raise RubricParsingError("[extract_category]: expected description tag, got {0} instead".format(descriptionxml.tag)) - - description = descriptionxml.text - - cur_points = 0 - options = [] - autonumbering = True - # parse options - for option in optionsxml: - if option.tag != 'option': - raise RubricParsingError("[extract_category]: expected option tag, got {0} instead".format(option.tag)) - else: - pointstr = option.get("points") - if pointstr: - autonumbering = False - # try to parse this into an int - try: - points = int(pointstr) - except ValueError: - raise RubricParsingError("[extract_category]: expected points to have int, got {0} instead".format(pointstr)) - elif autonumbering: - # use the generated one if we're in the right mode - points = cur_points - cur_points = cur_points + 1 - else: - raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly defined.") - - selected = score == points - optiontext = option.text - options.append({'text': option.text, 'points': points, 'selected': selected}) - - # sort and check for duplicates - options = sorted(options, key=lambda option: option['points']) - CombinedOpenEndedRubric.validate_options(options) - - return {'description': description, 'options': options} - - - @staticmethod - def validate_options(options): - ''' - Validates a set of options. This can and should be extended to filter out other bad edge cases - ''' - if len(options) == 0: - raise RubricParsingError("[extract_category]: no options associated with this category") - if len(options) == 1: - return - prev = options[0]['points'] - for option in options[1:]: - if prev == option['points']: - raise RubricParsingError("[extract_category]: found duplicate point values between two different options") - else: - prev = option['points'] diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py new file mode 100644 index 0000000000..787d355c4a --- /dev/null +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -0,0 +1,153 @@ +import json +import logging + +from xmodule.x_module import XModule +from xmodule.modulestore import Location +from xmodule.seq_module import SequenceDescriptor + +from pkg_resources import resource_string + +log = logging.getLogger('mitx.' + __name__) + + +class ConditionalModule(XModule): + ''' + Blocks child module from showing unless certain conditions are met. + + Example: + + + + + + + + ''' + + js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'), + resource_string(__name__, 'js/src/collapsible.coffee'), + resource_string(__name__, 'js/src/javascript_loader.coffee'), + ]} + + js_module_name = "Conditional" + css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} + + + def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): + """ + In addition to the normal XModule init, provide: + + self.condition = string describing condition required + + """ + XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + self.contents = None + self.condition = self.metadata.get('condition', '') + self._get_required_modules() + children = self.get_display_items() + if children: + self.icon_class = children[0].get_icon_class() + #log.debug('conditional module required=%s' % self.required_modules_list) + + def _get_required_modules(self): + self.required_modules = [] + for descriptor in self.descriptor.get_required_module_descriptors(): + module = self.system.get_module(descriptor) + self.required_modules.append(module) + #log.debug('required_modules=%s' % (self.required_modules)) + + def is_condition_satisfied(self): + self._get_required_modules() + + if self.condition == 'require_completed': + # all required modules must be completed, as determined by + # the modules .is_completed() method + for module in self.required_modules: + #log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers) + #log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state) + if not hasattr(module, 'is_completed'): + raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module) + if not module.is_completed(): + log.debug('conditional module: %s not completed' % module) + return False + else: + log.debug('conditional module: %s IS completed' % module) + return True + elif self.condition == 'require_attempted': + # all required modules must be attempted, as determined by + # the modules .is_attempted() method + for module in self.required_modules: + if not hasattr(module, 'is_attempted'): + raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module) + if not module.is_attempted(): + log.debug('conditional module: %s not attempted' % module) + return False + else: + log.debug('conditional module: %s IS attempted' % module) + return True + else: + raise Exception('Error in conditional module: unknown condition "%s"' % self.condition) + + return True + + def get_html(self): + self.is_condition_satisfied() + return self.system.render_template('conditional_ajax.html', { + 'element_id': self.location.html_id(), + 'id': self.id, + 'ajax_url': self.system.ajax_url, + }) + + def handle_ajax(self, dispatch, post): + ''' + This is called by courseware.module_render, to handle an AJAX call. + ''' + #log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch) + + if not self.is_condition_satisfied(): + context = {'module': self} + html = self.system.render_template('conditional_module.html', context) + return json.dumps({'html': html}) + + if self.contents is None: + self.contents = [child.get_html() for child in self.get_display_items()] + + # for now, just deal with one child + html = self.contents[0] + + return json.dumps({'html': html}) + + +class ConditionalDescriptor(SequenceDescriptor): + module_class = ConditionalModule + + filename_extension = "xml" + + stores_state = True + has_score = False + + def __init__(self, *args, **kwargs): + super(ConditionalDescriptor, self).__init__(*args, **kwargs) + + required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')] + self.required_module_locations = [] + for rm in required_module_list: + try: + (tag, name) = rm + except Exception as err: + msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required') + log.warning(msg) + self.system.error_tracker(msg) + continue + loc = self.location.dict() + loc['category'] = tag + loc['name'] = name + self.required_module_locations.append(Location(loc)) + log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations) + + def get_required_module_descriptors(self): + """Returns a list of XModuleDescritpor instances upon which this module depends, but are + not children of this module""" + return [self.system.load_item(loc) for loc in self.required_module_locations] diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index 5b10acc0ef..be33401bc8 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -11,15 +11,16 @@ from xmodule.modulestore import Location from .django import contentstore from PIL import Image + class StaticContent(object): def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None): self.location = loc - self.name = name #a display string which can be edited, and thus not part of the location which needs to be fixed + self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed self.content_type = content_type self.data = data self.last_modified_at = last_modified_at self.thumbnail_location = Location(thumbnail_location) if thumbnail_location is not None else None - # optional information about where this file was imported from. This is needed to support import/export + # optional information about where this file was imported from. This is needed to support import/export # cycles self.import_path = import_path @@ -29,7 +30,7 @@ class StaticContent(object): @staticmethod def generate_thumbnail_name(original_name): - return ('{0}'+XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0]) + return ('{0}' + XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0]) @staticmethod def compute_location(org, course, name, revision=None, is_thumbnail=False): @@ -41,7 +42,7 @@ class StaticContent(object): def get_url_path(self): return StaticContent.get_url_path_from_location(self.location) - + @staticmethod def get_url_path_from_location(location): if location is not None: @@ -56,15 +57,15 @@ class StaticContent(object): @staticmethod def get_id_from_location(location): - return { 'tag':location.tag, 'org' : location.org, 'course' : location.course, - 'category' : location.category, 'name' : location.name, - 'revision' : location.revision} + return {'tag': location.tag, 'org': location.org, 'course': location.course, + 'category': location.category, 'name': location.name, + 'revision': location.revision} @staticmethod def get_location_from_path(path): # remove leading / character if it is there one if path.startswith('/'): path = path[1:] - + return Location(path.split('/')) @staticmethod @@ -77,7 +78,7 @@ class StaticContent(object): return StaticContent.get_url_path_from_location(loc) - + class ContentStore(object): ''' @@ -95,14 +96,14 @@ class ContentStore(object): [ - {u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374, - u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg', - u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x', - u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'}, + {u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374, + u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg', + u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x', + u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'}, - {u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073, - u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg', - u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x', + {u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073, + u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg', + u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x', u'org': u'MITx', u'revision': None}, u'md5': u'ff1532598830e3feac91c2449eaa60d6'}, .... @@ -117,7 +118,7 @@ class ContentStore(object): thumbnail_name = StaticContent.generate_thumbnail_name(content.location.name) thumbnail_file_location = StaticContent.compute_location(content.location.org, content.location.course, - thumbnail_name, is_thumbnail = True) + thumbnail_name, is_thumbnail=True) # if we're uploading an image, then let's generate a thumbnail so that we can # serve it up when needed without having to rescale on the fly @@ -129,7 +130,7 @@ class ContentStore(object): # @todo: move the thumbnail size to a configuration setting?!? im = Image.open(StringIO.StringIO(content.data)) - # I've seen some exceptions from the PIL library when trying to save palletted + # I've seen some exceptions from the PIL library when trying to save palletted # PNG files to JPEG. Per the google-universe, they suggest converting to RGB first. im = im.convert('RGB') size = 128, 128 @@ -139,7 +140,7 @@ class ContentStore(object): thumbnail_file.seek(0) # store this thumbnail as any other piece of content - thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name, + thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name, 'image/jpeg', thumbnail_file) contentstore().save(thumbnail_content) @@ -149,7 +150,3 @@ class ContentStore(object): logging.exception("Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e))) return thumbnail_content, thumbnail_file_location - - - - diff --git a/common/lib/xmodule/xmodule/contentstore/django.py b/common/lib/xmodule/xmodule/contentstore/django.py index d8b3084135..ec0397a348 100644 --- a/common/lib/xmodule/xmodule/contentstore/django.py +++ b/common/lib/xmodule/xmodule/contentstore/django.py @@ -6,6 +6,7 @@ from django.conf import settings _CONTENTSTORE = None + def load_function(path): """ Load a function by name. diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py index 01f189a9e4..68cc6d73d3 100644 --- a/common/lib/xmodule/xmodule/contentstore/mongo.py +++ b/common/lib/xmodule/xmodule/contentstore/mongo.py @@ -17,14 +17,14 @@ import os class MongoContentStore(ContentStore): def __init__(self, host, db, port=27017, user=None, password=None, **kwargs): - logging.debug( 'Using MongoDB for static content serving at host={0} db={1}'.format(host,db)) + logging.debug('Using MongoDB for static content serving at host={0} db={1}'.format(host, db)) _db = Connection(host=host, port=port, **kwargs)[db] if user is not None and password is not None: _db.authenticate(user, password) self.fs = gridfs.GridFS(_db) - self.fs_files = _db["fs.files"] # the underlying collection GridFS uses + self.fs_files = _db["fs.files"] # the underlying collection GridFS uses def save(self, content): @@ -33,24 +33,24 @@ class MongoContentStore(ContentStore): # Seems like with the GridFS we can't update existing ID's we have to do a delete/add pair self.delete(id) - with self.fs.new_file(_id = id, filename=content.get_url_path(), content_type=content.content_type, + with self.fs.new_file(_id=id, filename=content.get_url_path(), content_type=content.content_type, displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp: fp.write(content.data) - + return content - + def delete(self, id): - if self.fs.exists({"_id" : id}): + if self.fs.exists({"_id": id}): self.fs.delete(id) def find(self, location): id = StaticContent.get_id_from_location(location) try: with self.fs.get(id) as fp: - return StaticContent(location, fp.displayname, fp.content_type, fp.read(), - fp.uploadDate, thumbnail_location = fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None, - import_path = fp.import_path if hasattr(fp, 'import_path') else None) + return StaticContent(location, fp.displayname, fp.content_type, fp.read(), + fp.uploadDate, thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None, + import_path=fp.import_path if hasattr(fp, 'import_path') else None) except NoFile: raise NotFoundError() @@ -76,25 +76,25 @@ class MongoContentStore(ContentStore): self.export(asset_location, output_directory) def get_all_content_thumbnails_for_course(self, location): - return self._get_all_content_for_course(location, get_thumbnails = True) + return self._get_all_content_for_course(location, get_thumbnails=True) def get_all_content_for_course(self, location): - return self._get_all_content_for_course(location, get_thumbnails = False) + return self._get_all_content_for_course(location, get_thumbnails=False) - def _get_all_content_for_course(self, location, get_thumbnails = False): + def _get_all_content_for_course(self, location, get_thumbnails=False): ''' Returns a list of all static assets for a course. The return format is a list of dictionary elements. Example: [ - {u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374, - u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg', - u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x', - u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'}, + {u'displayname': u'profile.jpg', u'chunkSize': 262144, u'length': 85374, + u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 183000), u'contentType': u'image/jpeg', + u'_id': {u'category': u'asset', u'name': u'profile.jpg', u'course': u'6.002x', u'tag': u'c4x', + u'org': u'MITx', u'revision': None}, u'md5': u'36dc53519d4b735eb6beba51cd686a0e'}, - {u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073, - u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg', - u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x', + {u'displayname': u'profile.thumbnail.jpg', u'chunkSize': 262144, u'length': 4073, + u'uploadDate': datetime.datetime(2012, 10, 3, 5, 41, 54, 196000), u'contentType': u'image/jpeg', + u'_id': {u'category': u'asset', u'name': u'profile.thumbnail.jpg', u'course': u'6.002x', u'tag': u'c4x', u'org': u'MITx', u'revision': None}, u'md5': u'ff1532598830e3feac91c2449eaa60d6'}, .... @@ -102,10 +102,7 @@ class MongoContentStore(ContentStore): ] ''' course_filter = Location(XASSET_LOCATION_TAG, category="asset" if not get_thumbnails else "thumbnail", - course=location.course,org=location.org) + course=location.course, org=location.org) # 'borrow' the function 'location_to_query' from the Mongo modulestore implementation items = self.fs_files.find(location_to_query(course_filter)) return list(items) - - - diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 6e3e2cfa39..2ed780fcae 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -147,37 +147,37 @@ class CourseDescriptor(SequenceDescriptor): """ Return a dict which is a copy of the default grading policy """ - default = {"GRADER" : [ + default = {"GRADER": [ { - "type" : "Homework", - "min_count" : 12, - "drop_count" : 2, - "short_label" : "HW", - "weight" : 0.15 + "type": "Homework", + "min_count": 12, + "drop_count": 2, + "short_label": "HW", + "weight": 0.15 }, { - "type" : "Lab", - "min_count" : 12, - "drop_count" : 2, - "weight" : 0.15 + "type": "Lab", + "min_count": 12, + "drop_count": 2, + "weight": 0.15 }, { - "type" : "Midterm Exam", - "short_label" : "Midterm", - "min_count" : 1, - "drop_count" : 0, - "weight" : 0.3 + "type": "Midterm Exam", + "short_label": "Midterm", + "min_count": 1, + "drop_count": 0, + "weight": 0.3 }, { - "type" : "Final Exam", - "short_label" : "Final", - "min_count" : 1, - "drop_count" : 0, - "weight" : 0.4 + "type": "Final Exam", + "short_label": "Final", + "min_count": 1, + "drop_count": 0, + "weight": 0.4 } ], - "GRADE_CUTOFFS" : { - "Pass" : 0.5 + "GRADE_CUTOFFS": { + "Pass": 0.5 }} return copy.deepcopy(default) @@ -230,8 +230,8 @@ class CourseDescriptor(SequenceDescriptor): # bleh, have to parse the XML here to just pull out the url_name attribute # I don't think it's stored anywhere in the instance. - course_file = StringIO(xml_data.encode('ascii','ignore')) - xml_obj = etree.parse(course_file,parser=edx_xml_parser).getroot() + course_file = StringIO(xml_data.encode('ascii', 'ignore')) + xml_obj = etree.parse(course_file, parser=edx_xml_parser).getroot() policy_dir = None url_name = xml_obj.get('url_name', xml_obj.get('slug')) @@ -329,7 +329,7 @@ class CourseDescriptor(SequenceDescriptor): def raw_grader(self, value): # NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf self._grading_policy['RAW_GRADER'] = value - self.definition['data'].setdefault('grading_policy',{})['GRADER'] = value + self.definition['data'].setdefault('grading_policy', {})['GRADER'] = value @property def grade_cutoffs(self): @@ -338,7 +338,7 @@ class CourseDescriptor(SequenceDescriptor): @grade_cutoffs.setter def grade_cutoffs(self, value): self._grading_policy['GRADE_CUTOFFS'] = value - self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value + self.definition['data'].setdefault('grading_policy', {})['GRADE_CUTOFFS'] = value @property @@ -352,6 +352,13 @@ class CourseDescriptor(SequenceDescriptor): """ return self.metadata.get('tabs') + @property + def pdf_textbooks(self): + """ + Return the pdf_textbooks config, as a python object, or None if not specified. + """ + return self.metadata.get('pdf_textbooks') + @tabs.setter def tabs(self, value): self.metadata['tabs'] = value @@ -371,13 +378,35 @@ class CourseDescriptor(SequenceDescriptor): return bool(config.get("cohorted")) + @property + def auto_cohort(self): + """ + Return whether the course is auto-cohorted. + """ + if not self.is_cohorted: + return False + + return bool(self.metadata.get("cohort_config", {}).get( + "auto_cohort", False)) + + @property + def auto_cohort_groups(self): + """ + Return the list of groups to put students into. Returns [] if not + specified. Returns specified list even if is_cohorted and/or auto_cohort are + false. + """ + return self.metadata.get("cohort_config", {}).get( + "auto_cohort_groups", []) + + @property def top_level_discussion_topic_ids(self): """ Return list of topic ids defined in course policy. """ topics = self.metadata.get("discussion_topics", {}) - return [d["id"] for d in topics.values()] + return [d["id"] for d in topics.values()] @property @@ -436,17 +465,17 @@ class CourseDescriptor(SequenceDescriptor): scale = 300.0 # about a year if announcement: days = (now - announcement).days - score = -exp(-days/scale) + score = -exp(-days / scale) else: days = (now - start).days - score = exp(days/scale) + score = exp(days / scale) return score def _sorting_dates(self): # utility function to get datetime objects for dates used to # compute the is_new flag and the sorting_score def to_datetime(timestamp): - return datetime.fromtimestamp(time.mktime(timestamp)) + return datetime(*timestamp[:6]) def get_date(field): timetuple = self._try_parse_time(field) @@ -501,16 +530,16 @@ class CourseDescriptor(SequenceDescriptor): xmoduledescriptors.append(s) # The xmoduledescriptors included here are only the ones that have scores. - section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) } + section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)} section_format = s.metadata.get('format', "") - graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description] + graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description] all_descriptors.extend(xmoduledescriptors) all_descriptors.append(s) - return { 'graded_sections' : graded_sections, - 'all_descriptors' : all_descriptors,} + return {'graded_sections': graded_sections, + 'all_descriptors': all_descriptors, } @staticmethod @@ -636,7 +665,7 @@ class CourseDescriptor(SequenceDescriptor): # *end* of the same day, not the same time. It's going to be used as the # end of the exam overall, so we don't want the exam to disappear too soon. # It's also used optionally as the registration end date, so time matters there too. - self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date + self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date if self.last_eligible_appointment_date is None: raise ValueError("Last appointment date must be specified") self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0) @@ -648,7 +677,7 @@ class CourseDescriptor(SequenceDescriptor): raise ValueError("First appointment date must be before last appointment date") if self.registration_end_date > self.last_eligible_appointment_date: raise ValueError("Registration end date must be before last appointment date") - + self.exam_url = exam_info.get('Exam_URL') def _try_parse_time(self, key): """ @@ -704,6 +733,10 @@ class CourseDescriptor(SequenceDescriptor): else: return None + def get_test_center_exam(self, exam_series_code): + exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] + return exams[0] if len(exams) == 1 else None + @property def title(self): return self.display_name @@ -715,4 +748,3 @@ class CourseDescriptor(SequenceDescriptor): @property def org(self): return self.location.org - diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index d40bdb556e..b705f5146e 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -40,7 +40,7 @@ section.problem { @include clearfix; label.choicegroup_correct{ - text:after{ + &:after{ content: url('../images/correct-icon.png'); } } @@ -227,7 +227,7 @@ section.problem { background: url('../images/correct-icon.png') center center no-repeat; height: 20px; position: relative; - top: 6px; + top: 3px; width: 25px; } @@ -237,7 +237,7 @@ section.problem { height: 20px; width: 20px; position: relative; - top: 6px; + top: 3px; } } diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index a4045c9dad..20700ab092 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -24,14 +24,11 @@ section.combined-open-ended { @include clearfix; .status-container { - float:right; - width:40%; + padding-bottom: 5px; } .item-container { - float:left; - width: 53%; - padding-bottom: 50px; + padding-bottom: 10px; } .result-container @@ -46,14 +43,26 @@ section.combined-open-ended { } } +section.legend-container { + .legenditem { + background-color : #d4d4d4; + font-size: .9em; + padding: 2px; + display: inline; + width: 20%; + } + margin-bottom: 5px; +} + section.combined-open-ended-status { .statusitem { - background-color: #FAFAFA; color: #2C2C2C; - font-family: monospace; - font-size: 1em; - padding: 10px; + background-color : #d4d4d4; + font-size: .9em; + padding: 2px; + display: inline; + width: 20%; .show-results { margin-top: .3em; text-align:right; @@ -61,12 +70,12 @@ section.combined-open-ended-status { .show-results-button { font: 1em monospace; } - } + } .statusitem-current { - background-color: #d4d4d4; + background-color: #B2B2B2; color: #222; - } + } span { &.unanswered { @@ -98,8 +107,29 @@ section.combined-open-ended-status { } } -div.result-container { +div.combined-rubric-container { + ul.rubric-list{ + list-style-type: none; + padding:0; + margin:0; + li { + &.rubric-list-item{ + margin-bottom: 2px; + padding: 0px; + } + } + } + span.rubric-category { + font-size: .9em; + } + padding-bottom: 5px; + padding-top: 10px; +} + +div.result-container { + padding-top: 10px; + padding-bottom: 5px; .evaluation { p { @@ -113,9 +143,8 @@ div.result-container { } .evaluation-response { - margin-bottom: 10px; + margin-bottom: 2px; header { - text-align: right; a { font-size: .85em; } @@ -198,20 +227,6 @@ div.result-container { } } - .result-correct { - background: url('../images/correct-icon.png') left 20px no-repeat; - .result-actual-output { - color: #090; - } - } - - .result-incorrect { - background: url('../images/incorrect-icon.png') left 20px no-repeat; - .result-actual-output { - color: #B00; - } - } - .markup-text{ margin: 5px; padding: 20px 0px 15px 50px; @@ -229,49 +244,18 @@ div.result-container { } } } + .rubric-result-container { + .rubric-result { + font-size: .9em; + padding: 2px; + display: inline-table; + } + padding: 2px; + margin: 0px; + display : inline; + } } -div.result-container, section.open-ended-child { - .rubric { - margin-bottom:25px; - tr { - margin:10px 0px; - height: 100%; - } - td { - padding: 20px 0px 25px 0px; - margin: 10px 0px; - height: 100%; - } - th { - padding: 5px; - margin: 5px; - } - label, - .view-only { - margin:2px; - position: relative; - padding: 10px 15px 25px 15px; - width: 145px; - height:100%; - display: inline-block; - min-height: 50px; - min-width: 50px; - background-color: #CCC; - font-size: .85em; - } - .grade { - position: absolute; - bottom:0px; - right:0px; - margin:10px; - } - .selected-grade { - background: #666; - color: white; - } - } -} section.open-ended-child { @media print { @@ -442,6 +426,14 @@ section.open-ended-child { margin: 10px; } + div.short-form-response { + background: #F6F6F6; + border: 1px solid #ddd; + margin-bottom: 0px; + overflow-y: auto; + height: 200px; + @include clearfix; + } .grader-status { padding: 9px; @@ -511,6 +503,18 @@ section.open-ended-child { margin-left: .75rem; } + ul.rubric-list{ + list-style-type: none; + padding:0; + margin:0; + li { + &.rubric-list-item{ + margin-bottom: 0px; + padding: 0px; + } + } + } + ol { list-style: decimal outside none; margin-bottom: lh(); @@ -536,9 +540,8 @@ section.open-ended-child { } li { - line-height: 1.4em; - margin-bottom: lh(.5); - + margin-bottom: 0px; + padding: 0px; &:last-child { margin-bottom: 0; } @@ -577,11 +580,6 @@ section.open-ended-child { } .submission_feedback { - // background: #F3F3F3; - // border: 1px solid #ddd; - // @include border-radius(3px); - // padding: 8px 12px; - // margin-top: 10px; @include inline-block; font-style: italic; margin: 8px 0 0 10px; diff --git a/common/lib/xmodule/xmodule/css/html/display.scss b/common/lib/xmodule/xmodule/css/html/display.scss index c031ecb141..93138ac5a9 100644 --- a/common/lib/xmodule/xmodule/css/html/display.scss +++ b/common/lib/xmodule/xmodule/css/html/display.scss @@ -49,16 +49,28 @@ p { em, i { font-style: italic; + + span { + font-style: italic; + } } strong, b { - font-style: bold; + font-weight: bold; + + span { + font-weight: bold; + } } p + p, ul + p, ol + p { margin-top: 20px; } +blockquote { + margin: 1em 40px; +} + ol, ul { margin: 1em 0; padding: 0 0 0 1em; diff --git a/common/lib/xmodule/xmodule/css/videoalpha/display.scss b/common/lib/xmodule/xmodule/css/videoalpha/display.scss new file mode 100644 index 0000000000..bf575e74a3 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/videoalpha/display.scss @@ -0,0 +1,559 @@ +& { + margin-bottom: 30px; +} + +div.video { + @include clearfix(); + background: #f3f3f3; + display: block; + margin: 0 -12px; + padding: 12px; + border-radius: 5px; + + article.video-wrapper { + float: left; + margin-right: flex-gutter(9); + width: flex-grid(6, 9); + + section.video-player { + height: 0; + overflow: hidden; + padding-bottom: 56.25%; + position: relative; + + object, iframe { + border: none; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + } + + section.video-controls { + @include clearfix(); + background: #333; + border: 1px solid #000; + border-top: 0; + color: #ccc; + position: relative; + + &:hover { + ul, div { + opacity: 1; + } + } + + div.slider { + @include clearfix(); + background: #c2c2c2; + border: 1px solid #000; + @include border-radius(0); + border-top: 1px solid #000; + @include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555); + height: 7px; + margin-left: -1px; + margin-right: -1px; + @include transition(height 2.0s ease-in-out); + + div.ui-widget-header { + background: #777; + @include box-shadow(inset 0 1px 0 #999); + } + + a.ui-slider-handle { + background: $pink url(../images/slider-handle.png) center center no-repeat; + @include background-size(50%); + border: 1px solid darken($pink, 20%); + @include border-radius(15px); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); + cursor: pointer; + height: 15px; + margin-left: -7px; + top: -4px; + @include transition(height 2.0s ease-in-out, width 2.0s ease-in-out); + width: 15px; + + &:focus, &:hover { + background-color: lighten($pink, 10%); + outline: none; + } + } + } + + ul.vcr { + @extend .dullify; + float: left; + list-style: none; + margin: 0 lh() 0 0; + padding: 0; + + li { + float: left; + margin-bottom: 0; + + a { + border-bottom: none; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555); + cursor: pointer; + display: block; + line-height: 46px; + padding: 0 lh(.75); + text-indent: -9999px; + @include transition(background-color, opacity); + width: 14px; + background: url('../images/vcr.png') 15px 15px no-repeat; + outline: 0; + + &:focus { + outline: 0; + } + + &:empty { + height: 46px; + background: url('../images/vcr.png') 15px 15px no-repeat; + } + + &.play { + background-position: 17px -114px; + + &:hover { + background-color: #444; + } + } + + &.pause { + background-position: 16px -50px; + + &:hover { + background-color: #444; + } + } + } + + div.vidtime { + padding-left: lh(.75); + font-weight: bold; + line-height: 46px; //height of play pause buttons + padding-left: lh(.75); + -webkit-font-smoothing: antialiased; + } + } + } + + div.secondary-controls { + @extend .dullify; + float: right; + + div.speeds { + float: left; + position: relative; + + &.open { + &>a { + background: url('../images/open-arrow.png') 10px center no-repeat; + } + + ol.video_speeds { + display: block; + opacity: 1; + padding: 0; + margin: 0; + list-style: none; + } + } + + &>a { + background: url('../images/closed-arrow.png') 10px center no-repeat; + border-left: 1px solid #000; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + @include clearfix(); + color: #fff; + cursor: pointer; + display: block; + line-height: 46px; //height of play pause buttons + margin-right: 0; + padding-left: 15px; + position: relative; + @include transition(); + -webkit-font-smoothing: antialiased; + width: 116px; + outline: 0; + + &:focus { + outline: 0; + } + + h3 { + color: #999; + float: left; + font-size: em(14); + font-weight: normal; + letter-spacing: 1px; + padding: 0 lh(.25) 0 lh(.5); + line-height: 46px; + text-transform: uppercase; + } + + p.active { + float: left; + font-weight: bold; + margin-bottom: 0; + padding: 0 lh(.5) 0 0; + line-height: 46px; + color: #fff; + } + + &:hover, &:active, &:focus { + opacity: 1; + background-color: #444; + } + } + + // fix for now + ol.video_speeds { + @include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444); + @include transition(); + background-color: #444; + border: 1px solid #000; + bottom: 46px; + display: none; + opacity: 0; + position: absolute; + width: 133px; + z-index: 10; + + li { + @include box-shadow( 0 1px 0 #555); + border-bottom: 1px solid #000; + color: #fff; + cursor: pointer; + + a { + border: 0; + color: #fff; + display: block; + padding: lh(.5); + + &:hover { + background-color: #666; + color: #aaa; + } + } + + &.active { + font-weight: bold; + } + + &:last-child { + @include box-shadow(none); + border-bottom: 0; + margin-top: 0; + } + } + } + } + + div.volume { + float: left; + position: relative; + + &.open { + .volume-slider-container { + display: block; + opacity: 1; + } + } + + &.muted { + &>a { + background: url('../images/mute.png') 10px center no-repeat; + } + } + + > a { + background: url('../images/volume.png') 10px center no-repeat; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + @include clearfix(); + color: #fff; + cursor: pointer; + display: block; + height: 46px; + margin-right: 0; + padding-left: 15px; + position: relative; + @include transition(); + -webkit-font-smoothing: antialiased; + width: 30px; + + &:hover, &:active, &:focus { + background-color: #444; + } + } + + .volume-slider-container { + @include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444); + @include transition(); + background-color: #444; + border: 1px solid #000; + bottom: 46px; + display: none; + opacity: 0; + position: absolute; + width: 45px; + height: 125px; + margin-left: -1px; + z-index: 10; + + .volume-slider { + height: 100px; + border: 0; + width: 5px; + margin: 14px auto; + background: #666; + border: 1px solid #000; + @include box-shadow(0 1px 0 #333); + + a.ui-slider-handle { + background: $pink url(../images/slider-handle.png) center center no-repeat; + @include background-size(50%); + border: 1px solid darken($pink, 20%); + @include border-radius(15px); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); + cursor: pointer; + height: 15px; + left: -6px; + @include transition(height 2.0s ease-in-out, width 2.0s ease-in-out); + width: 15px; + } + + .ui-slider-range { + background: #ddd; + } + } + } + } + + a.add-fullscreen { + background: url(../images/fullscreen.png) center no-repeat; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + color: #797979; + display: block; + float: left; + line-height: 46px; //height of play pause buttons + margin-left: 0; + padding: 0 lh(.5); + text-indent: -9999px; + @include transition(); + width: 30px; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + } + } + + a.quality_control { + background: url(../images/hd.png) center no-repeat; + border-right: 1px solid #000; + @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555); + color: #797979; + display: block; + float: left; + line-height: 46px; //height of play pause buttons + margin-left: 0; + padding: 0 lh(.5); + text-indent: -9999px; + @include transition(); + width: 30px; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + } + + &.active { + background-color: #F44; + color: #0ff; + text-decoration: none; + } + } + + + a.hide-subtitles { + background: url('../images/cc.png') center no-repeat; + color: #797979; + display: block; + float: left; + font-weight: 800; + line-height: 46px; //height of play pause buttons + margin-left: 0; + opacity: 1; + padding: 0 lh(.5); + position: relative; + text-indent: -9999px; + @include transition(); + -webkit-font-smoothing: antialiased; + width: 30px; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + } + + &.off { + opacity: .7; + } + } + } + } + + &:hover section.video-controls { + ul, div { + opacity: 1; + } + + div.slider { + height: 14px; + margin-top: -7px; + + a.ui-slider-handle { + @include border-radius(20px); + height: 20px; + margin-left: -10px; + top: -4px; + width: 20px; + } + } + } + } + + ol.subtitles { + padding-left: 0; + float: left; + max-height: 460px; + overflow: auto; + width: flex-grid(3, 9); + margin: 0; + font-size: 14px; + list-style: none; + + li { + border: 0; + color: #666; + cursor: pointer; + margin-bottom: 8px; + padding: 0; + line-height: lh(); + + &.current { + color: #333; + font-weight: 700; + } + + &:hover { + color: $blue; + } + + &:empty { + margin-bottom: 0px; + } + } + } + + &.closed { + @extend .trans; + + article.video-wrapper { + width: flex-grid(9,9); + } + + ol.subtitles { + width: 0; + height: 0; + } + } + + &.fullscreen { + background: rgba(#000, .95); + border: 0; + bottom: 0; + height: 100%; + left: 0; + margin: 0; + overflow: hidden; + padding: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 999; + vertical-align: middle; + + &.closed { + ol.subtitles { + right: -(flex-grid(4)); + width: auto; + } + } + + div.tc-wrapper { + @include clearfix; + display: table; + width: 100%; + height: 100%; + + article.video-wrapper { + width: 100%; + display: table-cell; + vertical-align: middle; + float: none; + } + + object, iframe { + bottom: 0; + height: 100%; + left: 0; + overflow: hidden; + position: fixed; + top: 0; + } + + section.video-controls { + bottom: 0; + left: 0; + position: absolute; + width: 100%; + z-index: 9999; + } + } + + ol.subtitles { + background: rgba(#000, .8); + bottom: 0; + height: 100%; + max-height: 100%; + max-width: flex-grid(3); + padding: lh(); + position: fixed; + right: 0; + top: 0; + @include transition(); + + li { + color: #aaa; + + &.current { + color: #fff; + } + } + } + } +} diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index 57d7780d95..6ddfcbe6c0 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -6,6 +6,7 @@ from xmodule.raw_module import RawDescriptor import json + class DiscussionModule(XModule): js = {'coffee': [resource_string(__name__, 'js/src/time.coffee'), @@ -30,6 +31,7 @@ class DiscussionModule(XModule): self.title = xml_data.attrib['for'] self.discussion_category = xml_data.attrib['discussion_category'] + class DiscussionDescriptor(RawDescriptor): module_class = DiscussionModule template_dir_name = "discussion" diff --git a/common/lib/xmodule/xmodule/errortracker.py b/common/lib/xmodule/xmodule/errortracker.py index 6accc8b8a7..80e6d288f8 100644 --- a/common/lib/xmodule/xmodule/errortracker.py +++ b/common/lib/xmodule/xmodule/errortracker.py @@ -8,12 +8,14 @@ log = logging.getLogger(__name__) ErrorLog = namedtuple('ErrorLog', 'tracker errors') + def exc_info_to_str(exc_info): """Given some exception info, convert it into a string using the traceback.format_exception() function. """ return ''.join(traceback.format_exception(*exc_info)) + def in_exception_handler(): '''Is there an active exception?''' return sys.exc_info() != (None, None, None) @@ -44,6 +46,7 @@ def make_error_tracker(): return ErrorLog(error_tracker, errors) + def null_error_tracker(msg): '''A dummy error tracker that just ignores the messages''' pass diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py new file mode 100644 index 0000000000..ea16fee7f1 --- /dev/null +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -0,0 +1,124 @@ +import logging +from lxml import etree +from dateutil import parser + +from pkg_resources import resource_string + +from xmodule.editing_module import EditingDescriptor +from xmodule.x_module import XModule +from xmodule.xml_module import XmlDescriptor + +log = logging.getLogger(__name__) + +class FolditModule(XModule): + def __init__(self, system, location, definition, descriptor, + instance_state=None, shared_state=None, **kwargs): + XModule.__init__(self, system, location, definition, descriptor, + instance_state, shared_state, **kwargs) + # ooh look--I'm lazy, so hardcoding the 7.00x required level. + # If we need it generalized, can pull from the xml later + self.required_level = 4 + self.required_sublevel = 5 + + def parse_due_date(): + """ + Pull out the date, or None + """ + s = self.metadata.get("due") + if s: + return parser.parse(s) + else: + return None + + self.due_str = self.metadata.get("due", "None") + self.due = parse_due_date() + + def is_complete(self): + """ + Did the user get to the required level before the due date? + """ + # We normally don't want django dependencies in xmodule. foldit is + # special. Import this late to avoid errors with things not yet being + # initialized. + from foldit.models import PuzzleComplete + + complete = PuzzleComplete.is_level_complete( + self.system.anonymous_student_id, + self.required_level, + self.required_sublevel, + self.due) + return complete + + def completed_puzzles(self): + """ + Return a list of puzzles that this user has completed, as an array of + dicts: + + [ {'set': int, + 'subset': int, + 'created': datetime} ] + + The list is sorted by set, then subset + """ + from foldit.models import PuzzleComplete + + return sorted( + PuzzleComplete.completed_puzzles(self.system.anonymous_student_id), + key=lambda d: (d['set'], d['subset'])) + + + def get_html(self): + """ + Render the html for the module. + """ + goal_level = '{0}-{1}'.format( + self.required_level, + self.required_sublevel) + + context = { + 'due': self.due_str, + 'success': self.is_complete(), + 'goal_level': goal_level, + 'completed': self.completed_puzzles(), + } + + return self.system.render_template('foldit.html', context) + + + def get_score(self): + """ + 0 / 1 based on whether student has gotten far enough. + """ + score = 1 if self.is_complete() else 0 + return {'score': score, + 'total': self.max_score()} + + def max_score(self): + return 1 + + +class FolditDescriptor(XmlDescriptor, EditingDescriptor): + """ + Module for adding open ended response questions to courses + """ + mako_template = "widgets/html-edit.html" + module_class = FolditModule + filename_extension = "xml" + + stores_state = True + has_score = True + template_dir_name = "foldit" + + js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} + js_module_name = "HTMLEditingDescriptor" + + # The grade changes without any student interaction with the edx website, + # so always need to actually check. + always_recalculate_grades = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + """ + For now, don't need anything from the xml + """ + return {} diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index 3e6d61eb00..35318f4f1e 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -49,6 +49,7 @@ def invalid_args(func, argdict): if keywords: return set() # All accepted return set(argdict) - set(args) + def grader_from_conf(conf): """ This creates a CourseGrader from a configuration (such as in course_settings.py). @@ -80,7 +81,7 @@ def grader_from_conf(conf): subgrader_class = SingleSectionGrader else: raise ValueError("Configuration has no appropriate grader class.") - + bad_args = invalid_args(subgrader_class.__init__, subgraderconf) # See note above concerning 'name'. if bad_args.issuperset({name}): @@ -90,7 +91,7 @@ def grader_from_conf(conf): log.warning("Invalid arguments for a subgrader: %s", bad_args) for key in bad_args: del subgraderconf[key] - + subgrader = subgrader_class(**subgraderconf) subgraders.append((subgrader, subgrader.category, weight)) @@ -210,13 +211,13 @@ class SingleSectionGrader(CourseGrader): break if foundScore or generate_random_scores: - if generate_random_scores: # for debugging! - earned = random.randint(2,15) + if generate_random_scores: # for debugging! + earned = random.randint(2, 15) possible = random.randint(earned, 15) - else: # We found the score + else: # We found the score earned = foundScore.earned possible = foundScore.possible - + percent = earned / float(possible) detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name, percent=percent, @@ -245,7 +246,7 @@ class AssignmentFormatGrader(CourseGrader): min_count defines how many assignments are expected throughout the course. Placeholder scores (of 0) will be inserted if the number of matching sections in the course is < min_count. If there number of matching sections in the course is > min_count, min_count will be ignored. - + show_only_average is to suppress the display of each assignment in this grader and instead only show the total score of this grader in the breakdown. @@ -257,7 +258,7 @@ class AssignmentFormatGrader(CourseGrader): short_label is similar to section_type, but shorter. For example, for Homework it would be "HW". - + starting_index is the first number that will appear. For example, starting_index=3 and min_count = 2 would produce the labels "Assignment 3", "Assignment 4" @@ -296,16 +297,16 @@ class AssignmentFormatGrader(CourseGrader): breakdown = [] for i in range(max(self.min_count, len(scores))): if i < len(scores) or generate_random_scores: - if generate_random_scores: # for debugging! - earned = random.randint(2,15) - possible = random.randint(earned, 15) + if generate_random_scores: # for debugging! + earned = random.randint(2, 15) + possible = random.randint(earned, 15) section_name = "Generated" - + else: earned = scores[i].earned possible = scores[i].possible section_name = scores[i].section - + percentage = earned / float(possible) summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index=i + self.starting_index, section_type=self.section_type, @@ -318,7 +319,7 @@ class AssignmentFormatGrader(CourseGrader): summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, section_type=self.section_type) short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label=self.short_label) - + breakdown.append({'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category}) total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count) @@ -328,13 +329,13 @@ class AssignmentFormatGrader(CourseGrader): total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, section_type=self.section_type) total_label = "{short_label} Avg".format(short_label=self.short_label) - + if self.show_only_average: breakdown = [] - + if not self.hide_average: breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True}) - + return {'percent': total_percent, 'section_breakdown': breakdown, #No grade_breakdown here diff --git a/common/lib/xmodule/xmodule/hidden_module.py b/common/lib/xmodule/xmodule/hidden_module.py index d4f2a0fa33..e7639e63c8 100644 --- a/common/lib/xmodule/xmodule/hidden_module.py +++ b/common/lib/xmodule/xmodule/hidden_module.py @@ -3,7 +3,11 @@ from xmodule.raw_module import RawDescriptor class HiddenModule(XModule): - pass + def get_html(self): + if self.system.user_is_staff: + return "ERROR: This module is unknown--students will not see it at all" + else: + return "" class HiddenDescriptor(RawDescriptor): diff --git a/common/lib/xmodule/xmodule/html_checker.py b/common/lib/xmodule/xmodule/html_checker.py index 5e6b417d28..b30e5163a2 100644 --- a/common/lib/xmodule/xmodule/html_checker.py +++ b/common/lib/xmodule/xmodule/html_checker.py @@ -1,5 +1,6 @@ from lxml import etree + def check_html(html): ''' Check whether the passed in html string can be parsed by lxml. diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 612e78ce35..456ea3cf10 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -133,7 +133,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): # TODO (ichuang): remove this after migration # for Fall 2012 LMS migration: keep filename (and unmangled filename) - definition['filename'] = [ filepath, filename ] + definition['filename'] = [filepath, filename] return definition @@ -172,6 +172,13 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): elt.set("filename", relname) return elt + @property + def editable_metadata_fields(self): + """Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user.""" + subset = [field for field in super(HtmlDescriptor,self).editable_metadata_fields + if field not in ['empty']] + return subset + class AboutDescriptor(HtmlDescriptor): """ @@ -180,6 +187,7 @@ class AboutDescriptor(HtmlDescriptor): """ template_dir_name = "about" + class StaticTabDescriptor(HtmlDescriptor): """ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located @@ -187,6 +195,7 @@ class StaticTabDescriptor(HtmlDescriptor): """ template_dir_name = "statictab" + class CourseInfoDescriptor(HtmlDescriptor): """ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located diff --git a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee index 6777d8c794..f2e8da7990 100644 --- a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee @@ -45,7 +45,6 @@ describe 'CombinedOpenEnded', -> @combined.poll() expect(window.queuePollerID).toBeUndefined() expect(window.setTimeout).not.toHaveBeenCalled() - expect(@combined.reload).toHaveBeenCalled() describe 'rebind', -> beforeEach -> diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee index 9b8062d60d..5161e658e7 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -119,13 +119,13 @@ describe 'MarkdownEditingDescriptor', ->

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

          Enter the numerical value of Pi:

          - +

          Enter the approximate value of 502*9:

          - + @@ -147,6 +147,20 @@ describe 'MarkdownEditingDescriptor', ->
          + """) + it 'will convert 0 as a numerical response (instead of string response)', -> + data = MarkdownEditingDescriptor.markdownToXml(""" + Enter 0 with a tolerance: + = 0 +- .02 + """) + expect(data).toEqual(""" +

          Enter 0 with a tolerance:

          + + + + + +
          """) it 'converts multiple choice to xml', -> data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets. diff --git a/common/lib/xmodule/xmodule/js/src/.gitignore b/common/lib/xmodule/xmodule/js/src/.gitignore index 03534687ca..bbd93c90e3 100644 --- a/common/lib/xmodule/xmodule/js/src/.gitignore +++ b/common/lib/xmodule/xmodule/js/src/.gitignore @@ -1,2 +1 @@ -*.js - +# Please do not ignore *.js files. Some xmodules are written in JS. diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1c0ace9e59..41c9b50891 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -11,8 +11,9 @@ class @Problem $(selector, @el) bind: => - @el.find('.problem > div').each (index, element) => - MathJax.Hub.Queue ["Typeset", MathJax.Hub, element] + if MathJax? + @el.find('.problem > div').each (index, element) => + MathJax.Hub.Queue ["Typeset", MathJax.Hub, element] window.update_schematics() @@ -31,8 +32,9 @@ class @Problem # Dynamath @$('input.math').keyup(@refreshMath) - @$('input.math').each (index, element) => - MathJax.Hub.Queue [@refreshMath, null, element] + if MathJax? + @$('input.math').each (index, element) => + MathJax.Hub.Queue [@refreshMath, null, element] updateProgress: (response) => if response.progress_changed @@ -140,15 +142,15 @@ class @Problem allowed_files = $(element).data("allowed_files") for file in element.files if allowed_files.length != 0 and file.name not in allowed_files - unallowed_file_submitted = true - errors.push "You submitted #{file.name}; only #{allowed_files} are allowed." + unallowed_file_submitted = true + errors.push "You submitted #{file.name}; only #{allowed_files} are allowed." if file.name in required_files - required_files.splice(required_files.indexOf(file.name), 1) + required_files.splice(required_files.indexOf(file.name), 1) if file.size > max_filesize file_too_large = true errors.push 'Your file "' + file.name '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)' fd.append(element.id, file) - if element.files.length == 0 + if element.files.length == 0 file_not_selected = true fd.append(element.id, '') # In case we want to allow submissions with no file if required_files.length != 0 @@ -157,7 +159,7 @@ class @Problem else fd.append(element.id, element.value) - + if file_not_selected errors.push 'You did not select any files to submit' @@ -230,8 +232,9 @@ class @Problem showMethod = @inputtypeShowAnswerMethods[cls] showMethod(inputtype, display, answers) if showMethod? - @el.find('.problem > div').each (index, element) => - MathJax.Hub.Queue ["Typeset", MathJax.Hub, element] + if MathJax? + @el.find('.problem > div').each (index, element) => + MathJax.Hub.Queue ["Typeset", MathJax.Hub, element] @$('.show').val 'Hide Answer' @el.addClass 'showed' @@ -259,9 +262,8 @@ class @Problem save: => Logger.log 'problem_save', @answers $.postWithPrefix "#{@url}/problem_save", @answers, (response) => - if response.success - saveMessage = "Your answers have been saved but not graded. Hit 'Check' to grade them." - @gentle_alert saveMessage + saveMessage = response.msg + @gentle_alert saveMessage @updateProgress response refreshMath: (event, element) => @@ -273,7 +275,7 @@ class @Problem preprocessor_tag = "inputtype_" + elid mathjax_preprocessor = @inputtypeDisplays[preprocessor_tag] - if jax = MathJax.Hub.getAllJax(target)[0] + if MathJax? and jax = MathJax.Hub.getAllJax(target)[0] eqn = $(element).val() if mathjax_preprocessor eqn = mathjax_preprocessor(eqn) @@ -286,7 +288,8 @@ class @Problem $("##{element.id}_dynamath").val(jax.root.toMathML '') catch exception throw exception unless exception.restart - MathJax.Callback.After [@refreshMath, jax], exception.restart + if MathJax? + MathJax.Callback.After [@refreshMath, jax], exception.restart refreshAnswers: => @$('input.schematic').each (index, element) -> diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 594efe2f9b..c749d65b45 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -1,38 +1,139 @@ +class @Rubric + constructor: () -> + + @initialize: (location) -> + $('.rubric').data("location", location) + $('input[class="score-selection"]').change @tracking_callback + # set up the hotkeys + $(window).unbind('keydown', @keypress_callback) + $(window).keydown @keypress_callback + # display the 'current' carat + @categories = $('.rubric-category') + @category = $(@categories.first()) + @category.prepend('> ') + @category_index = 0 + + + @keypress_callback: (event) => + # don't try to do this when user is typing in a text input + if $(event.target).is('input, textarea') + return + # for when we select via top row + if event.which >= 48 and event.which <= 57 + selected = event.which - 48 + # for when we select via numpad + else if event.which >= 96 and event.which <= 105 + selected = event.which - 96 + # we don't want to do anything since we haven't pressed a number + else + return + + # if we actually have a current category (not past the end) + if(@category_index <= @categories.length) + # find the valid selections for this category + inputs = $("input[name='score-selection-#{@category_index}']") + max_score = inputs.length - 1 + + if selected > max_score or selected < 0 + return + inputs.filter("input[value=#{selected}]").click() + + # move to the next category + old_category_text = @category.html().substring(5) + @category.html(old_category_text) + @category_index++ + @category = $(@categories[@category_index]) + @category.prepend('> ') + + @tracking_callback: (event) -> + target_selection = $(event.target).val() + # chop off the beginning of the name so that we can get the number of the category + category = $(event.target).data("category") + location = $('.rubric').data('location') + # probably want the original problem location as well + + data = {location: location, selection: target_selection, category: category} + Logger.log 'rubric_select', data + + + # finds the scores for each rubric category + @get_score_list: () => + # find the number of categories: + num_categories = $('.rubric-category').length + + score_lst = [] + # get the score for each one + for i in [0..(num_categories-1)] + score = $("input[name='score-selection-#{i}']:checked").val() + score_lst.push(score) + + return score_lst + + @get_total_score: () -> + score_lst = @get_score_list() + tot = 0 + for score in score_lst + tot += parseInt(score) + return tot + + @check_complete: () -> + # check to see whether or not any categories have not been scored + num_categories = $('.rubric-category').length + for i in [0..(num_categories-1)] + score = $("input[name='score-selection-#{i}']:checked").val() + if score == undefined + return false + return true + class @CombinedOpenEnded constructor: (element) -> @element=element @reinitialize(element) + $(window).keydown @keydown_handler reinitialize: (element) -> @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule') @el = $(element).find('section.combined-open-ended') @combined_open_ended=$(element).find('section.combined-open-ended') + @id = @el.data('id') @ajax_url = @el.data('ajax-url') @state = @el.data('state') @task_count = @el.data('task-count') @task_number = @el.data('task-number') + @accept_file_upload = @el.data('accept-file-upload') + @location = @el.data('location') + # set up handlers for click tracking + Rubric.initialize(@location) @allow_reset = @el.data('allow_reset') @reset_button = @$('.reset-button') @reset_button.click @reset @next_problem_button = @$('.next-step-button') @next_problem_button.click @next_problem + @status_container = @$('.status-elements') @show_results_button=@$('.show-results-button') @show_results_button.click @show_results + @question_header = @$('.question-header') + @question_header.click @collapse_question + # valid states: 'initial', 'assessing', 'post_assessment', 'done' Collapsible.setCollapsibles(@el) @submit_evaluation_button = $('.submit-evaluation-button') @submit_evaluation_button.click @message_post @results_container = $('.result-container') + @combined_rubric_container = $('.combined-rubric-container') + + @legend_container= $('.legend-container') + @show_legend_current() # Where to put the rubric once we load it @el = $(element).find('section.open-ended-child') @errors_area = @$('.error') @answer_area = @$('textarea.answer') - + @prompt_container = @$('.prompt') @rubric_wrapper = @$('.rubric-wrapper') @hint_wrapper = @$('.hint-wrapper') @message_wrapper = @$('.message-wrapper') @@ -43,19 +144,45 @@ class @CombinedOpenEnded @skip_button = @$('.skip-button') @skip_button.click @skip_post_assessment + @file_upload_area = @$('.file-upload') + @can_upload_files = false @open_ended_child= @$('.open-ended-child') + @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' + + if @task_number>1 + @prompt_hide() + else if @task_number==1 and @child_state!='initial' + @prompt_hide() + @find_assessment_elements() @find_hint_elements() @rebind() + if @task_number>1 + @show_combined_rubric_current() + @show_results_current() + # locally scoped jquery. $: (selector) -> $(selector, @el) + show_results_current: () => + data = {'task_number' : @task_number-1} + $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => + if response.success + @results_container.after(response.html).remove() + @results_container = $('div.result-container') + @submit_evaluation_button = $('.submit-evaluation-button') + @submit_evaluation_button.click @message_post + Collapsible.setCollapsibles(@results_container) + # make sure we still have click tracking + $('.evaluation-response a').click @log_feedback_click + $('input[name="evaluation-score"]').change @log_feedback_selection + show_results: (event) => - status_item = $(event.target).parent().parent() + status_item = $(event.target).parent() status_number = status_item.data('status-number') data = {'task_number' : status_number} $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => @@ -66,10 +193,30 @@ class @CombinedOpenEnded @submit_evaluation_button.click @message_post Collapsible.setCollapsibles(@results_container) else - @errors_area.html(response.error) + @gentle_alert response.error + + show_combined_rubric_current: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_combined_rubric", data, (response) => + if response.success + @combined_rubric_container.after(response.html).remove() + @combined_rubric_container= $('div.combined_rubric_container') + + show_status_current: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_status", data, (response) => + if response.success + @status_container.after(response.html).remove() + @status_container= $('.status-elements') + + show_legend_current: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_legend", data, (response) => + if response.success + @legend_container.after(response.html).remove() + @legend_container= $('.legend-container') message_post: (event)=> - Logger.log 'message_post', @answers external_grader_message=$(event.target).parent().parent().parent() evaluation_scoring = $(event.target).parent() @@ -98,6 +245,7 @@ class @CombinedOpenEnded $('section.evaluation').slideToggle() @message_wrapper.html(response.message_html) + $.ajaxWithPrefix("#{@ajax_url}/save_post_assessment", settings) @@ -107,22 +255,33 @@ class @CombinedOpenEnded @submit_button.show() @reset_button.hide() @next_problem_button.hide() + @hide_file_upload() @hint_area.attr('disabled', false) + if @task_number>1 or @child_state!='initial' + @show_status_current() + + if @task_number==1 and @child_state=='assessing' + @prompt_hide() if @child_state == 'done' @rubric_wrapper.hide() if @child_type=="openended" @skip_button.hide() if @allow_reset=="True" + @show_results_current @reset_button.show() @submit_button.hide() @answer_area.attr("disabled", true) + @replace_text_inputs() @hint_area.attr('disabled', true) else if @child_state == 'initial' @answer_area.attr("disabled", false) @submit_button.prop('value', 'Submit') @submit_button.click @save_answer + @setup_file_upload() else if @child_state == 'assessing' @answer_area.attr("disabled", true) + @replace_text_inputs() + @hide_file_upload() @submit_button.prop('value', 'Submit assessment') @submit_button.click @save_assessment if @child_type == "openended" @@ -133,6 +292,7 @@ class @CombinedOpenEnded @skip_button.show() @skip_post_assessment() @answer_area.attr("disabled", true) + @replace_text_inputs() @submit_button.prop('value', 'Submit post-assessment') if @child_type=="selfassessment" @submit_button.click @save_hint @@ -141,6 +301,7 @@ class @CombinedOpenEnded else if @child_state == 'done' @rubric_wrapper.hide() @answer_area.attr("disabled", true) + @replace_text_inputs() @hint_area.attr('disabled', true) @submit_button.hide() if @child_type=="openended" @@ -148,6 +309,7 @@ class @CombinedOpenEnded if @task_number<@task_count @next_problem() else + @show_results_current() @reset_button.show() @@ -159,25 +321,56 @@ class @CombinedOpenEnded save_answer: (event) => event.preventDefault() + max_filesize = 2*1000*1000 #2MB if @child_state == 'initial' - data = {'student_answer' : @answer_area.val()} - $.postWithPrefix "#{@ajax_url}/save_answer", data, (response) => - if response.success - @rubric_wrapper.html(response.rubric_html) - @rubric_wrapper.show() - @child_state = 'assessing' - @find_assessment_elements() - @rebind() + files = "" + if @can_upload_files == true + files = $('.file-upload-box')[0].files[0] + if files != undefined + if files.size > max_filesize + @can_upload_files = false + files = "" else - @errors_area.html(response.error) + @can_upload_files = false + + fd = new FormData() + fd.append('student_answer', @answer_area.val()) + fd.append('student_file', files) + fd.append('can_upload_files', @can_upload_files) + + settings = + type: "POST" + data: fd + processData: false + contentType: false + success: (response) => + if response.success + @rubric_wrapper.html(response.rubric_html) + @rubric_wrapper.show() + Rubric.initialize(@location) + @answer_area.html(response.student_response) + @child_state = 'assessing' + @find_assessment_elements() + @rebind() + else + @gentle_alert response.error + + $.ajaxWithPrefix("#{@ajax_url}/save_answer",settings) + else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) + + keydown_handler: (e) => + # only do anything when the key pressed is the 'enter' key + if e.which == 13 && @child_state == 'assessing' && Rubric.check_complete() + @save_assessment(e) save_assessment: (event) => event.preventDefault() - if @child_state == 'assessing' - checked_assessment = @$('input[name="grade-selection"]:checked') - data = {'assessment' : checked_assessment.val()} + if @child_state == 'assessing' && Rubric.check_complete() + checked_assessment = Rubric.get_total_score() + score_list = Rubric.get_score_list() + data = {'assessment' : checked_assessment, 'score_list' : score_list} $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) => if response.success @child_state = response.state @@ -187,13 +380,12 @@ class @CombinedOpenEnded @find_hint_elements() else if @child_state == 'done' @rubric_wrapper.hide() - @message_wrapper.html(response.message_html) @rebind() else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) save_hint: (event) => event.preventDefault() @@ -208,7 +400,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) skip_post_assessment: => if @child_state == 'post_assessment' @@ -220,7 +412,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) reset: (event) => event.preventDefault() @@ -240,7 +432,7 @@ class @CombinedOpenEnded else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) next_problem: => if @child_state == 'done' @@ -259,10 +451,11 @@ class @CombinedOpenEnded @gentle_alert "Moved to next step." else @gentle_alert "Your score did not meet the criteria to move to the next step." + @show_results_current() else @errors_area.html(response.error) else - @errors_area.html('Problem state got out of sync. Try reloading the page.') + @errors_area.html(@out_of_sync_message) gentle_alert: (msg) => if @el.find('.open-ended-alert').length @@ -285,6 +478,64 @@ class @CombinedOpenEnded else window.queuePollerID = window.setTimeout(@poll, 10000) - # wrap this so that it can be mocked + setup_file_upload: => + if @accept_file_upload == "True" + if window.File and window.FileReader and window.FileList and window.Blob + @can_upload_files = true + @file_upload_area.html('') + @file_upload_area.show() + else + @gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.' + + hide_file_upload: => + if @accept_file_upload == "True" + @file_upload_area.hide() + + replace_text_inputs: => + answer_class = @answer_area.attr('class') + answer_id = @answer_area.attr('id') + answer_val = @answer_area.val() + new_text = '' + new_text = "
          #{answer_val}
          " + @answer_area.replaceWith(new_text) + + # wrap this so that it can be mocked reload: -> location.reload() + + collapse_question: () => + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + if @question_header.text() == "(Hide)" + new_text = "(Show)" + Logger.log 'oe_hide_question', {location: @location} + else + Logger.log 'oe_show_question', {location: @location} + new_text = "(Hide)" + @question_header.text(new_text) + + prompt_show: () => + if @prompt_container.is(":hidden")==true + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + @question_header.text("(Hide)") + + prompt_hide: () => + if @prompt_container.is(":visible")==true + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + @question_header.text("(Show)") + + log_feedback_click: (event) -> + link_text = $(event.target).html() + if link_text == 'See full feedback' + Logger.log 'oe_show_full_feedback', {} + else if link_text == 'Respond to Feedback' + Logger.log 'oe_show_respond_to_feedback', {} + else + generated_event_type = link_text.toLowerCase().replace(" ","_") + Logger.log "oe_" + generated_event_type, {} + + log_feedback_selection: (event) -> + target_selection = $(event.target).val() + Logger.log 'oe_feedback_response_selected', {value: target_selection} diff --git a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee new file mode 100644 index 0000000000..33dcb29079 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee @@ -0,0 +1,26 @@ +class @Conditional + + constructor: (element) -> + @el = $(element).find('.conditional-wrapper') + @id = @el.data('problem-id') + @element_id = @el.attr('id') + @url = @el.data('url') + @render() + + $: (selector) -> + $(selector, @el) + + updateProgress: (response) => + if response.progress_changed + @el.attr progress: response.progress_status + @el.trigger('progressChanged') + + render: (content) -> + if content + @el.html(content) + XModule.loadModules(@el) + else + $.postWithPrefix "#{@url}/conditional_get", (response) => + @el.html(response.html) + XModule.loadModules(@el) + diff --git a/common/lib/xmodule/xmodule/js/src/html/edit.coffee b/common/lib/xmodule/xmodule/js/src/html/edit.coffee index fa83343d7a..eae9df0f20 100644 --- a/common/lib/xmodule/xmodule/js/src/html/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/html/edit.coffee @@ -10,7 +10,8 @@ class @HTMLEditingDescriptor lineWrapping: true }) - $(@advanced_editor.getWrapperElement()).addClass(HTMLEditingDescriptor.isInactiveClass) + @$advancedEditorWrapper = $(@advanced_editor.getWrapperElement()) + @$advancedEditorWrapper.addClass(HTMLEditingDescriptor.isInactiveClass) # This is a workaround for the fact that tinyMCE's baseURL property is not getting correctly set on AWS # instances (like sandbox). It is not necessary to explicitly set baseURL when running locally. @@ -43,16 +44,21 @@ class @HTMLEditingDescriptor theme_advanced_blockformats : "p,pre,h1,h2,h3", width: '100%', height: '400px', - setup : HTMLEditingDescriptor.setupTinyMCE, + setup : @setupTinyMCE, # Cannot get access to tinyMCE Editor instance (for focusing) until after it is rendered. # The tinyMCE callback passes in the editor as a paramter. init_instance_callback: @focusVisualEditor }) @showingVisualEditor = true + # Doing these find operations within onSwitchEditor leads to sporadic failures on Chrome (version 20 and older). + $element = $(element) + @$htmlTab = $element.find('.html-tab') + @$visualTab = $element.find('.visual-tab') + @element.on('click', '.editor-tabs .tab', @onSwitchEditor) - @setupTinyMCE: (ed) -> + setupTinyMCE: (ed) => ed.addButton('wrapAsCode', { title : 'Code', image : '/static/images/ico-tinymce-code.png', @@ -67,19 +73,23 @@ class @HTMLEditingDescriptor command.setActive('wrapAsCode', e.nodeName == 'CODE') ) - onSwitchEditor: (e)=> + @visualEditor = ed + + onSwitchEditor: (e) => e.preventDefault(); - if not $(e.currentTarget).hasClass('current') - $('.editor-tabs .current', @element).removeClass('current') - $(e.currentTarget).addClass('current') - $('table.mceToolbar', @element).toggleClass(HTMLEditingDescriptor.isInactiveClass) - $(@advanced_editor.getWrapperElement()).toggleClass(HTMLEditingDescriptor.isInactiveClass) + $currentTarget = $(e.currentTarget) + if not $currentTarget.hasClass('current') + $currentTarget.addClass('current') + @$mceToolbar.toggleClass(HTMLEditingDescriptor.isInactiveClass) + @$advancedEditorWrapper.toggleClass(HTMLEditingDescriptor.isInactiveClass) visualEditor = @getVisualEditor() - if $(e.currentTarget).attr('data-tab') is 'visual' + if $currentTarget.data('tab') is 'visual' + @$htmlTab.removeClass('current') @showVisualEditor(visualEditor) else + @$visualTab.removeClass('current') @showAdvancedEditor(visualEditor) # Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing. @@ -97,19 +107,24 @@ class @HTMLEditingDescriptor # In order for isDirty() to return true ONLY if edits have been made after setting the text, # both the startContent must be sync'ed up and the dirty flag set to false. visualEditor.startContent = visualEditor.getContent({format: "raw", no_events: 1}); - visualEditor.isNotDirty = true @focusVisualEditor(visualEditor) @showingVisualEditor = true - focusVisualEditor: (visualEditor) -> + focusVisualEditor: (visualEditor) => visualEditor.focus() + # Need to mark editor as not dirty both when it is initially created and when we switch back to it. + visualEditor.isNotDirty = true + if not @$mceToolbar? + @$mceToolbar = $(@element).find('table.mceToolbar') - getVisualEditor: -> + getVisualEditor: () -> ### Returns the instance of TinyMCE. This is different from the textarea that exists in the HTML template (@tiny_mce_textarea. + + Pulled out as a helper method for unit test. ### - return tinyMCE.get($('.tiny-mce', this.element).attr('id')) + return @visualEditor save: -> @element.off('click', '.editor-tabs .tab', @onSwitchEditor) diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee new file mode 100644 index 0000000000..45c678bad9 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee @@ -0,0 +1,49 @@ +# This is a simple class that just hides the error container +# and message container when they are empty +# Can (and should be) expanded upon when our problem list +# becomes more sophisticated +class @PeerGrading + constructor: (element) -> + @peer_grading_container = $('.peer-grading') + @use_single_location = @peer_grading_container.data('use-single-location') + @peer_grading_outer_container = $('.peer-grading-container') + @ajax_url = @peer_grading_container.data('ajax-url') + @error_container = $('.error-container') + @error_container.toggle(not @error_container.is(':empty')) + + @message_container = $('.message-container') + @message_container.toggle(not @message_container.is(':empty')) + + @problem_button = $('.problem-button') + @problem_button.click @show_results + + @problem_list = $('.problem-list') + @construct_progress_bar() + + if @use_single_location + @activate_problem() + + construct_progress_bar: () => + problems = @problem_list.find('tr').next() + problems.each( (index, element) => + problem = $(element) + progress_bar = problem.find('.progress-bar') + bar_value = parseInt(problem.data('graded')) + bar_max = parseInt(problem.data('required')) + bar_value + progress_bar.progressbar({value: bar_value, max: bar_max}) + ) + + show_results: (event) => + location_to_fetch = $(event.target).data('location') + data = {'location' : location_to_fetch} + $.postWithPrefix "#{@ajax_url}problem", data, (response) => + if response.success + @peer_grading_outer_container.after(response.html).remove() + backend = new PeerGradingProblemBackend(@ajax_url, false) + new PeerGradingProblem(backend) + else + @gentle_alert response.error + + activate_problem: () => + backend = new PeerGradingProblemBackend(@ajax_url, false) + new PeerGradingProblem(backend) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee new file mode 100644 index 0000000000..4bdb4bdf05 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -0,0 +1,497 @@ +################################## +# +# This is the JS that renders the peer grading problem page. +# Fetches the correct problem and/or calibration essay +# and sends back the grades +# +# Should not be run when we don't have a location to send back +# to the server +# +# PeerGradingProblemBackend - +# makes all the ajax requests and provides a mock interface +# for testing purposes +# +# PeerGradingProblem - +# handles the rendering and user interactions with the interface +# +################################## +class @PeerGradingProblemBackend + constructor: (ajax_url, mock_backend) -> + @mock_backend = mock_backend + @ajax_url = ajax_url + @mock_cnt = 0 + + post: (cmd, data, callback) -> + if @mock_backend + callback(@mock(cmd, data)) + else + # if this post request fails, the error callback will catch it + $.post(@ajax_url + cmd, data, callback) + .error => callback({success: false, error: "Error occured while performing this operation"}) + + mock: (cmd, data) -> + if cmd == 'is_student_calibrated' + # change to test each version + response = + success: true + calibrated: @mock_cnt >= 2 + else if cmd == 'show_calibration_essay' + #response = + # success: false + # error: "There was an error" + @mock_cnt++ + response = + success: true + submission_id: 1 + submission_key: 'abcd' + student_response: ''' + Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. + + The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. + ''' + prompt: ''' +

          S11E3: Metal Bands

          +

          Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.

          +

          * Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled?

          +

          This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.

          + ''' + rubric: ''' + + + + + + + + + + + + + + + + + + +
          Purpose + + + + + + + +
          Organization + + + + + + + +
          + ''' + max_score: 4 + else if cmd == 'get_next_submission' + response = + success: true + submission_id: 1 + submission_key: 'abcd' + student_response: '''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed nec tristique ante. Proin at mauris sapien, quis varius leo. Morbi laoreet leo nisi. Morbi aliquam lacus ante. Cras iaculis velit sed diam mattis a fermentum urna luctus. Duis consectetur nunc vitae felis facilisis eget vulputate risus viverra. Cras consectetur ullamcorper lobortis. Nam eu gravida lorem. Nulla facilisi. Nullam quis felis enim. Mauris orci lectus, dictum id cursus in, vulputate in massa. + + Phasellus non varius sem. Nullam commodo lacinia odio sit amet egestas. Donec ullamcorper sapien sagittis arcu volutpat placerat. Phasellus ut pretium ante. Nam dictum pulvinar nibh dapibus tristique. Sed at tellus mi, fringilla convallis justo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus tristique rutrum nulla sed eleifend. Praesent at nunc arcu. Mauris condimentum faucibus nibh, eget commodo quam viverra sed. Morbi in tincidunt dolor. Morbi sed augue et augue interdum fermentum. + + Curabitur tristique purus ac arcu consequat cursus. Cras diam felis, dignissim quis placerat at, aliquet ac metus. Mauris vulputate est eu nibh imperdiet varius. Cras aliquet rhoncus elit a laoreet. Mauris consectetur erat et erat scelerisque eu faucibus dolor consequat. Nam adipiscing sagittis nisl, eu mollis massa tempor ac. Nulla scelerisque tempus blandit. Phasellus ac ipsum eros, id posuere arcu. Nullam non sapien arcu. Vivamus sit amet lorem justo, ac tempus turpis. Suspendisse pharetra gravida imperdiet. Pellentesque lacinia mi eu elit luctus pellentesque. Sed accumsan libero a magna elementum varius. Nunc eget pellentesque metus. ''' + prompt: ''' +

          S11E3: Metal Bands

          +

          Shown below are schematic band diagrams for two different metals. Both diagrams appear different, yet both of the elements are undisputably metallic in nature.

          +

          * Why is it that both sodium and magnesium behave as metals, even though the s-band of magnesium is filled?

          +

          This is a self-assessed open response question. Please use as much space as you need in the box below to answer the question.

          + ''' + rubric: ''' + + + + + + + + + + + + + + + + + + +
          Purpose + + + + + + + +
          Organization + + + + + + + +
          + ''' + max_score: 4 + else if cmd == 'save_calibration_essay' + response = + success: true + actual_score: 2 + else if cmd == 'save_grade' + response = + success: true + + return response + +class @PeerGradingProblem + constructor: (backend) -> + @prompt_wrapper = $('.prompt-wrapper') + @backend = backend + + + # get the location of the problem + @location = $('.peer-grading').data('location') + # prevent this code from trying to run + # when we don't have a location + if(!@location) + return + + # get the other elements we want to fill in + @submission_container = $('.submission-container') + @prompt_container = $('.prompt-container') + @rubric_container = $('.rubric-container') + @flag_student_container = $('.flag-student-container') + @answer_unknown_container = $('.answer-unknown-container') + @calibration_panel = $('.calibration-panel') + @grading_panel = $('.grading-panel') + @content_panel = $('.content-panel') + @grading_message = $('.grading-message') + @grading_message.hide() + @question_header = $('.question-header') + @question_header.click @collapse_question + + @grading_wrapper =$('.grading-wrapper') + @calibration_feedback_panel = $('.calibration-feedback') + @interstitial_page = $('.interstitial-page') + @interstitial_page.hide() + + @calibration_interstitial_page = $('.calibration-interstitial-page') + @calibration_interstitial_page.hide() + + @error_container = $('.error-container') + + @submission_key_input = $("input[name='submission-key']") + @essay_id_input = $("input[name='essay-id']") + @feedback_area = $('.feedback-area') + + @score_selection_container = $('.score-selection-container') + @rubric_selection_container = $('.rubric-selection-container') + @grade = null + @calibration = null + + @submit_button = $('.submit-button') + @action_button = $('.action-button') + @calibration_feedback_button = $('.calibration-feedback-button') + @interstitial_page_button = $('.interstitial-page-button') + @calibration_interstitial_page_button = $('.calibration-interstitial-page-button') + @flag_student_checkbox = $('.flag-checkbox') + @answer_unknown_checkbox = $('.answer-unknown-checkbox') + + $(window).keydown @keydown_handler + + @collapse_question() + + Collapsible.setCollapsibles(@content_panel) + + # Set up the click event handlers + @action_button.click -> history.back() + @calibration_feedback_button.click => + @calibration_feedback_panel.hide() + @grading_wrapper.show() + @gentle_alert "Calibration essay saved. Fetched the next essay." + @is_calibrated_check() + + @interstitial_page_button.click => + @interstitial_page.hide() + @is_calibrated_check() + + @calibration_interstitial_page_button.click => + @calibration_interstitial_page.hide() + @is_calibrated_check() + + @calibration_feedback_button.hide() + @calibration_feedback_panel.hide() + @error_container.hide() + + @is_calibrated_check() + + + ########## + # + # Ajax calls to the backend + # + ########## + is_calibrated_check: () => + @backend.post('is_student_calibrated', {location: @location}, @calibration_check_callback) + + fetch_calibration_essay: () => + @backend.post('show_calibration_essay', {location: @location}, @render_calibration) + + fetch_submission_essay: () => + @backend.post('get_next_submission', {location: @location}, @render_submission) + + + construct_data: () -> + data = + rubric_scores: Rubric.get_score_list() + score: Rubric.get_total_score() + location: @location + submission_id: @essay_id_input.val() + submission_key: @submission_key_input.val() + feedback: @feedback_area.val() + submission_flagged: @flag_student_checkbox.is(':checked') + answer_unknown: @answer_unknown_checkbox.is(':checked') + return data + + + submit_calibration_essay: ()=> + data = @construct_data() + @backend.post('save_calibration_essay', data, @calibration_callback) + + submit_grade: () => + data = @construct_data() + @backend.post('save_grade', data, @submission_callback) + + + ########## + # + # Callbacks for various events + # + ########## + + # called after we perform an is_student_calibrated check + calibration_check_callback: (response) => + if response.success + # if we haven't been calibrating before + if response.calibrated and (@calibration == null or @calibration == false) + @calibration = false + @fetch_submission_essay() + # If we were calibrating before and no longer need to, + # show the interstitial page + else if response.calibrated and @calibration == true + @calibration = false + @render_interstitial_page() + else if not response.calibrated and @calibration==null + @calibration=true + @render_calibration_interstitial_page() + else + @calibration = true + @fetch_calibration_essay() + else if response.error + @render_error(response.error) + else + @render_error("Error contacting the grading service") + + + # called after we submit a calibration score + calibration_callback: (response) => + if response.success + @render_calibration_feedback(response) + else if response.error + @render_error(response.error) + else + @render_error("Error saving calibration score") + + # called after we submit a submission score + submission_callback: (response) => + if response.success + @is_calibrated_check() + @grading_message.fadeIn() + @grading_message.html("

          Successfully saved your feedback. Fetched the next essay.

          ") + else + if response.error + @render_error(response.error) + else + @render_error("Error occurred while submitting grade") + + # called after a grade is selected on the interface + graded_callback: (event) => + # check to see whether or not any categories have not been scored + if Rubric.check_complete() + # show button if we have scores for all categories + @grading_message.hide() + @show_submit_button() + @grade = Rubric.get_total_score() + + keydown_handler: (event) => + if event.which == 13 && @submit_button.is(':visible') + if @calibration + @submit_calibration_essay() + else + @submit_grade() + + + + + ########## + # + # Rendering methods and helpers + # + ########## + # renders a calibration essay + render_calibration: (response) => + if response.success + + # load in all the data + @submission_container.html("") + @render_submission_data(response) + # TODO: indicate that we're in calibration mode + @calibration_panel.addClass('current-state') + @grading_panel.removeClass('current-state') + + # Display the right text + # both versions of the text are written into the template itself + # we only need to show/hide the correct ones at the correct time + @calibration_panel.find('.calibration-text').show() + @grading_panel.find('.calibration-text').show() + @calibration_panel.find('.grading-text').hide() + @grading_panel.find('.grading-text').hide() + @flag_student_container.hide() + @answer_unknown_container.hide() + + @feedback_area.val("") + + @submit_button.unbind('click') + @submit_button.click @submit_calibration_essay + + else if response.error + @render_error(response.error) + else + @render_error("An error occurred while retrieving the next calibration essay") + + # Renders a student submission to be graded + render_submission: (response) => + if response.success + @submit_button.hide() + @submission_container.html("") + @render_submission_data(response) + + @calibration_panel.removeClass('current-state') + @grading_panel.addClass('current-state') + + # Display the correct text + # both versions of the text are written into the template itself + # we only need to show/hide the correct ones at the correct time + @calibration_panel.find('.calibration-text').hide() + @grading_panel.find('.calibration-text').hide() + @calibration_panel.find('.grading-text').show() + @grading_panel.find('.grading-text').show() + @flag_student_container.show() + @answer_unknown_container.show() + @feedback_area.val("") + + @submit_button.unbind('click') + @submit_button.click @submit_grade + else if response.error + @render_error(response.error) + else + @render_error("An error occured when retrieving the next submission.") + + + make_paragraphs: (text) -> + paragraph_split = text.split(/\n\s*\n/) + new_text = '' + for paragraph in paragraph_split + new_text += "

          #{paragraph}

          " + return new_text + + # render common information between calibration and grading + render_submission_data: (response) => + @content_panel.show() + @error_container.hide() + + @submission_container.append(@make_paragraphs(response.student_response)) + @prompt_container.html(response.prompt) + @rubric_selection_container.html(response.rubric) + @submission_key_input.val(response.submission_key) + @essay_id_input.val(response.submission_id) + @setup_score_selection(response.max_score) + + @submit_button.hide() + @action_button.hide() + @calibration_feedback_panel.hide() + Rubric.initialize(@location) + + + render_calibration_feedback: (response) => + # display correct grade + @calibration_feedback_panel.slideDown() + calibration_wrapper = $('.calibration-feedback-wrapper') + calibration_wrapper.html("

          The score you gave was: #{@grade}. The actual score is: #{response.actual_score}

          ") + + + score = parseInt(@grade) + actual_score = parseInt(response.actual_score) + + if score == actual_score + calibration_wrapper.append("

          Your score matches the actual score!

          ") + else + calibration_wrapper.append("

          You may want to review the rubric again.

          ") + + # disable score selection and submission from the grading interface + $("input[name='score-selection']").attr('disabled', true) + @submit_button.hide() + @calibration_feedback_button.show() + + render_interstitial_page: () => + @content_panel.hide() + @grading_message.hide() + @interstitial_page.show() + + render_calibration_interstitial_page: () => + @content_panel.hide() + @action_button.hide() + @calibration_interstitial_page.show() + + render_error: (error_message) => + @error_container.show() + @calibration_feedback_panel.hide() + @error_container.html(error_message) + @content_panel.hide() + @action_button.show() + + show_submit_button: () => + @submit_button.show() + + setup_score_selection: (max_score) => + # And now hook up an event handler again + $("input[class='score-selection']").change @graded_callback + + gentle_alert: (msg) => + @grading_message.fadeIn() + @grading_message.html("

          " + msg + "

          ") + + collapse_question: () => + @prompt_container.slideToggle() + @prompt_container.toggleClass('open') + if @question_header.text() == "(Hide)" + Logger.log 'peer_grading_hide_question', {location: @location} + new_text = "(Show)" + else + Logger.log 'peer_grading_show_question', {location: @location} + new_text = "(Hide)" + @question_header.text(new_text) diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index 2bfe483a7f..b723f230e9 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -231,13 +231,14 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor // replace string and numerical xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) { var string; - var params = /(.*?)\+\-\s*(.*?$)/.exec(p); - if(parseFloat(p)) { + var floatValue = parseFloat(p); + if(!isNaN(floatValue)) { + var params = /(.*?)\+\-\s*(.*?$)/.exec(p); if(params) { - string = '\n'; + string = '\n'; string += ' \n'; } else { - string = '\n'; + string = '\n'; } string += ' \n'; string += '\n\n'; diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee new file mode 100644 index 0000000000..a27362b094 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee @@ -0,0 +1,103 @@ +class @VideoAlpha + constructor: (element) -> + @el = $(element).find('.video') + @id = @el.attr('id').replace(/video_/, '') + @start = @el.data('start') + @end = @el.data('end') + @caption_data_dir = @el.data('caption-data-dir') + @caption_asset_path = @el.data('caption-asset-path') + @show_captions = @el.data('show-captions').toString() == "true" + @el = $("#video_#{@id}") + if @parseYoutubeId(@el.data("streams")) is true + @videoType = "youtube" + @fetchMetadata() + @parseSpeed() + else + @videoType = "html5" + @parseHtml5Sources @el.data('mp4-source'), @el.data('webm-source'), @el.data('ogg-source') + @speeds = ['0.75', '1.0', '1.25', '1.50'] + sub = @el.data('sub') + if (typeof sub isnt "string") or (sub.length is 0) + sub = "" + @show_captions = false + @videos = + "0.75": sub + "1.0": sub + "1.25": sub + "1.5": sub + @setSpeed $.cookie('video_speed') + $("#video_#{@id}").data('video', this).addClass('video-load-complete') + if @show_captions is true + @hide_captions = $.cookie('hide_captions') == 'true' + else + @hide_captions = true + $.cookie('hide_captions', @hide_captions, expires: 3650, path: '/') + @el.addClass 'closed' + if ((@videoType is "youtube") and (YT.Player)) or ((@videoType is "html5") and (HTML5Video.Player)) + @embed() + else + if @videoType is "youtube" + window.onYouTubePlayerAPIReady = => + @embed() + else if @videoType is "html5" + window.onHTML5PlayerAPIReady = => + @embed() + + youtubeId: (speed)-> + @videos[speed || @speed] + + parseYoutubeId: (videos)-> + return false if (typeof videos isnt "string") or (videos.length is 0) + @videos = {} + $.each videos.split(/,/), (index, video) => + speed = undefined + video = video.split(/:/) + speed = parseFloat(video[0]).toFixed(2).replace(/\.00$/, ".0") + @videos[speed] = video[1] + true + + parseHtml5Sources: (mp4Source, webmSource, oggSource)-> + @html5Sources = + mp4: null + webm: null + ogg: null + @html5Sources.mp4 = mp4Source if (typeof mp4Source is "string") and (mp4Source.length > 0) + @html5Sources.webm = webmSource if (typeof webmSource is "string") and (webmSource.length > 0) + @html5Sources.ogg = oggSource if (typeof oggSource is "string") and (oggSource.length > 0) + + parseSpeed: -> + @speeds = ($.map @videos, (url, speed) -> speed).sort() + @setSpeed $.cookie('video_speed') + + setSpeed: (newSpeed, updateCookie)-> + if @speeds.indexOf(newSpeed) isnt -1 + @speed = newSpeed + + if updateCookie isnt false + $.cookie "video_speed", "" + newSpeed, + expires: 3650 + path: "/" + else + @speed = "1.0" + + embed: -> + @player = new VideoPlayerAlpha video: this + + fetchMetadata: (url) -> + @metadata = {} + $.each @videos, (speed, url) => + $.get "https://gdata.youtube.com/feeds/api/videos/#{url}?v=2&alt=jsonc", ((data) => @metadata[data.data.id] = data.data) , 'jsonp' + + getDuration: -> + @metadata[@youtubeId()].duration + + log: (eventName)-> + logInfo = + id: @id + code: @youtubeId() + currentTime: @player.currentTime + speed: @speed + if @videoType is "youtube" + logInfo.code = @youtubeId() + else logInfo.code = "html5" if @videoType is "html5" + Logger.log eventName, logInfo diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee new file mode 100644 index 0000000000..6b86296dfa --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee @@ -0,0 +1,14 @@ +class @SubviewAlpha + constructor: (options) -> + $.each options, (key, value) => + @[key] = value + @initialize() + @render() + @bind() + + $: (selector) -> + $(selector, @el) + + initialize: -> + render: -> + bind: -> diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js b/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js new file mode 100644 index 0000000000..c3cc462ab8 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js @@ -0,0 +1,294 @@ +this.HTML5Video = (function () { + var HTML5Video; + + HTML5Video = {}; + + HTML5Video.Player = (function () { + Player.prototype.callStateChangeCallback = function () { + if ($.isFunction(this.config.events.onStateChange) === true) { + this.config.events.onStateChange({ + 'data': this.playerState + }); + } + }; + + Player.prototype.pauseVideo = function () { + this.video.pause(); + }; + + Player.prototype.seekTo = function (value) { + if ((typeof value === 'number') && (value <= this.video.duration) && (value >= 0)) { + this.start = 0; + this.end = this.video.duration; + + this.video.currentTime = value; + } + }; + + Player.prototype.setVolume = function (value) { + if ((typeof value === 'number') && (value <= 100) && (value >= 0)) { + this.video.volume = value * 0.01; + } + }; + + Player.prototype.getCurrentTime = function () { + return this.video.currentTime; + }; + + Player.prototype.playVideo = function () { + this.video.play(); + }; + + Player.prototype.getPlayerState = function () { + return this.playerState; + }; + + Player.prototype.getVolume = function () { + return this.video.volume; + }; + + Player.prototype.getDuration = function () { + if (isFinite(this.video.duration) === false) { + return 0; + } + + return this.video.duration; + }; + + Player.prototype.setPlaybackRate = function (value) { + var newSpeed; + + newSpeed = parseFloat(value); + + if (isFinite(newSpeed) === true) { + this.video.playbackRate = value; + } + }; + + Player.prototype.getAvailablePlaybackRates = function () { + return [0.75, 1.0, 1.25, 1.5]; + }; + + return Player; + + /* + * Constructor function for HTML5 Video player. + * + * @el - A DOM element where the HTML5 player will be inserted (as returned by jQuery(selector) function), + * or a selector string which will be used to select an element. This is a required parameter. + * + * @config - An object whose properties will be used as configuration options for the HTML5 video + * player. This is an optional parameter. In the case if this parameter is missing, or some of the config + * object's properties are missing, defaults will be used. The available options (and their defaults) are as + * follows: + * + * config = { + * + * 'videoSources': {}, // An object with properties being video sources. The property name is the + * // video format of the source. Supported video formats are: 'mp4', 'webm', and + * // 'ogg'. + * + * 'playerVars': { // Object's properties identify player parameters. + * 'start': 0, // Possible values: positive integer. Position from which to start playing the + * // video. Measured in seconds. If value is non-numeric, or 'start' property is + * // not specified, the video will start playing from the beginning. + * + * 'end': null // Possible values: positive integer. Position when to stop playing the + * // video. Measured in seconds. If value is null, or 'end' property is not + * // specified, the video will end playing at the end. + * + * }, + * + * 'events': { // Object's properties identify the events that the API fires, and the + * // functions (event listeners) that the API will call when those events occur. + * // If value is null, or property is not specified, then no callback will be + * // called for that event. + * + * 'onReady': null, + * 'onStateChange': null + * } + * } + */ + function Player(el, config) { + var sourceStr, _this; + + // If el is string, we assume it is an ID of a DOM element. Get the element, and check that the ID + // really belongs to an element. If we didn't get a DOM element, return. At this stage, nothing will + // break because other parts of the video player are waiting for 'onReady' callback to be called. + if (typeof el === 'string') { + this.el = $(el); + + if (this.el.length === 0) { + return; + } + } else if (el instanceof jQuery) { + this.el = el; + } else { + return; + } + + // A simple test to see that the 'config' is a normal object. + if ($.isPlainObject(config) === true) { + this.config = config; + } else { + return; + } + + // We should have at least one video source. Otherwise there is no point to continue. + if (config.hasOwnProperty('videoSources') === false) { + return; + } + + // From the start, all sources are empty. We will populate this object below. + sourceStr = { + 'mp4': ' ', + 'webm': ' ', + 'ogg': ' ' + }; + + // Will be used in inner functions to point to the current object. + _this = this; + + // Create HTML markup for individual sources of the HTML5