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/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..b7ae181e99 100644 --- a/cms/.coveragerc +++ b/cms/.coveragerc @@ -1,7 +1,7 @@ # .coveragerc for cms [run] data_file = reports/cms/.coverage -source = cms +source = cms,common/djangoapps omit = cms/envs/*, cms/manage.py [report] 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/common.py b/cms/djangoapps/contentstore/features/common.py index d910d73085..f868b598a8 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -12,6 +12,8 @@ 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 @@ -20,32 +22,37 @@ def i_visit_the_studio_homepage(step): world.browser.visit(django_url('/')) assert world.browser.is_element_present_by_css('body.no-header', 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) ####### 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 +65,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 +78,32 @@ 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): world.browser.find_by_css(css).first.click() + def css_fill(css, value): world.browser.find_by_css(css).first.fill(value) + 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', @@ -108,24 +122,27 @@ 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)) + 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) + css_fill(name_css, name) css_click(save_css) + def add_subsection(name='Subsection One'): css = 'a.new-subsection-item' css_click(css) 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..d2d038a928 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) + @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..3bcaeab6c4 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -2,54 +2,65 @@ 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 +74,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.py b/cms/djangoapps/contentstore/features/signup.py index 7794511f94..e105b674f7 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,18 @@ 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): register_form = world.browser.find_by_css('form#register_form') register_form.find_by_value(button).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..bb38e72d44 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -15,6 +15,7 @@ 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''' @@ -35,6 +36,3 @@ class Command(BaseCommand): print 'removing User permissions from course....' # in the django layer, we need to remove all the user permissions groups associated with this course _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 cb9f451d38..d15610f11c 100644 --- a/cms/djangoapps/contentstore/tests/factories.py +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -1,117 +1,49 @@ from factory import Factory -from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from time import gmtime +from datetime import datetime from uuid import uuid4 -from xmodule.timeparse import stringify_time +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 - - template = 'i4x://edx/templates/course/Empty' - org = 'MITx' - number = '999' - display_name = 'Robot Super Course' - -class XModuleItemFactory(Factory): - """ - Factory for XModule items. - """ - - ABSTRACT_FACTORY = True - _creation_function = (XMODULE_ITEM_CREATION,) - - @classmethod - def _create(cls, target_class, *args, **kwargs): - """ - kwargs must include parent_location, template. Can contain display_name - target_class is ignored - """ - - 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') - - store = modulestore('direct') - - # This code was based off that in cms/djangoapps/contentstore/views.py - parent = store.get_item(parent_location) - dest_location = parent_location._replace(category=template.category, name=uuid4().hex) - - new_item = store.clone_item(template, dest_location) - - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - - # replace the display name with an optional parameter passed in from the caller - if display_name is not None: - new_item.metadata['display_name'] = display_name - - store.update_metadata(new_item.location.url(), new_item.own_metadata) - - if new_item.location.category not in DETACHED_CATEGORIES: - store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) - - 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 + 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..e3680018e3 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -0,0 +1,442 @@ +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 tempfile import mkdtemp +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) + + 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') + + # check for graiding_policy.json + fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + self.assertTrue(fs.exists('grading_policy.json')) + + # 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()) + course = ms.get_item(location) + self.assertEqual(on_disk, course.definition['data']['grading_policy']) + + # 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, + '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 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") + + +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', 'Empty') + + 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..925b2431b9 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,43 +1,56 @@ -from django.test.testcases import TestCase import datetime import time +import json +import calendar +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 + +import xmodule +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 + # 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 +65,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 +87,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 +101,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 +119,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 +130,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 +140,26 @@ 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 })) + + 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 +172,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 +184,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 +212,56 @@ 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") - - 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..166982e35f 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -1,48 +1,34 @@ 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 +85,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 +172,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..be028b2836 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -0,0 +1,66 @@ +import json +import copy +from time import time +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 the current seconds since epoch to differentiate + # the mongo collections on jenkins. + sec_since_epoch = '%s' % int(time() * 100) + self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) + self.test_MODULESTORE = self.orig_MODULESTORE + self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch + self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch + 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..b14dd8b353 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 @@ -85,6 +88,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 +103,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 +111,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 +141,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 +149,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 f70164138d..87a2943773 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 @@ -81,6 +81,7 @@ def signup(request): csrf_token = csrf(request)['csrf_token'] return render_to_response('signup.html', {'csrf': csrf_token}) + @ssl_login_shortcut @ensure_csrf_cookie def login_page(request): @@ -114,14 +115,15 @@ 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])) 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 +134,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 +156,15 @@ 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 + upload_asset_callback_url = reverse('upload_asset', kwargs={ + 'org': org, + 'course': course, + 'coursename': name }) course = modulestore().get_item(location) @@ -213,7 +215,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 +235,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 }) @@ -291,10 +293,10 @@ 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 @@ -302,12 +304,12 @@ def edit_unit(request, location): preview_lms_link = '//{preview}{lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format( preview='preview.', - lms_base=settings.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) @@ -348,6 +350,7 @@ def preview_component(request, location): 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), }) + @expect_json @login_required @ensure_csrf_cookie @@ -358,14 +361,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: @@ -473,7 +476,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, ) @@ -510,24 +513,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()) @@ -554,7 +557,7 @@ def _xmodule_recurse(item, action): _xmodule_recurse(child, action) action(item) - + @login_required @expect_json @@ -588,8 +591,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() @@ -608,7 +611,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 @@ -664,6 +667,7 @@ def create_draft(request): return HttpResponse() + @login_required @expect_json def publish_draft(request): @@ -693,12 +697,13 @@ def unpublish_unit(request): return HttpResponse() + @login_required @expect_json 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): @@ -725,6 +730,8 @@ def clone_item(request): #@login_required #@ensure_csrf_cookie + + def upload_asset(request, org, course, coursename): ''' cdodge: this method allows for POST uploading of files into the course asset library, which will @@ -738,9 +745,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: @@ -774,12 +781,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)) @@ -789,10 +796,12 @@ def upload_asset(request, org, course, coursename): ''' 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() @@ -803,16 +812,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): + +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'})) @@ -822,21 +831,23 @@ def create_json_response(errmsg = None): This POST-back view will add a user - specified by email - to the list of editors for the specified course ''' + + @expect_json @login_required @ensure_csrf_cookie 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)) @@ -854,12 +865,14 @@ def add_user(request, location): This POST-back view will remove a user - specified by email - from the list of editors for the specified course ''' + + @expect_json @login_required @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() @@ -881,12 +894,13 @@ def remove_user(request, location): def landing(request, org, course, coursename): return render_to_response('temp-course-landing.html', {}) + @login_required @ensure_csrf_cookie 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() @@ -915,13 +929,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: @@ -934,15 +948,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() @@ -951,7 +965,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) @@ -980,10 +994,11 @@ 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 }) + def not_found(request): return render_to_response('error.html', {'error': '404'}) @@ -1001,24 +1016,25 @@ 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 @@ -1032,7 +1048,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 == '': @@ -1047,7 +1063,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 @@ -1064,7 +1080,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() @@ -1075,12 +1091,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") @@ -1089,6 +1105,7 @@ def module_info(request, module_location): else: return HttpResponseBadRequest() + @login_required @ensure_csrf_cookie def get_course_settings(request, org, course, name): @@ -1098,20 +1115,21 @@ 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', + 'active_tab': 'settings', 'context_course': course_module, - 'course_details' : json.dumps(course_details, cls=CourseSettingsEncoder) + 'course_details': json.dumps(course_details, cls=CourseSettingsEncoder) }) - + + @expect_json @login_required @ensure_csrf_cookie @@ -1134,15 +1152,16 @@ 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 @login_required @ensure_csrf_cookie @@ -1153,7 +1172,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 @@ -1164,17 +1183,17 @@ 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") @@ -1187,20 +1206,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) @@ -1214,15 +1233,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', { @@ -1237,13 +1256,23 @@ def asset_index(request, org, course, name): def edge(request): return render_to_response('university_profiles/edge.html', {}) + @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)) @@ -1283,19 +1312,24 @@ def create_new_course(request): return HttpResponse(json.dumps({'id': new_course.location.url()})) + def initialize_course_tabs(course): # set up the default tabs # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or # 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 @@ -1335,7 +1369,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 @@ -1349,10 +1383,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) @@ -1368,12 +1402,13 @@ 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]) }) + @ensure_csrf_cookie @login_required def generate_export_course(request, org, course, name): @@ -1383,12 +1418,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) @@ -1396,11 +1431,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') @@ -1422,12 +1457,13 @@ 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): ''' 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/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 48cfa3cf9a..a147f84531 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -52,10 +52,6 @@ LOGGING = get_logger_config(LOG_DIR, debug=False, service_variant=SERVICE_VARIANT) -with open(ENV_ROOT / "repos.json") as repos_file: - REPOS = json.load(repos_file) - - ################ SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: diff --git a/cms/envs/common.py b/cms/envs/common.py index f2d47dfdc6..30aac6ea01 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -33,8 +33,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 @@ -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 @@ -229,7 +222,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': { @@ -285,4 +278,5 @@ INSTALLED_APPS = ( # 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 d9a2597cbb..7f39e6818b 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" @@ -63,7 +62,7 @@ CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'OPTIONS': { 'host': 'localhost', - 'db' : 'xcontent', + 'db': 'xcontent', } } @@ -72,23 +71,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': { @@ -115,4 +103,4 @@ CACHES = { PASSWORD_HASHERS = ( 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', -) \ No newline at end of file +) 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/js/views/overview.js b/cms/static/js/views/overview.js index 8cbae177a8..7d92ab69ad 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -58,6 +58,9 @@ $(document).ready(function() { drop: onSectionReordered, greedy: true }); + + // stop clicks on drag bars from doing their thing w/o stopping drag + $('.drag-handle').click(function(e) {e.preventDefault(); }); }); @@ -202,13 +205,17 @@ function _handleReorder(event, ui, parentIdField, childrenSelector) { children = _.without(children, ui.draggable.data('id')); } // add to this parent (figure out where) - for (var i = 0; i < _els.length; i++) { - if (!ui.draggable.is(_els[i]) && ui.offset.top < $(_els[i]).offset().top) { + for (var i = 0, bump = 0; i < _els.length; i++) { + if (ui.draggable.is(_els[i])) { + bump = -1; // bump indicates that the draggable was passed in the dom but not children's list b/c + // it's not in that list + } + else if (ui.offset.top < $(_els[i]).offset().top) { // insert at i in children and _els ui.draggable.insertBefore($(_els[i])); // TODO figure out correct way to have it remove the style: top:n; setting (and similar line below) ui.draggable.attr("style", "position:relative;"); - children.splice(i, 0, ui.draggable.data('id')); + children.splice(i + bump, 0, ui.draggable.data('id')); break; } } diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 826b385dff..f4c7df41a6 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -227,7 +227,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ time = 0; } var newVal = new Date(date.getTime() + time * 1000); - if (cacheModel.get(fieldName).getTime() !== newVal.getTime()) { + if (!cacheModel.has(fieldName) || cacheModel.get(fieldName).getTime() !== newVal.getTime()) { cacheModel.save(fieldName, newVal, { error: CMS.ServerError}); } } diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index de5e14e0a9..d81f577940 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -107,6 +107,8 @@ + + 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/test_customrender.py b/common/lib/capa/capa/tests/test_customrender.py index 7208ab2941..eece275b05 100644 --- a/common/lib/capa/capa/tests/test_customrender.py +++ b/common/lib/capa/capa/tests/test_customrender.py @@ -8,6 +8,7 @@ from capa import customrender # just a handy shortcut lookup_tag = customrender.registry.get_class_for_tag + def extract_context(xml): """ Given an xml element corresponding to the output of test_system.render_template, get back the @@ -15,9 +16,11 @@ def extract_context(xml): """ return eval(xml.text) + def quote_attr(s): return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes + class HelperTest(unittest.TestCase): ''' Make sure that our helper function works! @@ -50,7 +53,7 @@ class SolutionRenderTest(unittest.TestCase): # our test_system "renders" templates to a div with the repr of the context xml = renderer.get_html() context = extract_context(xml) - self.assertEqual(context, {'id' : 'solution_12'}) + self.assertEqual(context, {'id': 'solution_12'}) class MathRenderTest(unittest.TestCase): @@ -65,12 +68,11 @@ class MathRenderTest(unittest.TestCase): renderer = lookup_tag('math')(test_system, element) self.assertEqual(renderer.mathstr, mathjax_out) - + def test_parsing(self): self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]') self.check_parse('$abc', '$abc') self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]') - + # NOTE: not testing get_html yet because I don't understand why it's doing what it's doing. - 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/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_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..18da338b91 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -16,6 +16,7 @@ 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" @@ -295,16 +296,16 @@ class CodeResponseTest(unittest.TestCase): old_cmap = CorrectMap() for i, answer_id in enumerate(answer_ids): queuekey = 1000 + i - queuestate = CodeResponseTest.make_queuestate(1000+i, datetime.now()) + queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now()) old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate)) # 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}) + 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': incorrect_score_msg, } # Incorrect queuekey, state should not be updated for correctness in ['correct', 'incorrect']: @@ -325,7 +326,7 @@ class CodeResponseTest(unittest.TestCase): new_cmap = CorrectMap() new_cmap.update(old_cmap) - npoints = 1 if correctness=='correct' else 0 + 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) @@ -361,7 +362,7 @@ class CodeResponseTest(unittest.TestCase): for i, answer_id in enumerate(answer_ids): queuekey = 1000 + i latest_timestamp = datetime.now() - queuestate = CodeResponseTest.make_queuestate(1000+i, latest_timestamp) + queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp) cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate)) test_lcp.correct_map.update(cmap) @@ -412,6 +413,7 @@ class ChoiceResponseTest(unittest.TestCase): 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') + class JavascriptResponseTest(unittest.TestCase): def test_jr_grade(self): @@ -424,4 +426,3 @@ class JavascriptResponseTest(unittest.TestCase): 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') - 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/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/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/xmodule/setup.py b/common/lib/xmodule/setup.py index 061e93fd21..ec369420cd 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -27,13 +27,17 @@ setup( "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", 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 8d04daa563..d806ec7913 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 @@ -356,7 +371,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): ''' @@ -477,7 +492,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]} @@ -548,9 +563,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() @@ -583,7 +598,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 @@ -694,7 +709,7 @@ class CapaDescriptor(RawDescriptor): @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 + subset = super(CapaDescriptor, self).editable_metadata_fields if 'markdown' in subset: subset.remove('markdown') 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 14a59c9004..2da15a4086 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -19,44 +19,17 @@ from .stringify import stringify_children 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 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), +) -#The highest score allowed for the overall xmodule and for each rubric point -MAX_SCORE_ALLOWED = 3 - -#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress -#Metadata overrides this. -IS_SCORED = False - -#If true, then default behavior is to require a file upload or pasted link from a student for this problem. -#Metadata overrides this. -ACCEPT_FILE_UPLOAD = False - -#Contains all reasonable bool and case combinations of True -TRUE_DICT = ["True", True, "TRUE", "true"] - -HUMAN_TASK_TYPE = { - 'selfassessment' : "Self Assessment", - 'openended' : "External Grader", -} - -class IncorrectMaxScoreError(Exception): - def __init__(self, msg): - self.msg = msg +DEFAULT_VERSION = 1 +DEFAULT_VERSION = str(DEFAULT_VERSION) class CombinedOpenEndedModule(XModule): """ @@ -137,512 +110,68 @@ 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) + if not isinstance(self.version, basestring): + try: + self.version = str(self.version) + except: + log.error("Version {0} is not correct. Going with version {1}".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) + try: + version_index = versions.index(self.version) + except: + log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION)) + self.version = DEFAULT_VERSION + version_index = versions.index(self.version) - #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)) - self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT - self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT - - # 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)) - - if self._max_score > MAX_SCORE_ALLOWED: - error_message = "Max score {0} is higher than max score allowed {1} for location {2}".format(self._max_score, - MAX_SCORE_ALLOWED, location) - log.error(error_message) - raise IncorrectMaxScoreError(error_message) - - rubric_renderer = CombinedOpenEndedRubric(system, True) - rubric_string = stringify_children(definition['rubric']) - rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED) - - #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, - 'accept_file_upload': self.accept_file_upload, + 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': 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) - - 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, - 'accept_file_upload': self.accept_file_upload, - } - - return context + self.child_descriptor = descriptors[version_index](self.system) + self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['xml_string']), 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 - if task_type in HUMAN_TASK_TYPE: - human_task_name = HUMAN_TASK_TYPE[task_type] - else: - human_task_name = task_type - - if state in task.HUMAN_NAMES: - human_state = task.HUMAN_NAMES[state] - else: - human_state = 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': human_state, - 'human_task': human_task_name, - '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. - """ - - 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, - } - - return json.dumps(state) - - 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) - - return status_html - - def check_if_done_and_scored(self): - """ - Checks if the object is currently in a finished state (either student didn't meet criteria to move - to next step, in which case they are in the allow_reset state, or they are done with the question - entirely, in which case they will be in the self.DONE state), and if it is scored or not. - @return: Boolean corresponding to the above. - """ - return (self.state == self.DONE or self.allow_reset) and self.is_scored + return self.child_module.get_instance_state() def get_score(self): - """ - Score the student received on the problem, or None if there is no - score. - - Returns: - dictionary - {'score': integer, from 0 to get_max_score(), - 'total': get_max_score()} - """ - max_score = None - score = None - if self.check_if_done_and_scored(): - last_response = self.get_last_response(self.current_task_number) - max_score = last_response['max_score'] - score = last_response['score'] - - score_dict = { - 'score': score, - 'total': max_score, - } - - return score_dict + return self.child_module.get_score() def max_score(self): - ''' Maximum score. Two notes: - - * This is generic; in abstract, a problem could be 3/5 points on one - randomization, and 5/7 on another - ''' - max_score = None - if self.check_if_done_and_scored(): - last_response = self.get_last_response(self.current_task_number) - max_score = last_response['max_score'] - return max_score + return self.child_module.max_score() def get_progress(self): - ''' Return a progress.Progress object that represents how far the - student has gone in this module. Must be implemented to get correct - progress tracking behavior in nesting modules like sequence and - vertical. + return self.child_module.get_progress() - If this module has no notion of progress, return None. - ''' - progress_object = Progress(self.current_task_number, len(self.task_xml)) + @property + def due_date(self): + return self.child_module.due_date - return progress_object + @property + def display_name(self): + return self.child_module.display_name class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): @@ -672,20 +201,8 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): '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')} + return {'xml_string' : etree.tostring(xml_object), 'metadata' : xml_object.attrib} def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py new file mode 100644 index 0000000000..8bd7df86c1 --- /dev/null +++ b/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py @@ -0,0 +1,725 @@ +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 .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 +import dateutil +import dateutil.parser +import datetime +from timeparse import parse_timedelta + +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 + +#The highest score allowed for the overall xmodule and for each rubric point +MAX_SCORE_ALLOWED = 3 + +#If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress +#Metadata overrides this. +IS_SCORED = False + +#If true, then default behavior is to require a file upload or pasted link from a student for this problem. +#Metadata overrides this. +ACCEPT_FILE_UPLOAD = False + +#Contains all reasonable bool and case combinations of True +TRUE_DICT = ["True", True, "TRUE", "true"] + +HUMAN_TASK_TYPE = { + 'selfassessment' : "Self Assessment", + 'openended' : "External Grader", + } + +class CombinedOpenEndedV1Module(): + """ + This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). + It transitions between problems, and support arbitrary ordering. + Each combined open ended module contains one or multiple "child" modules. + Child modules track their own state, and can transition between states. They also implement get_html and + handle_ajax. + The combined open ended module transitions between child modules as appropriate, tracks its own state, and passess + ajax requests from the browser to the child module or handles them itself (in the cases of reset and next problem) + ajax actions implemented by all children are: + 'save_answer' -- Saves the student answer + 'save_assessment' -- Saves the student assessment (or external grader assessment) + 'save_post_assessment' -- saves a post assessment (hint, feedback on feedback, etc) + ajax actions implemented by combined open ended module are: + 'reset' -- resets the whole combined open ended module and returns to the first child module + 'next_problem' -- moves to the next child module + 'get_results' -- gets results from a given child module + + Types of children. Task is synonymous with child module, so each combined open ended module + incorporates multiple children (tasks): + openendedmodule + selfassessmentmodule + """ + STATE_VERSION = 1 + + # states + INITIAL = 'initial' + ASSESSING = 'assessing' + INTERMEDIATE_DONE = 'intermediate_done' + DONE = 'done' + + js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'), + resource_string(__name__, 'js/src/collapsible.coffee'), + resource_string(__name__, 'js/src/javascript_loader.coffee'), + ]} + js_module_name = "CombinedOpenEnded" + + css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} + + def __init__(self, system, location, definition, descriptor, + instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs): + + """ + Definition file should have one or many task blocks, a rubric block, and a prompt block: + + Sample file: + + + Blah blah rubric. + + + Some prompt. + + + + + What hint about this problem would you give to someone? + + + Save Succcesful. Thanks for participating! + + + + + + + Enter essay here. + This is the answer. + {"grader_settings" : "ml_grading.conf", + "problem_id" : "6.002x/Welcome/OETest"} + + + + + + """ + + self.metadata = metadata + self.display_name = metadata.get('display_name', "Open Ended") + self.rewrite_content_links = static_data.get('rewrite_content_links',"") + + + # 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.system = system + + #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) + + 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)) + self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT + self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT + + display_due_date_string = self.metadata.get('due', None) + if display_due_date_string is not None: + try: + self.display_due_date = dateutil.parser.parse(display_due_date_string) + except ValueError: + log.error("Could not parse due date {0} for location {1}".format(display_due_date_string, location)) + raise + else: + self.display_due_date = None + + grace_period_string = self.metadata.get('graceperiod', None) + if grace_period_string is not None and self.display_due_date: + try: + self.grace_period = parse_timedelta(grace_period_string) + self.close_date = self.display_due_date + self.grace_period + except: + log.error("Error parsing the grace period {0} for location {1}".format(grace_period_string, location)) + raise + else: + self.grace_period = None + self.close_date = self.display_due_date + + # 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) + rubric_string = stringify_children(definition['rubric']) + rubric_renderer.check_if_rubric_is_parseable(rubric_string, location, MAX_SCORE_ALLOWED, self._max_score) + + #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, + 'accept_file_upload': self.accept_file_upload, + 'close_date' : self.close_date, + } + + self.task_xml = definition['task_xml'] + self.location = location + 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': 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) + + 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, + 'accept_file_upload': self.accept_file_upload, + } + + return context + + 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 + if task_type in HUMAN_TASK_TYPE: + human_task_name = HUMAN_TASK_TYPE[task_type] + else: + human_task_name = task_type + + if state in task.HUMAN_NAMES: + human_state = task.HUMAN_NAMES[state] + else: + human_state = 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': human_state, + 'human_task': human_task_name, + '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} + + 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()} + + 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. + """ + + 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, + } + + return json.dumps(state) + + 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) + + return status_html + + def check_if_done_and_scored(self): + """ + Checks if the object is currently in a finished state (either student didn't meet criteria to move + to next step, in which case they are in the allow_reset state, or they are done with the question + entirely, in which case they will be in the self.DONE state), and if it is scored or not. + @return: Boolean corresponding to the above. + """ + return (self.state == self.DONE or self.allow_reset) and self.is_scored + + def get_score(self): + """ + Score the student received on the problem, or None if there is no + score. + + Returns: + dictionary + {'score': integer, from 0 to get_max_score(), + 'total': get_max_score()} + """ + max_score = None + score = None + if self.check_if_done_and_scored(): + last_response = self.get_last_response(self.current_task_number) + max_score = last_response['max_score'] + score = last_response['score'] + + score_dict = { + 'score': score, + 'total': max_score, + } + + return score_dict + + def max_score(self): + ''' Maximum score. Two notes: + + * This is generic; in abstract, a problem could be 3/5 points on one + randomization, and 5/7 on another + ''' + max_score = None + if self.check_if_done_and_scored(): + last_response = self.get_last_response(self.current_task_number) + max_score = last_response['max_score'] + return max_score + + def get_progress(self): + ''' Return a progress.Progress object that represents how far the + student has gone in this module. Must be implemented to get correct + progress tracking behavior in nesting modules like sequence and + vertical. + + If this module has no notion of progress, return None. + ''' + progress_object = Progress(self.current_task_number, len(self.task_xml)) + + return progress_object + + +class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): + """ + Module for adding combined open ended questions + """ + mako_template = "widgets/html-edit.html" + module_class = CombinedOpenEndedV1Module + filename_extension = "xml" + + stores_state = True + 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 \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/combined_open_ended_rubric.py index f8b23a2774..689103a86a 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_rubric.py @@ -1,12 +1,14 @@ import logging from lxml import etree -log=logging.getLogger(__name__) +log = logging.getLogger(__name__) + class RubricParsingError(Exception): def __init__(self, msg): self.msg = msg + class CombinedOpenEndedRubric(object): def __init__ (self, system, view_only = False): @@ -27,16 +29,21 @@ class CombinedOpenEndedRubric(object): success = False try: rubric_categories = self.extract_categories(rubric_xml) + max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) + max_score = max(max_scores) html = self.system.render_template('open_ended_rubric.html', - {'categories' : rubric_categories, + {'categories': rubric_categories, 'has_score': self.has_score, - 'view_only': self.view_only}) + 'view_only': self.view_only, + 'max_score': max_score}) success = True except: - raise RubricParsingError("[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml)) + error_message = "[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml) + log.error(error_message) + raise RubricParsingError(error_message) return success, html - def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed): + def check_if_rubric_is_parseable(self, rubric_string, location, max_score_allowed, max_score): success, rubric_feedback = self.render_rubric(rubric_string) if not success: error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url()) @@ -44,13 +51,21 @@ class CombinedOpenEndedRubric(object): raise RubricParsingError(error_message) rubric_categories = self.extract_categories(rubric_string) + total = 0 for category in rubric_categories: + total = total + len(category['options']) - 1 if len(category['options']) > (max_score_allowed + 1): error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}".format( len(category['options']), max_score_allowed) log.error(error_message) raise RubricParsingError(error_message) + if total != max_score: + error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}".format( + max_score, location, total) + log.error(error_msg) + raise RubricParsingError(error_msg) + def extract_categories(self, element): ''' Contstruct a list of categories such that the structure looks like: @@ -58,8 +73,8 @@ class CombinedOpenEndedRubric(object): 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}, + options: [{text: "Option 1 Name", points: 0}, + {text: "Option 2 Name", points: 1}, {text: "Option 3 Name", points: 2]}] ''' @@ -75,7 +90,7 @@ class CombinedOpenEndedRubric(object): def extract_category(self, category): - ''' + ''' construct an individual category {category: "Category 1 Name", options: [{text: "Option 1 text", points: 1}, @@ -108,7 +123,7 @@ class CombinedOpenEndedRubric(object): autonumbering = True # parse options for option in optionsxml: - if option.tag != 'option': + if option.tag != 'option': raise RubricParsingError("[extract_category]: expected option tag, got {0} instead".format(option.tag)) else: pointstr = option.get("points") @@ -125,7 +140,7 @@ class CombinedOpenEndedRubric(object): 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}) diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index e20681e614..787d355c4a 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -9,12 +9,13 @@ 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: - + @@ -37,13 +38,17 @@ class ConditionalModule(XModule): 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.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): @@ -56,7 +61,7 @@ class ConditionalModule(XModule): def is_condition_satisfied(self): self._get_required_modules() - if self.condition=='require_completed': + 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: @@ -70,7 +75,7 @@ class ConditionalModule(XModule): else: log.debug('conditional module: %s IS completed' % module) return True - elif self.condition=='require_attempted': + 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: @@ -111,9 +116,10 @@ class ConditionalModule(XModule): # for now, just deal with one child html = self.contents[0] - + return json.dumps({'html': html}) + class ConditionalDescriptor(SequenceDescriptor): module_class = ConditionalModule @@ -125,17 +131,23 @@ class ConditionalDescriptor(SequenceDescriptor): 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('&')] + required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')] self.required_module_locations = [] - for (tag, name) in required_module_list: + 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..2c69c449ba 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 @@ -377,7 +377,7 @@ class CourseDescriptor(SequenceDescriptor): 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 +436,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 +501,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 +636,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 +648,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 +704,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 +719,3 @@ class CourseDescriptor(SequenceDescriptor): @property def org(self): return self.location.org - diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 41896e6173..8d921f828b 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -231,47 +231,6 @@ div.result-container { } } -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,12 +401,12 @@ section.open-ended-child { margin: 10px; } - span.short-form-response { - padding: 9px; + div.short-form-response { background: #F6F6F6; border: 1px solid #ddd; - border-top: 0; margin-bottom: 20px; + overflow-y: auto; + height: 200px; @include clearfix; } @@ -585,11 +544,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..956923c6d0 100644 --- a/common/lib/xmodule/xmodule/css/html/display.scss +++ b/common/lib/xmodule/xmodule/css/html/display.scss @@ -52,13 +52,17 @@ em, i { } strong, b { - font-style: bold; + 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/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/lms/djangoapps/open_ended_grading/grading_service.py b/common/lib/xmodule/xmodule/grading_service_module.py similarity index 77% rename from lms/djangoapps/open_ended_grading/grading_service.py rename to common/lib/xmodule/xmodule/grading_service_module.py index 63febb105f..10c6f16adb 100644 --- a/lms/djangoapps/open_ended_grading/grading_service.py +++ b/common/lib/xmodule/xmodule/grading_service_module.py @@ -5,22 +5,16 @@ import requests from requests.exceptions import RequestException, ConnectionError, HTTPError import sys -from django.conf import settings -from django.http import HttpResponse, Http404 - -from courseware.access import has_access -from util.json_request import expect_json -from xmodule.course_module import CourseDescriptor from xmodule.combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError from lxml import etree -from mitxmako.shortcuts import render_to_string -from xmodule.x_module import ModuleSystem log = logging.getLogger(__name__) + class GradingServiceError(Exception): pass + class GradingService(object): """ Interface to staff grading backend. @@ -31,7 +25,7 @@ class GradingService(object): self.url = config['url'] self.login_url = self.url + '/login/' self.session = requests.session() - self.system = ModuleSystem(None, None, None, render_to_string, None) + self.system = config['system'] def _login(self): """ @@ -42,20 +36,20 @@ class GradingService(object): Returns the decoded json dict of the response. """ response = self.session.post(self.login_url, - {'username': self.username, - 'password': self.password,}) + {'username': self.username, + 'password': self.password, }) response.raise_for_status() return response.json - def post(self, url, data, allow_redirects=False): + def post(self, url, data, allow_redirects=False): """ Make a post request to the grading controller """ try: op = lambda: self.session.post(url, data=data, - allow_redirects=allow_redirects) + allow_redirects=allow_redirects) r = self._try_with_login(op) except (RequestException, ConnectionError, HTTPError) as err: # reraise as promised GradingServiceError, but preserve stacktrace. @@ -69,8 +63,8 @@ class GradingService(object): """ log.debug(params) op = lambda: self.session.get(url, - allow_redirects=allow_redirects, - params=params) + allow_redirects=allow_redirects, + params=params) try: r = self._try_with_login(op) except (RequestException, ConnectionError, HTTPError) as err: @@ -78,7 +72,7 @@ class GradingService(object): raise GradingServiceError, str(err), sys.exc_info()[2] return r.text - + def _try_with_login(self, operation): """ @@ -96,8 +90,8 @@ class GradingService(object): r = self._login() if r and not r.get('success'): log.warning("Couldn't log into staff_grading backend. Response: %s", - r) - # try again + r) + # try again response = operation() response.raise_for_status() @@ -113,23 +107,23 @@ class GradingService(object): """ try: response_json = json.loads(response) + except: + response_json = response + + try: if 'rubric' in response_json: rubric = response_json['rubric'] - rubric_renderer = CombinedOpenEndedRubric(self.system, False) + rubric_renderer = CombinedOpenEndedRubric(self.system, view_only) success, rubric_html = rubric_renderer.render_rubric(rubric) response_json['rubric'] = rubric_html return response_json - # if we can't parse the rubric into HTML, + # if we can't parse the rubric into HTML, except etree.XMLSyntaxError, RubricParsingError: log.exception("Cannot parse rubric string. Raw string: {0}" - .format(rubric)) + .format(rubric)) return {'success': False, - 'error': 'Error displaying submission'} + 'error': 'Error displaying submission'} except ValueError: log.exception("Error parsing response: {0}".format(response)) return {'success': False, - 'error': "Error displaying submission"} - - - - + 'error': "Error displaying submission"} 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..af1ce0ad80 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 @@ -180,6 +180,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 +188,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/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 5890686c0e..57ff85298c 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 @@ -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' @@ -273,7 +276,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 +289,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 52fd4c2547..ae63171ed4 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -1,3 +1,36 @@ +class @Rubric + constructor: () -> + + # finds the scores for each rubric category + @get_score_list: () => + # find the number of categories: + num_categories = $('table.rubric tr').length + + score_lst = [] + # get the score for each one + for i in [0..(num_categories-2)] + 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 = $('table.rubric tr').length + # -2 because we want to skip the header + for i in [0..(num_categories-2)] + score = $("input[name='score-selection-#{i}']:checked").val() + if score == undefined + return false + return true + class @CombinedOpenEnded constructor: (element) -> @element=element @@ -222,9 +255,9 @@ class @CombinedOpenEnded 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() + data = {'assessment' : checked_assessment} $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) => if response.success @child_state = response.state @@ -329,7 +362,7 @@ class @CombinedOpenEnded $.postWithPrefix "#{@ajax_url}/check_for_score", (response) => if response.state == "done" or response.state=="post_assessment" delete window.queuePollerID - @reload + location.reload() else window.queuePollerID = window.setTimeout(@poll, 10000) @@ -351,7 +384,7 @@ class @CombinedOpenEnded answer_id = @answer_area.attr('id') answer_val = @answer_area.val() new_text = '' - new_text = "#{answer_val}" + new_text = "
          #{answer_val}
          " @answer_area.replaceWith(new_text) # wrap this so that it can be mocked diff --git a/common/lib/xmodule/xmodule/js/src/html/edit.coffee b/common/lib/xmodule/xmodule/js/src/html/edit.coffee index 07e6163f25..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 - @element.on('click', '.editor-tabs .tab', this, @onSwitchEditor) + # 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') - @setupTinyMCE: (ed) -> + @element.on('click', '.editor-tabs .tab', @onSwitchEditor) + + setupTinyMCE: (ed) => ed.addButton('wrapAsCode', { title : 'Code', image : '/static/images/ico-tinymce-code.png', @@ -67,22 +73,23 @@ class @HTMLEditingDescriptor command.setActive('wrapAsCode', e.nodeName == 'CODE') ) - onSwitchEditor: (e)=> + @visualEditor = ed + + onSwitchEditor: (e) => e.preventDefault(); - if not $(e.currentTarget).hasClass('current') - element = e.data.element + $currentTarget = $(e.currentTarget) + if not $currentTarget.hasClass('current') + $currentTarget.addClass('current') + @$mceToolbar.toggleClass(HTMLEditingDescriptor.isInactiveClass) + @$advancedEditorWrapper.toggleClass(HTMLEditingDescriptor.isInactiveClass) - $(e.currentTarget).addClass('current') - $(element).find('table.mceToolbar').toggleClass(HTMLEditingDescriptor.isInactiveClass) - $(@advanced_editor.getWrapperElement()).toggleClass(HTMLEditingDescriptor.isInactiveClass) - - visualEditor = @getVisualEditor(element) - if $(e.currentTarget).attr('data-tab') is 'visual' - $(element).find('.html-tab').removeClass('current') + visualEditor = @getVisualEditor() + if $currentTarget.data('tab') is 'visual' + @$htmlTab.removeClass('current') @showVisualEditor(visualEditor) else - $(element).find('.visual-tab').removeClass('current') + @$visualTab.removeClass('current') @showAdvancedEditor(visualEditor) # Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing. @@ -100,24 +107,29 @@ 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: (element) -> + 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($(element).find('.tiny-mce').attr('id')) + return @visualEditor save: -> @element.off('click', '.editor-tabs .tab', @onSwitchEditor) text = @advanced_editor.getValue() - visualEditor = @getVisualEditor(@element) + visualEditor = @getVisualEditor() if @showingVisualEditor and visualEditor.isDirty() text = visualEditor.getContent({no_events: 1}) data: text 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/lms/static/coffee/src/peer_grading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee similarity index 50% rename from lms/static/coffee/src/peer_grading/peer_grading_problem.coffee rename to common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index c4b87eb30e..deeb82900b 100644 --- a/lms/static/coffee/src/peer_grading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -7,7 +7,7 @@ # Should not be run when we don't have a location to send back # to the server # -# PeerGradingProblemBackend - +# PeerGradingProblemBackend - # makes all the ajax requests and provides a mock interface # for testing purposes # @@ -15,7 +15,7 @@ # handles the rendering and user interactions with the interface # ################################## -class PeerGradingProblemBackend +class @PeerGradingProblemBackend constructor: (ajax_url, mock_backend) -> @mock_backend = mock_backend @ajax_url = ajax_url @@ -32,141 +32,140 @@ class PeerGradingProblemBackend mock: (cmd, data) -> if cmd == 'is_student_calibrated' # change to test each version - response = - success: true + response = + success: true calibrated: @mock_cnt >= 2 else if cmd == 'show_calibration_essay' - #response = + #response = # success: false # error: "There was an error" @mock_cnt++ - response = + 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. + 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. - ''' + 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.

          - ''' +

          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 - - - - - - - -
          - ''' + + + + + + + + + + + + + + + + + + +
          Purpose + + + + + + + +
          Organization + + + + + + + +
          + ''' max_score: 4 else if cmd == 'get_next_submission' - response = + 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. + 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. ''' + 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.

          - ''' +

          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 - - - - - - - -
          - ''' + + + + + + + + + + + + + + + + + + +
          Purpose + + + + + + + +
          Organization + + + + + + + +
          + ''' max_score: 4 else if cmd == 'save_calibration_essay' - response = + response = success: true actual_score: 2 else if cmd == 'save_grade' - response = + response = success: true return response - -class PeerGradingProblem +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 + # prevent this code from trying to run # when we don't have a location if(!@location) return @@ -175,6 +174,7 @@ class PeerGradingProblem @submission_container = $('.submission-container') @prompt_container = $('.prompt-container') @rubric_container = $('.rubric-container') + @flag_student_container = $('.flag-student-container') @calibration_panel = $('.calibration-panel') @grading_panel = $('.grading-panel') @content_panel = $('.content-panel') @@ -201,12 +201,13 @@ class PeerGradingProblem @action_button = $('.action-button') @calibration_feedback_button = $('.calibration-feedback-button') @interstitial_page_button = $('.interstitial-page-button') + @flag_student_checkbox = $('.flag-checkbox') Collapsible.setCollapsibles(@content_panel) # Set up the click event handlers @action_button.click -> history.back() - @calibration_feedback_button.click => + @calibration_feedback_button.click => @calibration_feedback_panel.hide() @grading_wrapper.show() @is_calibrated_check() @@ -232,27 +233,16 @@ class PeerGradingProblem fetch_submission_essay: () => @backend.post('get_next_submission', {location: @location}, @render_submission) - # finds the scores for each rubric category - get_score_list: () => - # find the number of categories: - num_categories = $('table.rubric tr').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 construct_data: () -> data = - rubric_scores: @get_score_list() - score: @grade + 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() + feedback: @feedback_area.val() + submission_flagged: @flag_student_checkbox.is(':checked') return data @@ -263,7 +253,7 @@ class PeerGradingProblem submit_grade: () => data = @construct_data() @backend.post('save_grade', data, @submission_callback) - + ########## # @@ -298,7 +288,7 @@ class PeerGradingProblem @render_calibration_feedback(response) else if response.error @render_error(response.error) - else + else @render_error("Error saving calibration score") # called after we submit a submission score @@ -315,20 +305,14 @@ class PeerGradingProblem # called after a grade is selected on the interface graded_callback: (event) => - @grade = $("input[name='grade-selection']:checked").val() - if @grade == undefined - return # check to see whether or not any categories have not been scored - num_categories = $('table.rubric tr').length - for i in [0..(num_categories-1)] - score = $("input[name='score-selection-#{i}']:checked").val() - if score == undefined - return - # show button if we have scores for all categories - @show_submit_button() + if Rubric.check_complete() + # show button if we have scores for all categories + @show_submit_button() + @grade = Rubric.get_total_score() + + - - ########## # # Rendering methods and helpers @@ -341,7 +325,7 @@ class PeerGradingProblem # load in all the data @submission_container.html("

          Training Essay

          ") @render_submission_data(response) - # TODO: indicate that we're in calibration mode + # TODO: indicate that we're in calibration mode @calibration_panel.addClass('current-state') @grading_panel.removeClass('current-state') @@ -352,7 +336,7 @@ class PeerGradingProblem @grading_panel.find('.calibration-text').show() @calibration_panel.find('.grading-text').hide() @grading_panel.find('.grading-text').hide() - + @flag_student_container.hide() @submit_button.unbind('click') @submit_button.click @submit_calibration_essay @@ -379,6 +363,7 @@ class PeerGradingProblem @grading_panel.find('.calibration-text').hide() @calibration_panel.find('.grading-text').show() @grading_panel.find('.grading-text').show() + @flag_student_container.show() @submit_button.unbind('click') @submit_button.click @submit_grade @@ -398,6 +383,7 @@ class PeerGradingProblem # 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) @@ -424,12 +410,12 @@ class PeerGradingProblem if score == actual_score calibration_wrapper.append("

          Congratulations! Your score matches the actual score!

          ") else - calibration_wrapper.append("

          Please try to understand the grading critera better to be more accurate next time.

          ") + calibration_wrapper.append("

          Please try to understand the grading critera better to be more accurate next time.

          ") # disable score selection and submission from the grading interface $("input[name='score-selection']").attr('disabled', true) @submit_button.hide() - + render_interstitial_page: () => @content_panel.hide() @interstitial_page.show() @@ -445,30 +431,5 @@ class PeerGradingProblem @submit_button.show() setup_score_selection: (max_score) => - - # first, get rid of all the old inputs, if any. - @score_selection_container.html(""" -

          Overall Score

          -

          Choose an overall score for this submission.

          - """) - - # Now create new labels and inputs for each possible score. - for score in [0..max_score] - id = 'score-' + score - label = """""" - - input = """ - - """ # " fix broken parsing in emacs - @score_selection_container.append(input + label) - # And now hook up an event handler again - $("input[name='score-selection']").change @graded_callback - $("input[name='grade-selection']").change @graded_callback - - - -mock_backend = false -ajax_url = $('.peer-grading').data('ajax_url') -backend = new PeerGradingProblemBackend(ajax_url, mock_backend) -$(document).ready(() -> new PeerGradingProblem(backend)) + $("input[class='score-selection']").change @graded_callback 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..acdc03932c --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/html5_video.js @@ -0,0 +1,285 @@ +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