diff --git a/.gitignore b/.gitignore index 3b7223108b..2fd1ca0181 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ lms/lib/comment_client/python nosetests.xml cover_html/ .idea/ +chromedriver.log \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..253bae3686 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "common/test/phantom-jasmine"] + path = common/test/phantom-jasmine + url = https://github.com/jcarver989/phantom-jasmine.git \ No newline at end of file diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index c2e8348a66..6995df06a8 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -1,7 +1,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore -from lxml import etree +from lxml import html import re from django.http import HttpResponseBadRequest import logging @@ -24,9 +24,9 @@ def get_course_updates(location): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data']) - except etree.XMLSyntaxError: - course_html_parsed = etree.fromstring("
    ") + 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 = [] @@ -39,7 +39,7 @@ def get_course_updates(location): # could enforce that update[0].tag == 'h2' content = update[0].tail else: - content = "\n".join([etree.tostring(ele) for ele in update[1:]]) + 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), @@ -61,17 +61,17 @@ def update_course_updates(location, update, passed_id=None): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data']) - except etree.XMLSyntaxError: - course_html_parsed = etree.fromstring("
          ") + course_html_parsed = html.fromstring(course_updates.definition['data']) + except: + course_html_parsed = html.fromstring("
            ") # No try/catch b/c failure generates an error back to client - new_html_parsed = etree.fromstring('
          1. ' + update['date'] + '

            ' + update['content'] + '
          2. ') + new_html_parsed = html.fromstring('
          3. ' + update['date'] + '

            ' + update['content'] + '
          4. ') # 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? - if passed_id: + if passed_id is not None: idx = get_idx(passed_id) # idx is count from end of list course_html_parsed[-idx] = new_html_parsed @@ -82,7 +82,7 @@ def update_course_updates(location, update, passed_id=None): passed_id = course_updates.location.url() + "/" + str(idx) # update db record - course_updates.definition['data'] = etree.tostring(course_html_parsed) + course_updates.definition['data'] = html.tostring(course_html_parsed) modulestore('direct').update_item(location, course_updates.definition['data']) return {"id" : passed_id, @@ -105,9 +105,9 @@ def delete_course_update(location, update, passed_id): # 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 = etree.fromstring(course_updates.definition['data']) - except etree.XMLSyntaxError: - course_html_parsed = etree.fromstring("
                ") + 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? @@ -118,7 +118,7 @@ def delete_course_update(location, update, passed_id): course_html_parsed.remove(element_to_delete) # update db record - course_updates.definition['data'] = etree.tostring(course_html_parsed) + course_updates.definition['data'] = html.tostring(course_html_parsed) store = modulestore('direct') store.update_item(location, course_updates.definition['data']) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py new file mode 100644 index 0000000000..1f14e29083 --- /dev/null +++ b/cms/djangoapps/contentstore/features/common.py @@ -0,0 +1,127 @@ +from lettuce import world, step +from factories import * +from django.core.management import call_command +from lettuce.django import django_url +from django.conf import settings +from django.core.management import call_command +from nose.tools import assert_true +from nose.tools import assert_equal +import xmodule.modulestore.django + +from logging import getLogger +logger = getLogger(__name__) + +########### STEP HELPERS ############## +@step('I (?:visit|access|open) the Studio homepage$') +def i_visit_the_studio_homepage(step): + # To make this go to port 8001, put + # LETTUCE_SERVER_PORT = 8001 + # in your settings.py file. + world.browser.visit(django_url('/')) + assert world.browser.is_element_present_by_css('body.no-header', 10) + +@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' + else: + assert False, 'Invalid category: %s' % category + css_click(css) + +####### HELPER FUNCTIONS ############## +def create_studio_user( + uname='robot', + em='robot+studio@edx.org', + password='test'): + studio_user = UserFactory.build( + username=uname, + email=em) + studio_user.set_password(password) + studio_user.save() + + registration = RegistrationFactory(user=studio_user) + registration.register(studio_user) + registration.activate() + + 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 + # 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() + +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) + +def log_into_studio( + uname='robot', + email='robot+studio@edx.org', + password='test'): + create_studio_user(uname, email) + world.browser.cookies.delete() + world.browser.visit(django_url('/')) + world.browser.is_element_present_by_css('body.no-header', 10) + + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill(email) + login_form.find_by_name('password').fill(password) + login_form.find_by_name('submit').click() + + 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_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 diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature new file mode 100644 index 0000000000..39d39b50aa --- /dev/null +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -0,0 +1,13 @@ +Feature: Create Course + In order offer a course on the edX platform + As a course author + I want to create courses + + Scenario: Create a course + Given There are no courses + And I am logged into Studio + When I click the New Course button + And I fill in the new course information + And I press the "Save" button + Then the Courseware page has loaded in Studio + And I see a link for adding a new section \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py new file mode 100644 index 0000000000..2c1cf6281a --- /dev/null +++ b/cms/djangoapps/contentstore/features/courses.py @@ -0,0 +1,50 @@ +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') + +@step('the course is loaded$') +def course_is_loaded(step): + class_css = 'a.class-name' + 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) + +@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') diff --git a/cms/djangoapps/contentstore/features/factories.py b/cms/djangoapps/contentstore/features/factories.py new file mode 100644 index 0000000000..389f2bac49 --- /dev/null +++ b/cms/djangoapps/contentstore/features/factories.py @@ -0,0 +1,31 @@ +import factory +from student.models import User, UserProfile, Registration +from datetime import datetime +import uuid + +class UserProfileFactory(factory.Factory): + FACTORY_FOR = UserProfile + + user = None + 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 + + username = 'robot-studio' + email = 'robot+studio@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Studio' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime.now() + date_joined = datetime.now() \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature new file mode 100644 index 0000000000..ad00ba2911 --- /dev/null +++ b/cms/djangoapps/contentstore/features/section.feature @@ -0,0 +1,26 @@ +Feature: Create Section + In order offer a course on the edX platform + As a course author + I want to create and edit sections + + Scenario: Add a new section to a course + Given I have opened a new course in Studio + When I click the New Section link + And I enter the section name and click save + Then I see my section on the Courseware page + And I see a release date for my section + And I see a link to create a new subsection + + Scenario: Edit section release date + Given I have opened a new course in Studio + And I have added a new section + When I click the Edit link for the release date + And I save a new section release date + Then the section release date is updated + + Scenario: Delete section + Given I have opened a new course in Studio + And I have added a new section + When I press the "section" delete icon + And I confirm the alert + Then the section does not exist \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py new file mode 100644 index 0000000000..8ac30e2170 --- /dev/null +++ b/cms/djangoapps/contentstore/features/section.py @@ -0,0 +1,82 @@ +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_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') + # click here to make the calendar go away + css_click(time_css) + 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') + +@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 + + css = 'span.published-status' + assert world.browser.is_element_present_by_css(css) + status_text = world.browser.find_by_css(css).text + + # e.g. 11/06/2012 at 16:25 + msg = 'Will Release:' + 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) + +@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' + status_text = world.browser.find_by_css(css).text + assert status_text == 'Will Release: 12/25/2013 at 12:00am' diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature new file mode 100644 index 0000000000..8a6f93d33b --- /dev/null +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -0,0 +1,12 @@ +Feature: Sign in + In order to use the edX content + As a new user + I want to signup for a student account + + Scenario: Sign up from the homepage + Given I visit the Studio homepage + When I click the link with the text "Sign up" + And I fill in the registration form + And I press the "Create My Account" button on the registration form + Then I should see be on the studio home page + And I should see the message "please click on the activation link in your email." \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py new file mode 100644 index 0000000000..7794511f94 --- /dev/null +++ b/cms/djangoapps/contentstore/features/signup.py @@ -0,0 +1,23 @@ +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') + register_form.find_by_name('email').fill('robot+studio@edx.org') + register_form.find_by_name('password').fill('test') + register_form.find_by_name('username').fill('robot-studio') + 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 diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature new file mode 100644 index 0000000000..5acb5bfe44 --- /dev/null +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -0,0 +1,18 @@ +Feature: Create Subsection + In order offer a course on the edX platform + As a course author + I want to create and edit subsections + + Scenario: Add a new subsection to a section + Given I have opened a new course section in Studio + When I click the New Subsection link + And I enter the subsection name and click save + Then I see my subsection on the Courseware page + + Scenario: Delete a subsection + Given I have opened a new course section in Studio + And I have added a new subsection + And I see my subsection on the Courseware page + When I press the "subsection" delete icon + And I confirm the alert + Then the subsection does not exist diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py new file mode 100644 index 0000000000..ea614d3feb --- /dev/null +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -0,0 +1,39 @@ +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() + log_into_studio() + 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_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) + css = 'span.subsection-name-value' + 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 diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py new file mode 100644 index 0000000000..b05754d214 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -0,0 +1,29 @@ +from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase +from django.core.urlresolvers import reverse +import json +from cms.djangoapps.contentstore.course_info_model import update_course_updates + + +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 }) + self.client.get(url) + + content = '' + payload = { 'content' : content, + 'date' : 'January 8, 2013'} + # No means to post w/ provided_id missing. django doesn't handle. So, go direct for the create + payload = update_course_updates(['i4x', self.course_location.org, self.course_location.course, 'course_info', "updates"] , payload) + + url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course, + 'provided_id' : payload['id']}) + + self.assertHTMLEqual(content, payload['content'], "single iframe") + + 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/views.py b/cms/djangoapps/contentstore/views.py index 19d74024c7..86d821cbb6 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -200,7 +200,7 @@ def edit_subsection(request, location): # make sure that location references a 'sequential', otherwise return BadRequest if item.location.category != 'sequential': - return HttpResponseBadRequest + return HttpResponseBadRequest() parent_locs = modulestore().get_parent_locations(location) @@ -993,7 +993,7 @@ def course_info_updates(request, org, course, provided_id=None): elif request.method == 'POST': try: return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json") - except etree.XMLSyntaxError: + except: return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain") @@ -1025,7 +1025,7 @@ def module_info(request, module_location): elif real_method == 'POST' or real_method == 'PUT': return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json") else: - return HttpResponseBadRequest + return HttpResponseBadRequest() @login_required @ensure_csrf_cookie diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py new file mode 100644 index 0000000000..5bc9b53fc4 --- /dev/null +++ b/cms/envs/acceptance.py @@ -0,0 +1,38 @@ +""" +This config file extends the test environment configuration +so that we can run the lettuce acceptance tests. +""" +from .test import * + +# You need to start the server in debug mode, +# otherwise the browser will not render the pages correctly +DEBUG = True + +# Show the courses that are in the data directory +COURSES_ROOT = ENV_ROOT / "data" +DATA_DIR = COURSES_ROOT +# MODULESTORE = { +# 'default': { +# 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', +# 'OPTIONS': { +# 'data_dir': DATA_DIR, +# 'default_class': 'xmodule.hidden_module.HiddenDescriptor', +# } +# } +# } + +# 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", + } +} + +# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command +INSTALLED_APPS += ('lettuce.django',) +LETTUCE_APPS = ('contentstore',) +LETTUCE_SERVER_PORT = 8001 diff --git a/cms/envs/common.py b/cms/envs/common.py index 4b4b69ad39..c047d689ce 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -34,6 +34,7 @@ MITX_FEATURES = { '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 } ENABLE_JASMINE = False diff --git a/common/djangoapps/track/migrations/__init__.py b/common/djangoapps/track/migrations/__init__.py index 8b13789179..e69de29bb2 100644 --- a/common/djangoapps/track/migrations/__init__.py +++ b/common/djangoapps/track/migrations/__init__.py @@ -1 +0,0 @@ - diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 2eaa0e4286..efc96fc717 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -186,6 +186,24 @@ class LoncapaProblem(object): maxscore += responder.get_max_score() return maxscore + def message_post(self,event_info): + """ + Handle an ajax post that contains feedback on feedback + Returns a boolean success variable + Note: This only allows for feedback to be posted back to the grading controller for the first + open ended response problem on each page. Multiple problems will cause some sync issues. + TODO: Handle multiple problems on one page sync issues. + """ + success=False + message = "Could not find a valid responder." + log.debug("in lcp") + for responder in self.responders.values(): + if hasattr(responder, 'handle_message_post'): + success, message = responder.handle_message_post(event_info) + if success: + break + return success, message + def get_score(self): """ Compute score for this problem. The score is the number of points awarded. diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 73056bc09e..e3eb47acc5 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -748,7 +748,7 @@ class OpenEndedInput(InputTypeBase): # pulled out for testing submitted_msg = ("Feedback not yet available. Reload to check again. " "Once the problem is graded, this message will be " - "replaced with the grader's feedback") + "replaced with the grader's feedback.") @classmethod def get_attributes(cls): diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 8f62bc74d4..16dc15297d 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1853,6 +1853,7 @@ class OpenEndedResponse(LoncapaResponse): """ DEFAULT_QUEUE = 'open-ended' + DEFAULT_MESSAGE_QUEUE = 'open-ended-message' response_tag = 'openendedresponse' allowed_inputfields = ['openendedinput'] max_inputfields = 1 @@ -1864,12 +1865,17 @@ class OpenEndedResponse(LoncapaResponse): xml = self.xml self.url = xml.get('url', None) self.queue_name = xml.get('queuename', self.DEFAULT_QUEUE) + self.message_queue_name = xml.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE) # The openendedparam tag encapsulates all grader settings oeparam = self.xml.find('openendedparam') prompt = self.xml.find('prompt') rubric = self.xml.find('openendedrubric') + #This is needed to attach feedback to specific responses later + self.submission_id=None + self.grader_id=None + if oeparam is None: raise ValueError("No oeparam found in problem xml.") if prompt is None: @@ -1916,23 +1922,83 @@ class OpenEndedResponse(LoncapaResponse): # response types) except TypeError, ValueError: log.exception("Grader payload %r is not a json object!", grader_payload) + + self.initial_display = find_with_default(oeparam, 'initial_display', '') + self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.') + parsed_grader_payload.update({ 'location' : self.system.location, 'course_id' : self.system.course_id, 'prompt' : prompt_string, 'rubric' : rubric_string, - }) + 'initial_display' : self.initial_display, + 'answer' : self.answer, + }) + updated_grader_payload = json.dumps(parsed_grader_payload) self.payload = {'grader_payload': updated_grader_payload} - self.initial_display = find_with_default(oeparam, 'initial_display', '') - self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.') try: self.max_score = int(find_with_default(oeparam, 'max_score', 1)) except ValueError: self.max_score = 1 + def handle_message_post(self,event_info): + """ + Handles a student message post (a reaction to the grade they received from an open ended grader type) + Returns a boolean success/fail and an error message + """ + survey_responses=event_info['survey_responses'] + for tag in ['feedback', 'submission_id', 'grader_id', 'score']: + if tag not in survey_responses: + return False, "Could not find needed tag {0}".format(tag) + try: + submission_id=int(survey_responses['submission_id']) + grader_id = int(survey_responses['grader_id']) + feedback = str(survey_responses['feedback'].encode('ascii', 'ignore')) + score = int(survey_responses['score']) + except: + error_message=("Could not parse submission id, grader id, " + "or feedback from message_post ajax call. Here is the message data: {0}".format(survey_responses)) + log.exception(error_message) + return False, "There was an error saving your feedback. Please contact course staff." + + qinterface = self.system.xqueue['interface'] + qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) + anonymous_student_id = self.system.anonymous_student_id + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + + anonymous_student_id + + self.answer_id) + + xheader = xqueue_interface.make_xheader( + lms_callback_url=self.system.xqueue['callback_url'], + lms_key=queuekey, + queue_name=self.message_queue_name + ) + + student_info = {'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime, + } + contents= { + 'feedback' : feedback, + 'submission_id' : submission_id, + 'grader_id' : grader_id, + 'score': score, + 'student_info' : json.dumps(student_info), + } + + (error, msg) = qinterface.send_to_queue(header=xheader, + body=json.dumps(contents)) + + #Convert error to a success value + success=True + if error: + success=False + + return success, "Successfully submitted your feedback." + + def get_score(self, student_answers): try: @@ -1973,7 +2039,7 @@ class OpenEndedResponse(LoncapaResponse): contents.update({ 'student_info': json.dumps(student_info), 'student_response': submission, - 'max_score' : self.max_score + 'max_score' : self.max_score, }) # Submit request. When successful, 'msg' is the prior length of the queue @@ -2073,18 +2139,39 @@ class OpenEndedResponse(LoncapaResponse): """ return priorities.get(elt[0], default_priority) + + def encode_values(feedback_type,value): + feedback_type=str(feedback_type).encode('ascii', 'ignore') + if not isinstance(value,basestring): + value=str(value) + value=value.encode('ascii', 'ignore') + return feedback_type,value + def format_feedback(feedback_type, value): - return """ + feedback_type,value=encode_values(feedback_type,value) + feedback= """
                  {value}
                  """.format(feedback_type=feedback_type, value=value) + return feedback + + def format_feedback_hidden(feedback_type , value): + feedback_type,value=encode_values(feedback_type,value) + feedback = """ + + """.format(feedback_type=feedback_type, value=value) + return feedback + + # TODO (vshnayder): design and document the details of this format so # that we can do proper escaping here (e.g. are the graders allowed to # include HTML?) - for tag in ['success', 'feedback']: + for tag in ['success', 'feedback', 'submission_id', 'grader_id']: if tag not in response_items: return format_feedback('errors', 'Error getting feedback') @@ -2100,10 +2187,16 @@ class OpenEndedResponse(LoncapaResponse): return format_feedback('errors', 'No feedback available') feedback_lst = sorted(feedback.items(), key=get_priority) - return u"\n".join(format_feedback(k, v) for k, v in feedback_lst) - else: - return format_feedback('errors', response_items['feedback']) + feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst) + else: + feedback_list_part1 = format_feedback('errors', response_items['feedback']) + + feedback_list_part2=(u"\n".join([format_feedback_hidden(feedback_type,value) + for feedback_type,value in response_items.items() + if feedback_type in ['submission_id', 'grader_id']])) + + return u"\n".join([feedback_list_part1,feedback_list_part2]) def _format_feedback(self, response_items): """ @@ -2121,7 +2214,7 @@ class OpenEndedResponse(LoncapaResponse): feedback_template = self.system.render_template("open_ended_feedback.html", { 'grader_type': response_items['grader_type'], - 'score': response_items['score'], + 'score': "{0} / {1}".format(response_items['score'], self.max_score), 'feedback': feedback, }) @@ -2155,7 +2248,8 @@ class OpenEndedResponse(LoncapaResponse): " Received score_result = {0}".format(score_result)) return fail - for tag in ['score', 'feedback', 'grader_type', 'success']: + + for tag in ['score', 'feedback', 'grader_type', 'success', 'grader_id', 'submission_id']: if tag not in score_result: log.error("External grader message is missing required tag: {0}" .format(tag)) @@ -2163,9 +2257,12 @@ class OpenEndedResponse(LoncapaResponse): feedback = self._format_feedback(score_result) + self.submission_id=score_result['submission_id'] + self.grader_id=score_result['grader_id'] + # HACK: for now, just assume it's correct if you got more than 2/3. # Also assumes that score_result['score'] is an integer. - score_ratio = int(score_result['score']) / self.max_score + score_ratio = int(score_result['score']) / float(self.max_score) correct = (score_ratio >= 0.66) #Currently ignore msg and only return feedback (which takes the place of msg) diff --git a/common/lib/capa/capa/templates/openendedinput.html b/common/lib/capa/capa/templates/openendedinput.html index 65fc7fb9bb..c42ad73faf 100644 --- a/common/lib/capa/capa/templates/openendedinput.html +++ b/common/lib/capa/capa/templates/openendedinput.html @@ -27,6 +27,30 @@ % endif
                  - ${msg|n} + ${msg|n} + % if status in ['correct','incorrect']: +
                  +
                  + Respond to Feedback +
                  +
                  +

                  How accurate do you find this feedback?

                  +
                  +
                    +
                  • +
                  • +
                  • +
                  • +
                  • +
                  +
                  +

                  Additional comments:

                  + +
                  + +
                  +
                  +
                  + % endif
                  diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index d880ed1129..228690fb7a 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -371,6 +371,7 @@ class CapaModule(XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, 'score_update': self.update_score, + 'message_post' : self.message_post, } if dispatch not in handlers: @@ -385,6 +386,20 @@ class CapaModule(XModule): }) return json.dumps(d, cls=ComplexEncoder) + def message_post(self, get): + """ + Posts a message from a form to an appropriate location + """ + event_info = dict() + event_info['state'] = self.lcp.get_state() + event_info['problem_id'] = self.location.url() + event_info['student_id'] = self.system.anonymous_student_id + event_info['survey_responses']= get + + success, message = self.lcp.message_post(event_info) + + return {'success' : success, 'message' : message} + def closed(self): ''' Is the student still allowed to submit answers? ''' if self.attempts == self.max_attempts: diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 53c924cf02..f8e18a9355 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -97,7 +97,6 @@ class CourseDescriptor(SequenceDescriptor): # NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically # disable the syllabus content for courses that do not provide a syllabus self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) - self.set_grading_policy(self.definition['data'].get('grading_policy', None)) def defaut_grading_policy(self): @@ -210,7 +209,7 @@ class CourseDescriptor(SequenceDescriptor): instance.set_grading_policy(policy) return instance - + @classmethod def definition_from_xml(cls, xml_object, system): diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index b25ab3d3a2..929b6dcb48 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -297,6 +297,51 @@ section.problem { float: left; } } + + } + .evaluation { + p { + margin-bottom: 4px; + } + } + + + .feedback-on-feedback { + height: 100px; + margin-right: 20px; + } + + .evaluation-response { + header { + text-align: right; + a { + font-size: .85em; + } + } + } + + .evaluation-scoring { + .scoring-list { + list-style-type: none; + margin-left: 3px; + + li { + &:first-child { + margin-left: 0px; + } + display:inline; + margin-left: 50px; + + label { + font-size: .9em; + } + + } + } + + } + .submit-message-container { + margin: 10px 0px ; } } @@ -634,6 +679,10 @@ section.problem { color: #2C2C2C; font-family: monospace; font-size: 1em; + padding-top: 10px; + header { + font-size: 1.4em; + } .shortform { font-weight: bold; diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index e3b123ce15..3e6d61eb00 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -262,7 +262,7 @@ class AssignmentFormatGrader(CourseGrader): min_count = 2 would produce the labels "Assignment 3", "Assignment 4" """ - def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, show_only_average=False, starting_index=1): + def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, show_only_average=False, hide_average=False, starting_index=1): self.type = type self.min_count = min_count self.drop_count = drop_count @@ -271,6 +271,7 @@ class AssignmentFormatGrader(CourseGrader): self.short_label = short_label or self.type self.show_only_average = show_only_average self.starting_index = starting_index + self.hide_average = hide_average def grade(self, grade_sheet, generate_random_scores=False): def totalWithDrops(breakdown, drop_count): @@ -331,7 +332,8 @@ class AssignmentFormatGrader(CourseGrader): if self.show_only_average: breakdown = [] - breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True}) + 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, diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 1c0ace9e59..ba746fecb8 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -25,6 +25,7 @@ class @Problem @$('section.action input.reset').click @reset @$('section.action input.show').click @show @$('section.action input.save').click @save + @$('section.evaluation input.submit-message').click @message_post # Collapsibles Collapsible.setCollapsibles(@el) @@ -197,6 +198,35 @@ class @Problem else @gentle_alert response.success + message_post: => + Logger.log 'message_post', @answers + + fd = new FormData() + feedback = @$('section.evaluation textarea.feedback-on-feedback')[0].value + submission_id = $('div.external-grader-message div.submission_id')[0].innerHTML + grader_id = $('div.external-grader-message div.grader_id')[0].innerHTML + score = $(".evaluation-scoring input:radio[name='evaluation-score']:checked").val() + fd.append('feedback', feedback) + fd.append('submission_id', submission_id) + fd.append('grader_id', grader_id) + if(!score) + @gentle_alert "You need to pick a rating before you can submit." + return + else + fd.append('score', score) + + + settings = + type: "POST" + data: fd + processData: false + contentType: false + success: (response) => + @gentle_alert response.message + @$('section.evaluation').slideToggle() + + $.ajaxWithPrefix("#{@url}/message_post", settings) + reset: => Logger.log 'problem_reset', @answers $.postWithPrefix "#{@url}/problem_reset", id: @id, (response) => diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index ba24c86517..2a47b26eaa 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -358,6 +358,12 @@ class ModuleStore(object): ''' raise NotImplementedError + def get_course(self, course_id): + ''' + Look for a specific course id. Returns the course descriptor, or None if not found. + ''' + raise NotImplementedError + def get_parent_locations(self, location): '''Find all locations that are the parents of this location. Needed for path_to_location(). diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index c709107acb..bb3af745ae 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -13,6 +13,9 @@ from xmodule.contentstore.content import StaticContent import datetime import time +import datetime +import time + log = logging.getLogger(__name__) diff --git a/common/static/js/vendor/RequireJS.js b/common/static/js/vendor/RequireJS.js new file mode 100644 index 0000000000..a0526930ef --- /dev/null +++ b/common/static/js/vendor/RequireJS.js @@ -0,0 +1,57 @@ +/* + * This file is a wrapper for the Require JS file and module loader. Please see + * the discussion at: + * + * https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system + */ + +var RequireJS = function() { + +// Below is the unmodified minified version of Require JS. The latest can be +// found at: +// +// http://requirejs.org/docs/download.html + +/* + RequireJS 2.1.2 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved. + Available via the MIT or new BSD license. + see: http://github.com/jrburke/requirejs for details +*/ +var requirejs,require,define; +(function(Y){function H(b){return"[object Function]"===L.call(b)}function I(b){return"[object Array]"===L.call(b)}function x(b,c){if(b){var d;for(d=0;dthis.depCount&&!this.defined){if(H(n)){if(this.events.error)try{e=j.execCb(c,n,b,e)}catch(d){a=d}else e=j.execCb(c,n,b,e);this.map.isDefine&&((b=this.module)&&void 0!==b.exports&&b.exports!==this.exports?e=b.exports:void 0===e&&this.usingExports&&(e=this.exports));if(a)return a.requireMap=this.map,a.requireModules=[this.map.id],a.requireType="define",C(this.error=a)}else e=n;this.exports=e;if(this.map.isDefine&& +!this.ignore&&(p[c]=e,l.onResourceLoad))l.onResourceLoad(j,this.map,this.depMaps);delete k[c];this.defined=!0}this.defining=!1;this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else this.fetch()}},callPlugin:function(){var a=this.map,b=a.id,d=h(a.prefix);this.depMaps.push(d);s(d,"defined",t(this,function(e){var n,d;d=this.map.name;var v=this.map.parentMap?this.map.parentMap.name:null,f=j.makeRequire(a.parentMap,{enableBuildCallback:!0, +skipMap:!0});if(this.map.unnormalized){if(e.normalize&&(d=e.normalize(d,function(a){return c(a,v,!0)})||""),e=h(a.prefix+"!"+d,this.map.parentMap),s(e,"defined",t(this,function(a){this.init([],function(){return a},null,{enabled:!0,ignore:!0})})),d=i(k,e.id)){this.depMaps.push(e);if(this.events.error)d.on("error",t(this,function(a){this.emit("error",a)}));d.enable()}}else n=t(this,function(a){this.init([],function(){return a},null,{enabled:!0})}),n.error=t(this,function(a){this.inited=!0;this.error= +a;a.requireModules=[b];E(k,function(a){0===a.map.id.indexOf(b+"_unnormalized")&&delete k[a.map.id]});C(a)}),n.fromText=t(this,function(e,c){var d=a.name,u=h(d),v=O;c&&(e=c);v&&(O=!1);q(u);r(m.config,b)&&(m.config[d]=m.config[b]);try{l.exec(e)}catch(k){throw Error("fromText eval for "+d+" failed: "+k);}v&&(O=!0);this.depMaps.push(u);j.completeLoad(d);f([d],n)}),e.load(a.name,f,n,m)}));j.enable(d,this);this.pluginMaps[d.id]=d},enable:function(){this.enabling=this.enabled=!0;x(this.depMaps,t(this,function(a, +b){var c,e;if("string"===typeof a){a=h(a,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap);this.depMaps[b]=a;if(c=i(N,a.id)){this.depExports[b]=c(this);return}this.depCount+=1;s(a,"defined",t(this,function(a){this.defineDep(b,a);this.check()}));this.errback&&s(a,"error",this.errback)}c=a.id;e=k[c];!r(N,c)&&(e&&!e.enabled)&&j.enable(a,this)}));E(this.pluginMaps,t(this,function(a){var b=i(k,a.id);b&&!b.enabled&&j.enable(a,this)}));this.enabling=!1;this.check()},on:function(a,b){var c= +this.events[a];c||(c=this.events[a]=[]);c.push(b)},emit:function(a,b){x(this.events[a],function(a){a(b)});"error"===a&&delete this.events[a]}};j={config:m,contextName:b,registry:k,defined:p,urlFetched:S,defQueue:F,Module:W,makeModuleMap:h,nextTick:l.nextTick,configure:function(a){a.baseUrl&&"/"!==a.baseUrl.charAt(a.baseUrl.length-1)&&(a.baseUrl+="/");var b=m.pkgs,c=m.shim,e={paths:!0,config:!0,map:!0};E(a,function(a,b){e[b]?"map"===b?Q(m[b],a,!0,!0):Q(m[b],a,!0):m[b]=a});a.shim&&(E(a.shim,function(a, +b){I(a)&&(a={deps:a});if((a.exports||a.init)&&!a.exportsFn)a.exportsFn=j.makeShimExports(a);c[b]=a}),m.shim=c);a.packages&&(x(a.packages,function(a){a="string"===typeof a?{name:a}:a;b[a.name]={name:a.name,location:a.location||a.name,main:(a.main||"main").replace(ga,"").replace(aa,"")}}),m.pkgs=b);E(k,function(a,b){!a.inited&&!a.map.unnormalized&&(a.map=h(b))});if(a.deps||a.callback)j.require(a.deps||[],a.callback)},makeShimExports:function(a){return function(){var b;a.init&&(b=a.init.apply(Y,arguments)); +return b||a.exports&&Z(a.exports)}},makeRequire:function(a,d){function f(e,c,u){var i,m;d.enableBuildCallback&&(c&&H(c))&&(c.__requireJsBuild=!0);if("string"===typeof e){if(H(c))return C(J("requireargs","Invalid require call"),u);if(a&&r(N,e))return N[e](k[a.id]);if(l.get)return l.get(j,e,a);i=h(e,a,!1,!0);i=i.id;return!r(p,i)?C(J("notloaded",'Module name "'+i+'" has not been loaded yet for context: '+b+(a?"":". Use require([])"))):p[i]}K();j.nextTick(function(){K();m=q(h(null,a));m.skipMap=d.skipMap; +m.init(e,c,u,{enabled:!0});B()});return f}d=d||{};Q(f,{isBrowser:z,toUrl:function(b){var d=b.lastIndexOf("."),g=null;-1!==d&&(g=b.substring(d,b.length),b=b.substring(0,d));return j.nameToUrl(c(b,a&&a.id,!0),g)},defined:function(b){return r(p,h(b,a,!1,!0).id)},specified:function(b){b=h(b,a,!1,!0).id;return r(p,b)||r(k,b)}});a||(f.undef=function(b){w();var c=h(b,a,!0),d=i(k,b);delete p[b];delete S[c.url];delete X[b];d&&(d.events.defined&&(X[b]=d.events),delete k[b])});return f},enable:function(a){i(k, +a.id)&&q(a).enable()},completeLoad:function(a){var b,c,d=i(m.shim,a)||{},h=d.exports;for(w();F.length;){c=F.shift();if(null===c[0]){c[0]=a;if(b)break;b=!0}else c[0]===a&&(b=!0);D(c)}c=i(k,a);if(!b&&!r(p,a)&&c&&!c.inited){if(m.enforceDefine&&(!h||!Z(h)))return y(a)?void 0:C(J("nodefine","No define call for "+a,null,[a]));D([a,d.deps||[],d.exportsFn])}B()},nameToUrl:function(a,b){var c,d,h,f,j,k;if(l.jsExtRegExp.test(a))f=a+(b||"");else{c=m.paths;d=m.pkgs;f=a.split("/");for(j=f.length;0f.attachEvent.toString().indexOf("[native code"))&&!V?(O=!0,f.attachEvent("onreadystatechange", +b.onScriptLoad)):(f.addEventListener("load",b.onScriptLoad,!1),f.addEventListener("error",b.onScriptError,!1)),f.src=d,K=f,D?A.insertBefore(f,D):A.appendChild(f),K=null,f;$&&(importScripts(d),b.completeLoad(c))};z&&M(document.getElementsByTagName("script"),function(b){A||(A=b.parentNode);if(s=b.getAttribute("data-main"))return q.baseUrl||(G=s.split("/"),ba=G.pop(),ca=G.length?G.join("/")+"/":"./",q.baseUrl=ca,s=ba),s=s.replace(aa,""),q.deps=q.deps?q.deps.concat(s):[s],!0});define=function(b,c,d){var i, +f;"string"!==typeof b&&(d=c,c=b,b=null);I(c)||(d=c,c=[]);!c.length&&H(d)&&d.length&&(d.toString().replace(ia,"").replace(ja,function(b,d){c.push(d)}),c=(1===d.length?["require"]:["require","exports","module"]).concat(c));if(O){if(!(i=K))P&&"interactive"===P.readyState||M(document.getElementsByTagName("script"),function(b){if("interactive"===b.readyState)return P=b}),i=P;i&&(b||(b=i.getAttribute("data-requiremodule")),f=B[i.getAttribute("data-requirecontext")])}(f?f.defQueue:R).push([b,c,d])};define.amd= +{jQuery:!0};l.exec=function(b){return eval(b)};l(q)}})(this); + +// The object which will be globally available via RequireJS variable. +return { + 'requirejs': requirejs, + 'require': require, + 'define': define +}; +}(); // End-of: var RequireJS = function() diff --git a/common/static/sass/bourbon/css3/_box-sizing.scss b/common/static/sass/bourbon/css3/_box-sizing.scss index e73e0fbd1c..d61523b5f1 100644 --- a/common/static/sass/bourbon/css3/_box-sizing.scss +++ b/common/static/sass/bourbon/css3/_box-sizing.scss @@ -2,5 +2,5 @@ // content-box | border-box | inherit -webkit-box-sizing: $box; -moz-box-sizing: $box; - box-sizing: $box; *behavior: url(/static/scripts/boxsizing.htc) + box-sizing: $box; *behavior: url(/static/scripts/boxsizing.htc); } diff --git a/doc/testing.md b/doc/testing.md index ee54ae74d9..694a9e8231 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -1,17 +1,25 @@ # Testing -Testing is good. Here is some useful info about how we set up tests-- +Testing is good. Here is some useful info about how we set up tests. +More info is [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Test+Engineering) -### Backend code: +## Backend code -- TODO +- The python unit tests can be run via rake tasks. +See development.md for more info on how to do this. -### Frontend code: +## Frontend code -We're using Jasmine to unit-testing the JavaScript files. All the specs are -written in CoffeeScript for the consistency. To access the test cases, start the -server in debug mode, navigate to `http://127.0.0.1:[port number]/_jasmine` to -see the test result. +### Jasmine + +We're using Jasmine to unit/integration test the JavaScript files. +More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Jasmine) + +All the specs are written in CoffeeScript to be consistent with the code. +To access the test cases, start the server using the settings file **jasmine.py** using this command: + `rake django-admin[runserver,lms,jasmine,12345]` + +Then navigate to `http://localhost:12345/_jasmine/` to see the test results. All the JavaScript codes must have test coverage. Both CMS and LMS has its own test directory in `{cms,lms}/static/coffee/spec` If you haven't @@ -30,3 +38,31 @@ If you're finishing a feature that contains JavaScript code snippets and do not sure how to test, please feel free to open up a pull request and asking people for help. (However, the best way to do it would be writing your test first, then implement your feature - Test Driven Development.) + +### BDD style acceptance tests with Lettuce + +We're using Lettuce for end user acceptance testing of features. +More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Lettuce+Acceptance+Testing) + +Lettuce is a port of Cucumber. We're using it to drive Splinter, which is a python wrapper to Selenium. +To execute the automated test scripts, you'll need to start up the django server separately, then launch the tests. +Do both use the settings file named **acceptance.py**. + +What this will do is to use a sqllite database named mitx_all/db/test_mitx.db. +That way it can be flushed etc. without messing up your dev db. +Note that this also means that you need to syncdb and migrate the db first before starting the server to initialize it if it does not yet exist. + +1. Set up the test database (only needs to be done once): + rm ../db/test_mitx.db + rake django-admin[syncdb,lms,acceptance,--noinput] + rake django-admin[migrate,lms,acceptance,--noinput] + +2. Start up the django server separately in a shell + rake lms[acceptance] + +3. Then in another shell, run the tests in different ways as below. Lettuce comes with a new django-admin command called _harvest_. See the [lettuce django docs](http://lettuce.it/recipes/django-lxml.html) for more details. +* All tests in a specified feature folder: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/` +* Only the specified feature's scenarios: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/courseware/features/high-level-tabs.feature` + +4. Troubleshooting +* If you get an error msg that says something about harvest not being a command, you probably are missing a requirement. Pip install (test-requirements.txt) and/or brew install as needed. \ No newline at end of file diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 483e0e2196..72de6a0dad 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -148,7 +148,7 @@ def get_course_about_section(course, section_key): request = get_request_for_thread() loc = course.location._replace(category='about', name=section_key) - course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = True) + course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = False) html = '' @@ -186,7 +186,7 @@ def get_course_info_section(request, cache, course, section_key): loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) - course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = True) + course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = False) html = '' if course_module is not None: diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py new file mode 100644 index 0000000000..aecaa139ff --- /dev/null +++ b/lms/djangoapps/courseware/features/courses.py @@ -0,0 +1,254 @@ +from lettuce import world +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from courseware.courses import get_course_by_id +from xmodule import seq_module, vertical_module + +from logging import getLogger +logger = getLogger(__name__) + +## support functions +def get_courses(): + ''' + Returns dict of lists of courses available, keyed by course.org (ie university). + Courses are sorted by course.number. + ''' + courses = [c for c in modulestore().get_courses() + if isinstance(c, CourseDescriptor)] + courses = sorted(courses, key=lambda course: course.number) + return courses + +# def get_courseware(course_id): +# """ +# Given a course_id (string), return a courseware array of dictionaries for the +# top two levels of navigation. Example: + +# [ +# {'chapter_name': 'Overview', +# 'sections': ['Welcome', 'System Usage Sequence', 'Lab0: Using the tools', 'Circuit Sandbox'] +# }, +# {'chapter_name': 'Week 1', +# 'sections': ['Administrivia and Circuit Elements', 'Basic Circuit Analysis', 'Resistor Divider', 'Week 1 Tutorials'] +# }, +# {'chapter_name': 'Midterm Exam', +# 'sections': ['Midterm Exam'] +# } +# ] +# """ + +# course = get_course_by_id(course_id) +# chapters = course.get_children() +# courseware = [ {'chapter_name':c.display_name, 'sections':[s.display_name for s in c.get_children()]} for c in chapters] +# return courseware + +def get_courseware_with_tabs(course_id): + """ + Given a course_id (string), return a courseware array of dictionaries for the + top three levels of navigation. Same as get_courseware() except include + the tabs on the right hand main navigation page. + + This hides the appropriate courseware as defined by the XML flag test: + chapter.metadata.get('hide_from_toc','false').lower() == 'true' + + Example: + + [{ + 'chapter_name': 'Overview', + 'sections': [{ + 'clickable_tab_count': 0, + 'section_name': 'Welcome', + 'tab_classes': [] + }, { + 'clickable_tab_count': 1, + 'section_name': 'System Usage Sequence', + 'tab_classes': ['VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Lab0: Using the tools', + 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Circuit Sandbox', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Week 1', + 'sections': [{ + 'clickable_tab_count': 4, + 'section_name': 'Administrivia and Circuit Elements', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Basic Circuit Analysis', + 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Resistor Divider', + 'tab_classes': [] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Week 1 Tutorials', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Midterm Exam', + 'sections': [{ + 'clickable_tab_count': 2, + 'section_name': 'Midterm Exam', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] + }] + }] + """ + + course = get_course_by_id(course_id) + chapters = [ chapter for chapter in course.get_children() if chapter.metadata.get('hide_from_toc','false').lower() != 'true' ] + courseware = [{'chapter_name':c.display_name, + 'sections':[{'section_name':s.display_name, + 'clickable_tab_count':len(s.get_children()) if (type(s)==seq_module.SequenceDescriptor) else 0, + 'tabs':[{'children_count':len(t.get_children()) if (type(t)==vertical_module.VerticalDescriptor) else 0, + 'class':t.__class__.__name__ } + for t in s.get_children() ]} + for s in c.get_children() if s.metadata.get('hide_from_toc', 'false').lower() != 'true']} + for c in chapters ] + + return courseware + +def process_section(element, num_tabs=0): + ''' + Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. + + This function is recursive + + There are 6 types, with 6 actions. + + Sequence Module + -contains one child module + + Vertical Module + -contains other modules + -process it and get its children, then process them + + Capa Module + -problem type, contains only one problem + -for this, the most complex type, we created a separate method, process_problem + + Video Module + -video type, contains only one video + -we only check to ensure that a section with class of video exists + + HTML Module + -html text + -we do not check anything about it + + Custom Tag Module + -a custom 'hack' module type + -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type + + can be used like this: + e = world.browser.find_by_css('section.course-content section') + process_section(e) + + ''' + if element.has_class('xmodule_display xmodule_SequenceModule'): + logger.debug('####### Processing xmodule_SequenceModule') + child_modules = element.find_by_css("div>div>section[class^='xmodule']") + for mod in child_modules: + process_section(mod) + + elif element.has_class('xmodule_display xmodule_VerticalModule'): + logger.debug('####### Processing xmodule_VerticalModule') + vert_list = element.find_by_css("li section[class^='xmodule']") + for item in vert_list: + process_section(item) + + elif element.has_class('xmodule_display xmodule_CapaModule'): + logger.debug('####### Processing xmodule_CapaModule') + assert element.find_by_css("section[id^='problem']"), "No problems found in Capa Module" + p = element.find_by_css("section[id^='problem']").first + p_id = p['id'] + logger.debug('####################') + logger.debug('id is "%s"' % p_id) + logger.debug('####################') + process_problem(p, p_id) + + elif element.has_class('xmodule_display xmodule_VideoModule'): + logger.debug('####### Processing xmodule_VideoModule') + assert element.find_by_css("section[class^='video']"), "No video found in Video Module" + + elif element.has_class('xmodule_display xmodule_HtmlModule'): + logger.debug('####### Processing xmodule_HtmlModule') + pass + + elif element.has_class('xmodule_display xmodule_CustomTagModule'): + logger.debug('####### Processing xmodule_CustomTagModule') + pass + + else: + assert False, "Class for element not recognized!!" + + + +def process_problem(element, problem_id): + ''' + Process problem attempts to + 1) scan all the input fields and reset them + 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') + 3) click the 'show answer' button IF it exists and IF the answer is not already displayed + 4) enter the correct answer in each input box + 5) click the 'check' button and verify that answers are correct + + Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. + The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. + ''' + + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + + ## clear out all input to ensure an incorrect result + for field in input_fields: + field.find_by_css("input").first.fill('') + + ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' + # This would need to be reworked because multiple choice problems don't have this status + # if prob_xmod.find_by_css("p.status").first.text.strip().lower() != 'incorrect': + prob_xmod.find_by_css("section.action input.check").first.click() + + ## all elements become disconnected after the click + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + # Wait for the ajax reload + assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) + element = world.browser.find_by_css("section[id='%s']" % problem_id).first + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + for field in input_fields: + assert field.find_by_css("div.incorrect"), "The 'check' button did not work for %s" % (problem_id) + + show_button = element.find_by_css("section.action input.show").first + ## this logic is to ensure we do not accidentally hide the answers + if show_button.value.lower() == 'show answer': + show_button.click() + else: + pass + + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) + element = world.browser.find_by_css("section[id='%s']" % problem_id).first + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + + ## in each field, find the answer, and send it to the field. + ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" + for field in input_fields: + field.find_by_css("input").first.fill(field.find_by_css("p[id^='answer']").first.text) + + prob_xmod.find_by_css("section.action input.check").first.click() + + ## assert that we entered the correct answers + ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) + assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) + element = world.browser.find_by_css("section[id='%s']" % problem_id).first + prob_xmod = element.find_by_css("section.problem").first + input_fields = prob_xmod.find_by_css("section[id^='input']") + for field in input_fields: + ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) + assert field.find_by_css("div[class^='correct']"), "The check answer values were not correct for %s" % problem_id diff --git a/lms/djangoapps/courseware/features/courseware.feature b/lms/djangoapps/courseware/features/courseware.feature new file mode 100644 index 0000000000..21c7e84541 --- /dev/null +++ b/lms/djangoapps/courseware/features/courseware.feature @@ -0,0 +1,18 @@ +Feature: View the Courseware Tab + As a student in an edX course + In order to work on the course + I want to view the info on the courseware tab + + Scenario: I can get to the courseware tab when logged in + Given I am registered for a course + And I log in + And I click on View Courseware + When I click on the "Courseware" tab + Then the "Courseware" tab is active + + # TODO: fix this one? Not sure whether you should get a 404. + # Scenario: I cannot get to the courseware tab when not logged in + # Given I am not logged in + # And I visit the homepage + # When I visit the courseware URL + # Then the login dialog is visible diff --git a/lms/djangoapps/courseware/features/courseware.py b/lms/djangoapps/courseware/features/courseware.py new file mode 100644 index 0000000000..05ecd63f4b --- /dev/null +++ b/lms/djangoapps/courseware/features/courseware.py @@ -0,0 +1,7 @@ +from lettuce import world, step +from lettuce.django import django_url + +@step('I visit the courseware URL$') +def i_visit_the_course_info_url(step): + url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') + world.browser.visit(url) \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py new file mode 100644 index 0000000000..8850c88fef --- /dev/null +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -0,0 +1,37 @@ +from lettuce import world, step +from lettuce.django import django_url + +@step('I click on View Courseware') +def i_click_on_view_courseware(step): + css = 'p.enter-course' + world.browser.find_by_css(css).first.click() + +@step('I click on the "([^"]*)" tab$') +def i_click_on_the_tab(step, tab): + world.browser.find_link_by_text(tab).first.click() + world.save_the_html() + +@step('I visit the courseware URL$') +def i_visit_the_course_info_url(step): + url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') + world.browser.visit(url) + +@step(u'I do not see "([^"]*)" anywhere on the page') +def i_do_not_see_text_anywhere_on_the_page(step, text): + assert world.browser.is_text_not_present(text) + +@step(u'I am on the dashboard page$') +def i_am_on_the_dashboard_page(step): + assert world.browser.is_element_present_by_css('section.courses') + assert world.browser.url == django_url('/dashboard') + +@step('the "([^"]*)" tab is active$') +def the_tab_is_active(step, tab): + css = '.course-tabs a.active' + active_tab = world.browser.find_by_css(css) + assert (active_tab.text == tab) + +@step('the login dialog is visible$') +def login_dialog_visible(step): + css = 'form#login_form.login_form' + assert world.browser.find_by_css(css).visible diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature new file mode 100644 index 0000000000..2e9c4f1886 --- /dev/null +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -0,0 +1,23 @@ +Feature: All the high level tabs should work + In order to preview the courseware + As a student + I want to navigate through the high level tabs + +# Note this didn't work as a scenario outline because +# before each scenario was not flushing the database +# TODO: break this apart so that if one fails the others +# will still run + Scenario: A student can see all tabs of the course + Given I am registered for a course + And I log in + And I click on View Courseware + When I click on the "Courseware" tab + Then the page title should be "6.002x Courseware" + When I click on the "Course Info" tab + Then the page title should be "6.002x Course Info" + When I click on the "Textbook" tab + Then the page title should be "6.002x Textbook" + When I click on the "Wiki" tab + Then the page title should be "6.002x | edX Wiki" + When I click on the "Progress" tab + Then the page title should be "6.002x Progress" diff --git a/lms/djangoapps/courseware/features/openended.feature b/lms/djangoapps/courseware/features/openended.feature new file mode 100644 index 0000000000..3c7043ba54 --- /dev/null +++ b/lms/djangoapps/courseware/features/openended.feature @@ -0,0 +1,33 @@ +Feature: Open ended grading + As a student in an edX course + In order to complete the courseware questions + I want the machine learning grading to be functional + + Scenario: An answer that is too short is rejected + Given I navigate to an openended question + And I enter the answer "z" + When I press the "Check" button + And I wait for "8" seconds + And I see the grader status "Submitted for grading" + And I press the "Recheck for Feedback" button + Then I see the red X + And I see the grader score "0" + + Scenario: An answer with too many spelling errors is rejected + Given I navigate to an openended question + And I enter the answer "az" + When I press the "Check" button + And I wait for "8" seconds + And I see the grader status "Submitted for grading" + And I press the "Recheck for Feedback" button + Then I see the red X + And I see the grader score "0" + When I click the link for full output + Then I see the spelling grading message "More spelling errors than average." + + Scenario: An answer makes its way to the instructor dashboard + Given I navigate to an openended question as staff + When I submit the answer "I love Chemistry." + And I wait for "8" seconds + And I visit the staff grading page + Then my answer is queued for instructor grading \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py new file mode 100644 index 0000000000..d37f9a0fae --- /dev/null +++ b/lms/djangoapps/courseware/features/openended.py @@ -0,0 +1,89 @@ +from lettuce import world, step +from lettuce.django import django_url +from nose.tools import assert_equals, assert_in +from logging import getLogger +logger = getLogger(__name__) + +@step('I navigate to an openended question$') +def navigate_to_an_openended_question(step): + world.register_by_course_id('MITx/3.091x/2012_Fall') + world.log_in('robot@edx.org','test') + problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' + world.browser.visit(django_url(problem)) + tab_css = 'ol#sequence-list > li > a[data-element="5"]' + world.browser.find_by_css(tab_css).click() + +@step('I navigate to an openended question as staff$') +def navigate_to_an_openended_question_as_staff(step): + world.register_by_course_id('MITx/3.091x/2012_Fall', True) + world.log_in('robot@edx.org','test') + problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' + world.browser.visit(django_url(problem)) + tab_css = 'ol#sequence-list > li > a[data-element="5"]' + world.browser.find_by_css(tab_css).click() + +@step(u'I enter the answer "([^"]*)"$') +def enter_the_answer_text(step, text): + textarea_css = 'textarea' + world.browser.find_by_css(textarea_css).first.fill(text) + +@step(u'I submit the answer "([^"]*)"$') +def i_submit_the_answer_text(step, text): + textarea_css = 'textarea' + world.browser.find_by_css(textarea_css).first.fill(text) + check_css = 'input.check' + world.browser.find_by_css(check_css).click() + +@step('I click the link for full output$') +def click_full_output_link(step): + link_css = 'a.full' + world.browser.find_by_css(link_css).first.click() + +@step(u'I visit the staff grading page$') +def i_visit_the_staff_grading_page(step): + # course_u = '/courses/MITx/3.091x/2012_Fall' + # sg_url = '%s/staff_grading' % course_u + world.browser.click_link_by_text('Instructor') + world.browser.click_link_by_text('Staff grading') + # world.browser.visit(django_url(sg_url)) + +@step(u'I see the grader message "([^"]*)"$') +def see_grader_message(step, msg): + message_css = 'div.external-grader-message' + grader_msg = world.browser.find_by_css(message_css).text + assert_in(msg, grader_msg) + +@step(u'I see the grader status "([^"]*)"$') +def see_the_grader_status(step, status): + status_css = 'div.grader-status' + grader_status = world.browser.find_by_css(status_css).text + assert_equals(status, grader_status) + +@step('I see the red X$') +def see_the_red_x(step): + x_css = 'div.grader-status > span.incorrect' + assert world.browser.find_by_css(x_css) + +@step(u'I see the grader score "([^"]*)"$') +def see_the_grader_score(step, score): + score_css = 'div.result-output > p' + score_text = world.browser.find_by_css(score_css).text + assert_equals(score_text, 'Score: %s' % score) + +@step('I see the link for full output$') +def see_full_output_link(step): + link_css = 'a.full' + assert world.browser.find_by_css(link_css) + +@step('I see the spelling grading message "([^"]*)"$') +def see_spelling_msg(step, msg): + spelling_css = 'div.spelling' + spelling_msg = world.browser.find_by_css(spelling_css).text + assert_equals('Spelling: %s' % msg, spelling_msg) + +@step(u'my answer is queued for instructor grading$') +def answer_is_queued_for_instructor_grading(step): + list_css = 'ul.problem-list > li > a' + actual_msg = world.browser.find_by_css(list_css).text + expected_msg = "(0 graded, 1 pending)" + assert_in(expected_msg, actual_msg) diff --git a/lms/djangoapps/courseware/features/smart-accordion.feature b/lms/djangoapps/courseware/features/smart-accordion.feature new file mode 100644 index 0000000000..90d097144a --- /dev/null +++ b/lms/djangoapps/courseware/features/smart-accordion.feature @@ -0,0 +1,59 @@ +# Here are all the courses for Fall 2012 +# MITx/3.091x/2012_Fall +# MITx/6.002x/2012_Fall +# MITx/6.00x/2012_Fall +# HarvardX/CS50x/2012 (we will not be testing this, as it is anomolistic) +# HarvardX/PH207x/2012_Fall +# BerkeleyX/CS169.1x/2012_Fall +# BerkeleyX/CS169.2x/2012_Fall +# BerkeleyX/CS184.1x/2012_Fall + +#You can load the courses into your data directory with these cmds: +# git clone https://github.com/MITx/3.091x.git +# git clone https://github.com/MITx/6.00x.git +# git clone https://github.com/MITx/content-mit-6002x.git +# git clone https://github.com/MITx/content-mit-6002x.git +# git clone https://github.com/MITx/content-harvard-id270x.git +# git clone https://github.com/MITx/content-berkeley-cs169x.git +# git clone https://github.com/MITx/content-berkeley-cs169.2x.git +# git clone https://github.com/MITx/content-berkeley-cs184x.git + +Feature: There are courses on the homepage + In order to compared rendered content to the database + As an acceptance test + I want to count all the chapters, sections, and tabs for each course + + Scenario: Navigate through course MITx/3.091x/2012_Fall + Given I am registered for course "MITx/3.091x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course MITx/6.002x/2012_Fall + Given I am registered for course "MITx/6.002x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course MITx/6.00x/2012_Fall + Given I am registered for course "MITx/6.00x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course HarvardX/PH207x/2012_Fall + Given I am registered for course "HarvardX/PH207x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS169.1x/2012_Fall + Given I am registered for course "BerkeleyX/CS169.1x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS169.2x/2012_Fall + Given I am registered for course "BerkeleyX/CS169.2x/2012_Fall" + And I log in + Then I verify all the content of each course + + Scenario: Navigate through course BerkeleyX/CS184.1x/2012_Fall + Given I am registered for course "BerkeleyX/CS184.1x/2012_Fall" + And I log in + Then I verify all the content of each course \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py new file mode 100644 index 0000000000..95d3396f57 --- /dev/null +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -0,0 +1,152 @@ +from lettuce import world, step +from re import sub +from nose.tools import assert_equals +from xmodule.modulestore.django import modulestore +from courses import * + +from logging import getLogger +logger = getLogger(__name__) + +def check_for_errors(): + e = world.browser.find_by_css('.outside-app') + if len(e) > 0: + assert False, 'there was a server error at %s' % (world.browser.url) + else: + assert True + +@step(u'I verify all the content of each course') +def i_verify_all_the_content_of_each_course(step): + all_possible_courses = get_courses() + logger.debug('Courses found:') + for c in all_possible_courses: + logger.debug(c.id) + ids = [c.id for c in all_possible_courses] + + # Get a list of all the registered courses + registered_courses = world.browser.find_by_css('article.my-course') + if len(all_possible_courses) < len(registered_courses): + assert False, "user is registered for more courses than are uniquely posssible" + else: + pass + + for test_course in registered_courses: + test_course.find_by_css('a').click() + check_for_errors() + + # Get the course. E.g. 'MITx/6.002x/2012_Fall' + current_course = sub('/info','', sub('.*/courses/', '', world.browser.url)) + validate_course(current_course,ids) + + world.browser.find_link_by_text('Courseware').click() + assert world.browser.is_element_present_by_id('accordion',wait_time=2) + check_for_errors() + browse_course(current_course) + + # clicking the user link gets you back to the user's home page + world.browser.find_by_css('.user-link').click() + check_for_errors() + +def browse_course(course_id): + + ## count chapters from xml and page and compare + chapters = get_courseware_with_tabs(course_id) + num_chapters = len(chapters) + + rendered_chapters = world.browser.find_by_css('#accordion > nav > div') + num_rendered_chapters = len(rendered_chapters) + + msg = '%d chapters expected, %d chapters found on page for %s' % (num_chapters, num_rendered_chapters, course_id) + #logger.debug(msg) + assert num_chapters == num_rendered_chapters, msg + + chapter_it = 0 + + ## Iterate the chapters + while chapter_it < num_chapters: + + ## click into a chapter + world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('h3').click() + + ## look for the "there was a server error" div + check_for_errors() + + ## count sections from xml and page and compare + sections = chapters[chapter_it]['sections'] + num_sections = len(sections) + + rendered_sections = world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li') + num_rendered_sections = len(rendered_sections) + + msg = ('%d sections expected, %d sections found on page, %s - %d - %s' % + (num_sections, num_rendered_sections, course_id, chapter_it, chapters[chapter_it]['chapter_name'])) + #logger.debug(msg) + assert num_sections == num_rendered_sections, msg + + section_it = 0 + + ## Iterate the sections + while section_it < num_sections: + + ## click on a section + world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li')[section_it].find_by_tag('a').click() + + ## sometimes the course-content takes a long time to load + assert world.browser.is_element_present_by_css('.course-content',wait_time=5) + + ## look for server error div + check_for_errors() + + ## count tabs from xml and page and compare + + ## count the number of tabs. If number of tabs is 0, there won't be anything rendered + ## so we explicitly set rendered_tabs because otherwise find_elements returns a None object with no length + num_tabs = sections[section_it]['clickable_tab_count'] + if num_tabs != 0: + rendered_tabs = world.browser.find_by_css('ol#sequence-list > li') + num_rendered_tabs = len(rendered_tabs) + else: + rendered_tabs = 0 + num_rendered_tabs = 0 + + msg = ('%d tabs expected, %d tabs found, %s - %d - %s' % + (num_tabs, num_rendered_tabs, course_id, section_it, sections[section_it]['section_name'])) + #logger.debug(msg) + + # Save the HTML to a file for later comparison + world.save_the_course_content('/tmp/%s' % course_id) + + assert num_tabs == num_rendered_tabs, msg + + tabs = sections[section_it]['tabs'] + tab_it = 0 + + ## Iterate the tabs + while tab_it < num_tabs: + + rendered_tabs[tab_it].find_by_tag('a').click() + + ## do something with the tab sections[section_it] + # e = world.browser.find_by_css('section.course-content section') + # process_section(e) + tab_children = tabs[tab_it]['children_count'] + tab_class = tabs[tab_it]['class'] + if tab_children != 0: + rendered_items = world.browser.find_by_css('div#seq_content > section > ol > li > section') + num_rendered_items = len(rendered_items) + msg = ('%d items expected, %d items found, %s - %d - %s - tab %d' % + (tab_children, num_rendered_items, course_id, section_it, sections[section_it]['section_name'], tab_it)) + #logger.debug(msg) + assert tab_children == num_rendered_items, msg + + tab_it += 1 + + section_it += 1 + + chapter_it += 1 + + +def validate_course(current_course, ids): + try: + ids.index(current_course) + except: + assert False, "invalid course id %s" % current_course diff --git a/lms/djangoapps/portal/README.md b/lms/djangoapps/portal/README.md new file mode 100644 index 0000000000..09930ec8fb --- /dev/null +++ b/lms/djangoapps/portal/README.md @@ -0,0 +1,15 @@ +## acceptance_testing + +This fake django app is here to support acceptance testing using lettuce + +splinter (which wraps selenium). + +First you need to make sure that you've installed the requirements. +This includes lettuce, selenium, splinter, etc. +Do this with: +```pip install -r test-requirements.txt``` + +The settings.py environment file used is named acceptance.py. +It uses a test SQLite database defined as ../db/test-mitx.db. +You need to first start up the server separately, then run the lettuce scenarios. + +Full documentation can be found on the wiki at this link. diff --git a/lms/djangoapps/portal/__init__.py b/lms/djangoapps/portal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/portal/features/common.py b/lms/djangoapps/portal/features/common.py new file mode 100644 index 0000000000..20c2ab56b8 --- /dev/null +++ b/lms/djangoapps/portal/features/common.py @@ -0,0 +1,84 @@ +from lettuce import world, step#, before, after +from factories import * +from django.core.management import call_command +from nose.tools import assert_equals, assert_in +from lettuce.django import django_url +from django.conf import settings +from django.contrib.auth.models import User +from student.models import CourseEnrollment +import time + +from logging import getLogger +logger = getLogger(__name__) + +@step(u'I wait (?:for )?"(\d+)" seconds?$') +def wait(step, seconds): + time.sleep(float(seconds)) + +@step('I (?:visit|access|open) the homepage$') +def i_visit_the_homepage(step): + world.browser.visit(django_url('/')) + assert world.browser.is_element_present_by_css('header.global', 10) + +@step(u'I (?:visit|access|open) the dashboard$') +def i_visit_the_dashboard(step): + world.browser.visit(django_url('/dashboard')) + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + +@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') +def click_the_link_called(step, text): + world.browser.find_link_by_text(text).click() + +@step('I should be on the dashboard page$') +def i_should_be_on_the_dashboard(step): + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.browser.title == 'Dashboard' + +@step(u'I (?:visit|access|open) the courses page$') +def i_am_on_the_courses_page(step): + world.browser.visit(django_url('/courses')) + assert world.browser.is_element_present_by_css('section.courses') + +@step('I should see that the path is "([^"]*)"$') +def i_should_see_that_the_path_is(step, path): + assert world.browser.url == django_url(path) + +@step(u'the page title should be "([^"]*)"$') +def the_page_title_should_be(step, title): + assert world.browser.title == title + +@step(r'should see that the url is "([^"]*)"$') +def should_have_the_url(step, url): + assert_equals(world.browser.url, url) + +@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') +def should_see_a_link_called(step, text): + assert len(world.browser.find_link_by_text(text)) > 0 + +@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') +def should_see_in_the_page(step, text): + assert_in(text, world.browser.html) + +@step('I am logged in$') +def i_am_logged_in(step): + world.create_user('robot') + world.log_in('robot@edx.org', 'test') + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() + +@step(u'I am registered for a course$') +def i_am_registered_for_a_course(step): + world.create_user('robot') + u = User.objects.get(username='robot') + CourseEnrollment.objects.create(user=u, course_id='MITx/6.002x/2012_Fall') + world.log_in('robot@edx.org', 'test') + +@step(u'I am an edX user$') +def i_am_an_edx_user(step): + world.create_user('robot') + +@step(u'User "([^"]*)" is an edX user$') +def registered_edx_user(step, uname): + world.create_user(uname) diff --git a/lms/djangoapps/portal/features/factories.py b/lms/djangoapps/portal/features/factories.py new file mode 100644 index 0000000000..07b615f468 --- /dev/null +++ b/lms/djangoapps/portal/features/factories.py @@ -0,0 +1,34 @@ +import factory +from student.models import User, UserProfile, Registration +from datetime import datetime +import uuid + +class UserProfileFactory(factory.Factory): + FACTORY_FOR = UserProfile + + user = None + name = 'Jack Foo' + level_of_education = None + gender = 'm' + mailing_address = None + goals = 'World domination' + +class RegistrationFactory(factory.Factory): + FACTORY_FOR = Registration + + user = None + activation_key = uuid.uuid4().hex + +class UserFactory(factory.Factory): + FACTORY_FOR = User + + username = 'robot' + email = 'robot+test@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Test' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime(2012, 1, 1) + date_joined = datetime(2011, 1, 1) diff --git a/lms/djangoapps/portal/features/homepage.feature b/lms/djangoapps/portal/features/homepage.feature new file mode 100644 index 0000000000..06a45c4bfa --- /dev/null +++ b/lms/djangoapps/portal/features/homepage.feature @@ -0,0 +1,47 @@ +Feature: Homepage for web users + In order to get an idea what edX is about + As a an anonymous web user + I want to check the information on the home page + + Scenario: User can see the "Login" button + Given I visit the homepage + Then I should see a link called "Log In" + + Scenario: User can see the "Sign up" button + Given I visit the homepage + Then I should see a link called "Sign Up" + + Scenario Outline: User can see main parts of the page + Given I visit the homepage + Then I should see a link called "" + When I click the link with the text "" + Then I should see that the path is "" + + Examples: + | Link | Path | + | Find Courses | /courses | + | About | /about | + | Jobs | /jobs | + | Contact | /contact | + + Scenario: User can visit the blog + Given I visit the homepage + When I click the link with the text "Blog" + Then I should see that the url is "http://blog.edx.org/" + + # TODO: test according to domain or policy + Scenario: User can see the partner institutions + Given I visit the homepage + Then I should see "" in the Partners section + + Examples: + | Partner | + | MITx | + | HarvardX | + | BerkeleyX | + | UTx | + | WellesleyX | + | GeorgetownX | + + # # TODO: Add scenario that tests the courses available + # # using a policy or a configuration file diff --git a/lms/djangoapps/portal/features/homepage.py b/lms/djangoapps/portal/features/homepage.py new file mode 100644 index 0000000000..638d65077c --- /dev/null +++ b/lms/djangoapps/portal/features/homepage.py @@ -0,0 +1,8 @@ +from lettuce import world, step +from nose.tools import assert_in + +@step('I should see "([^"]*)" in the Partners section$') +def i_should_see_partner(step, partner): + partners = world.browser.find_by_css(".partner .name span") + names = set(span.text for span in partners) + assert_in(partner, names) diff --git a/lms/djangoapps/portal/features/login.feature b/lms/djangoapps/portal/features/login.feature new file mode 100644 index 0000000000..23317b4876 --- /dev/null +++ b/lms/djangoapps/portal/features/login.feature @@ -0,0 +1,27 @@ +Feature: Login in as a registered user + As a registered user + In order to access my content + I want to be able to login in to edX + + Scenario: Login to an unactivated account + Given I am an edX user + And I am an unactivated user + And I visit the homepage + When I click the link with the text "Log In" + And I submit my credentials on the login form + Then I should see the login error message "This account has not been activated" + + Scenario: Login to an activated account + Given I am an edX user + And I am an activated user + And I visit the homepage + When I click the link with the text "Log In" + And I submit my credentials on the login form + Then I should be on the dashboard page + + Scenario: Logout of a signed in account + Given I am logged in + When I click the dropdown arrow + And I click the link with the text "Log Out" + Then I should see a link with the text "Log In" + And I should see that the path is "/" diff --git a/lms/djangoapps/portal/features/login.py b/lms/djangoapps/portal/features/login.py new file mode 100644 index 0000000000..5f200eb259 --- /dev/null +++ b/lms/djangoapps/portal/features/login.py @@ -0,0 +1,45 @@ +from lettuce import step, world +from django.contrib.auth.models import User + +@step('I am an unactivated user$') +def i_am_an_unactivated_user(step): + user_is_an_unactivated_user('robot') + +@step('I am an activated user$') +def i_am_an_activated_user(step): + user_is_an_activated_user('robot') + +@step('I submit my credentials on the login form') +def i_submit_my_credentials_on_the_login_form(step): + fill_in_the_login_form('email', 'robot@edx.org') + fill_in_the_login_form('password', 'test') + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_value('Access My Courses').click() + +@step(u'I should see the login error message "([^"]*)"$') +def i_should_see_the_login_error_message(step, msg): + login_error_div = world.browser.find_by_css('form#login_form #login_error') + assert (msg in login_error_div.text) + +@step(u'click the dropdown arrow$') +def click_the_dropdown(step): + css = ".dropdown" + e = world.browser.find_by_css(css) + e.click() + +#### helper functions + +def user_is_an_unactivated_user(uname): + u = User.objects.get(username=uname) + u.is_active = False + u.save() + +def user_is_an_activated_user(uname): + u = User.objects.get(username=uname) + u.is_active = True + u.save() + +def fill_in_the_login_form(field, value): + login_form = world.browser.find_by_css('form#login_form') + form_field = login_form.find_by_name(field) + form_field.fill(value) diff --git a/lms/djangoapps/portal/features/registration.feature b/lms/djangoapps/portal/features/registration.feature new file mode 100644 index 0000000000..d8a6796ee3 --- /dev/null +++ b/lms/djangoapps/portal/features/registration.feature @@ -0,0 +1,17 @@ +Feature: Register for a course + As a registered user + In order to access my class content + I want to register for a class on the edX website + + Scenario: I can register for a course + Given I am logged in + And I visit the courses page + When I register for the course numbered "6.002x" + Then I should see the course numbered "6.002x" in my dashboard + + Scenario: I can unregister for a course + Given I am registered for a course + And I visit the dashboard + When I click the link with the text "Unregister" + And I press the "Unregister" button in the Unenroll dialog + Then I should see "Looks like you haven't registered for any courses yet." somewhere in the page \ No newline at end of file diff --git a/lms/djangoapps/portal/features/registration.py b/lms/djangoapps/portal/features/registration.py new file mode 100644 index 0000000000..124bed4923 --- /dev/null +++ b/lms/djangoapps/portal/features/registration.py @@ -0,0 +1,24 @@ +from lettuce import world, step + +@step('I register for the course numbered "([^"]*)"$') +def i_register_for_the_course(step, course): + courses_section = world.browser.find_by_css('section.courses') + course_link_css = 'article[id*="%s"] a' % course + course_link = courses_section.find_by_css(course_link_css).first + course_link.click() + + intro_section = world.browser.find_by_css('section.intro') + register_link = intro_section.find_by_css('a.register') + register_link.click() + + assert world.browser.is_element_present_by_css('section.container.dashboard') + +@step(u'I should see the course numbered "([^"]*)" in my dashboard$') +def i_should_see_that_course_in_my_dashboard(step, course): + course_link_css = 'section.my-courses a[href*="%s"]' % course + assert world.browser.is_element_present_by_css(course_link_css) + +@step(u'I press the "([^"]*)" button in the Unenroll dialog') +def i_press_the_button_in_the_unenroll_dialog(step, value): + button_css = 'section#unenroll-modal input[value="%s"]' % value + world.browser.find_by_css(button_css).click() diff --git a/lms/djangoapps/portal/features/signup.feature b/lms/djangoapps/portal/features/signup.feature new file mode 100644 index 0000000000..b28a6819a1 --- /dev/null +++ b/lms/djangoapps/portal/features/signup.feature @@ -0,0 +1,16 @@ +Feature: Sign in + In order to use the edX content + As a new user + I want to signup for a student account + + Scenario: Sign up from the homepage + Given I visit the homepage + When I click the link with the text "Sign Up" + And I fill in "email" on the registration form with "robot2@edx.org" + And I fill in "password" on the registration form with "test" + And I fill in "username" on the registration form with "robot2" + And I fill in "name" on the registration form with "Robot Two" + And I check the checkbox named "terms_of_service" + And I check the checkbox named "honor_code" + And I press the "Create My Account" button on the registration form + Then I should see "THANKS FOR REGISTERING!" in the dashboard banner diff --git a/lms/djangoapps/portal/features/signup.py b/lms/djangoapps/portal/features/signup.py new file mode 100644 index 0000000000..afde72b589 --- /dev/null +++ b/lms/djangoapps/portal/features/signup.py @@ -0,0 +1,22 @@ +from lettuce import world, step + +@step('I fill in "([^"]*)" on the registration form with "([^"]*)"$') +def when_i_fill_in_field_on_the_registration_form_with_value(step, field, value): + register_form = world.browser.find_by_css('form#register_form') + form_field = register_form.find_by_name(field) + form_field.fill(value) + +@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 check the checkbox named "([^"]*)"$') +def i_check_checkbox(step, checkbox): + world.browser.find_by_name(checkbox).check() + +@step('I should see "([^"]*)" in the dashboard banner$') +def i_should_see_text_in_the_dashboard_banner_section(step, text): + css_selector = "section.dashboard-banner h2" + assert (text in world.browser.find_by_css(css_selector).text) + \ No newline at end of file diff --git a/lms/djangoapps/terrain/__init__.py b/lms/djangoapps/terrain/__init__.py new file mode 100644 index 0000000000..dd6869e7fd --- /dev/null +++ b/lms/djangoapps/terrain/__init__.py @@ -0,0 +1,6 @@ +# Use this as your lettuce terrain file so that the common steps +# across all lms apps can be put in terrain/common +# See https://groups.google.com/forum/?fromgroups=#!msg/lettuce-users/5VyU9B4HcX8/USgbGIJdS5QJ +from terrain.browser import * +from terrain.steps import * +from terrain.factories import * \ No newline at end of file diff --git a/lms/djangoapps/terrain/browser.py b/lms/djangoapps/terrain/browser.py new file mode 100644 index 0000000000..1c2d401680 --- /dev/null +++ b/lms/djangoapps/terrain/browser.py @@ -0,0 +1,27 @@ +from lettuce import before, after, world +from splinter.browser import Browser +from logging import getLogger +import time + +logger = getLogger(__name__) +logger.info("Loading the lettuce acceptance testing terrain file...") + +from django.core.management import call_command + +@before.harvest +def initial_setup(server): + # Launch firefox + world.browser = Browser('chrome') + +@before.each_scenario +def reset_data(scenario): + # Clean out the django test database defined in the + # envs/acceptance.py file: mitx_all/db/test_mitx.db + logger.debug("Flushing the test database...") + call_command('flush', interactive=False) + +@after.all +def teardown_browser(total): + # Quit firefox + world.browser.quit() + pass \ No newline at end of file diff --git a/lms/djangoapps/terrain/factories.py b/lms/djangoapps/terrain/factories.py new file mode 100644 index 0000000000..ddab9e2b06 --- /dev/null +++ b/lms/djangoapps/terrain/factories.py @@ -0,0 +1,34 @@ +import factory +from student.models import User, UserProfile, Registration +from datetime import datetime +import uuid + +class UserProfileFactory(factory.Factory): + FACTORY_FOR = UserProfile + + user = None + name = 'Robot Test' + level_of_education = None + gender = 'm' + mailing_address = None + goals = 'World domination' + +class RegistrationFactory(factory.Factory): + FACTORY_FOR = Registration + + user = None + activation_key = uuid.uuid4().hex + +class UserFactory(factory.Factory): + FACTORY_FOR = User + + username = 'robot' + email = 'robot+test@edx.org' + password = 'test' + first_name = 'Robot' + last_name = 'Test' + is_staff = False + is_active = True + is_superuser = False + last_login = datetime(2012, 1, 1) + date_joined = datetime(2011, 1, 1) diff --git a/lms/djangoapps/terrain/steps.py b/lms/djangoapps/terrain/steps.py new file mode 100644 index 0000000000..6824fa16ce --- /dev/null +++ b/lms/djangoapps/terrain/steps.py @@ -0,0 +1,175 @@ +from lettuce import world, step +from factories import * +from django.core.management import call_command +from lettuce.django import django_url +from django.conf import settings +from django.contrib.auth.models import User +from student.models import CourseEnrollment +from urllib import quote_plus +from nose.tools import assert_equals +from bs4 import BeautifulSoup +import time +import re +import os.path + +from logging import getLogger +logger = getLogger(__name__) + +@step(u'I wait (?:for )?"(\d+)" seconds?$') +def wait(step, seconds): + time.sleep(float(seconds)) + +@step('I (?:visit|access|open) the homepage$') +def i_visit_the_homepage(step): + world.browser.visit(django_url('/')) + assert world.browser.is_element_present_by_css('header.global', 10) + +@step(u'I (?:visit|access|open) the dashboard$') +def i_visit_the_dashboard(step): + world.browser.visit(django_url('/dashboard')) + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + +@step('I should be on the dashboard page$') +def i_should_be_on_the_dashboard(step): + assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.browser.title == 'Dashboard' + +@step(u'I (?:visit|access|open) the courses page$') +def i_am_on_the_courses_page(step): + world.browser.visit(django_url('/courses')) + assert world.browser.is_element_present_by_css('section.courses') + +@step(u'I press the "([^"]*)" button$') +def and_i_press_the_button(step, value): + button_css = 'input[value="%s"]' % value + world.browser.find_by_css(button_css).first.click() + +@step(u'I click the link with the text "([^"]*)"$') +def click_the_link_with_the_text_group1(step, linktext): + world.browser.find_link_by_text(linktext).first.click() + +@step('I should see that the path is "([^"]*)"$') +def i_should_see_that_the_path_is(step, path): + assert world.browser.url == django_url(path) + +@step(u'the page title should be "([^"]*)"$') +def the_page_title_should_be(step, title): + assert_equals(world.browser.title, title) + +@step('I am a logged in user$') +def i_am_logged_in_user(step): + create_user('robot') + log_in('robot@edx.org','test') + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() + +@step('I am registered for a course$') +def i_am_registered_for_a_course(step): + create_user('robot') + u = User.objects.get(username='robot') + CourseEnrollment.objects.get_or_create(user=u, course_id='MITx/6.002x/2012_Fall') + +@step('I am registered for course "([^"]*)"$') +def i_am_registered_for_course_by_id(step, course_id): + register_by_course_id(course_id) + +@step('I am staff for course "([^"]*)"$') +def i_am_staff_for_course_by_id(step, course_id): + register_by_course_id(course_id, True) + +@step('I log in$') +def i_log_in(step): + log_in('robot@edx.org','test') + +@step(u'I am an edX user$') +def i_am_an_edx_user(step): + create_user('robot') + +#### helper functions +@world.absorb +def create_user(uname): + portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + portal_user.set_password('test') + portal_user.save() + + registration = RegistrationFactory(user=portal_user) + registration.register(portal_user) + registration.activate() + + user_profile = UserProfileFactory(user=portal_user) + +@world.absorb +def log_in(email, password): + world.browser.cookies.delete() + world.browser.visit(django_url('/')) + world.browser.is_element_present_by_css('header.global', 10) + world.browser.click_link_by_href('#login-modal') + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill(email) + login_form.find_by_name('password').fill(password) + login_form.find_by_name('submit').click() + + # wait for the page to redraw + assert world.browser.is_element_present_by_css('.content-wrapper', 10) + +@world.absorb +def register_by_course_id(course_id, is_staff=False): + create_user('robot') + u = User.objects.get(username='robot') + if is_staff: + u.is_staff=True + u.save() + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close + +@world.absorb +def save_the_course_content(path='/tmp'): + html = world.browser.html.encode('ascii', 'ignore') + soup = BeautifulSoup(html) + + # get rid of the header, we only want to compare the body + soup.head.decompose() + + # for now, remove the data-id attributes, because they are + # causing mismatches between cms-master and master + for item in soup.find_all(attrs={'data-id': re.compile('.*')}): + del item['data-id'] + + # we also need to remove them from unrendered problems, + # where they are contained in the text of divs instead of + # in attributes of tags + # Be careful of whether or not it was the last attribute + # and needs a trailing space + for item in soup.find_all(text=re.compile(' data-id=".*?" ')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) + + for item in soup.find_all(text=re.compile(' data-id=".*?"')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) + + # prettify the html so it will compare better, with + # each HTML tag on its own line + output = soup.prettify() + + # use string slicing to grab everything after 'courseware/' in the URL + u = world.browser.url + section_url = u[u.find('courseware/')+11:] + + if not os.path.exists(path): + os.makedirs(path) + + filename = '%s.html' % (quote_plus(section_url)) + f = open('%s/%s' % (path, filename), 'w') + f.write(output) + f.close diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py new file mode 100644 index 0000000000..e0857a4392 --- /dev/null +++ b/lms/envs/acceptance.py @@ -0,0 +1,41 @@ +""" +This config file extends the test environment configuration +so that we can run the lettuce acceptance tests. +""" +from .test import * + +# You need to start the server in debug mode, +# otherwise the browser will not render the pages correctly +DEBUG = True + +# Show the courses that are in the data directory +COURSES_ROOT = ENV_ROOT / "data" +DATA_DIR = COURSES_ROOT +MODULESTORE = { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': DATA_DIR, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + } + } +} + +# 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", + } +} + +# Do not display the YouTube videos in the browser while running the +# acceptance tests. This makes them faster and more reliable +MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True + +# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command +INSTALLED_APPS += ('lettuce.django',) +LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment diff --git a/lms/envs/cms/acceptance.py b/lms/envs/cms/acceptance.py new file mode 100644 index 0000000000..e5ee2937f4 --- /dev/null +++ b/lms/envs/cms/acceptance.py @@ -0,0 +1,23 @@ +""" +This config file is a copy of dev environment without the Debug +Toolbar. I it suitable to run against acceptance tests. + +""" +from .dev import * + +# REMOVE DEBUG TOOLBAR + +INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar') +INSTALLED_APPS = tuple(e for e in INSTALLED_APPS if e != 'debug_toolbar_mongo') + +MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ + if e != 'debug_toolbar.middleware.DebugToolbarMiddleware') + + +########################### LETTUCE TESTING ########################## +MITX_FEATURES['DISPLAY_TOY_COURSES'] = True + +INSTALLED_APPS += ('lettuce.django',) +# INSTALLED_APPS += ('portal',) + +LETTUCE_APPS = ('portal',) # dummy app covers the home page, login, registration, and course enrollment diff --git a/lms/envs/common.py b/lms/envs/common.py index 4118f3d744..7db8e6cd25 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -74,6 +74,8 @@ MITX_FEATURES = { 'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL + 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests + # extrernal access methods 'ACCESS_REQUIRE_STAFF_FOR_COURSE': False, 'AUTH_USE_OPENID': False, @@ -397,6 +399,7 @@ courseware_js = ( ) main_vendor_js = [ + 'js/vendor/RequireJS.js', 'js/vendor/json2.js', 'js/vendor/jquery.min.js', 'js/vendor/jquery-ui.min.js', @@ -408,6 +411,7 @@ main_vendor_js = [ discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.coffee')) + staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.coffee')) PIPELINE_CSS = { @@ -440,7 +444,6 @@ PIPELINE_JS = { rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.coffee')) - set(courseware_js + discussion_js + staff_grading_js) ) + [ - 'js/form.ext.js', 'js/my_courses_dropdown.js', 'js/toggle_login_modal.js', @@ -469,6 +472,7 @@ PIPELINE_JS = { 'source_filenames': staff_grading_js, 'output_filename': 'js/staff_grading.js' } + } PIPELINE_DISABLE_WRAPPER = True diff --git a/lms/static/coffee/files.json b/lms/static/coffee/files.json index 4721ef58bb..5dc03613b9 100644 --- a/lms/static/coffee/files.json +++ b/lms/static/coffee/files.json @@ -1,5 +1,6 @@ { "js_files": [ + "/static/js/vendor/RequireJS.js", "/static/js/vendor/jquery.min.js", "/static/js/vendor/jquery-ui.min.js", "/static/js/vendor/jquery.leanModal.min.js", diff --git a/lms/static/coffee/spec/requirejs_spec.coffee b/lms/static/coffee/spec/requirejs_spec.coffee new file mode 100644 index 0000000000..10d34a2f75 --- /dev/null +++ b/lms/static/coffee/spec/requirejs_spec.coffee @@ -0,0 +1,89 @@ +describe "RequireJS namespacing", -> + beforeEach -> + + # Jasmine does not provide a way to use the typeof operator. We need + # to create our own custom matchers so that a TypeError is not thrown. + @addMatchers + requirejsTobeUndefined: -> + typeof requirejs is "undefined" + + requireTobeUndefined: -> + typeof require is "undefined" + + defineTobeUndefined: -> + typeof define is "undefined" + + + it "check that the RequireJS object is present in the global namespace", -> + expect(RequireJS).toEqual jasmine.any(Object) + expect(window.RequireJS).toEqual jasmine.any(Object) + + it "check that requirejs(), require(), and define() are not in the global namespace", -> + + # The custom matchers that we defined in the beforeEach() function do + # not operate on an object. We pass a dummy empty object {} not to + # confuse Jasmine. + expect({}).requirejsTobeUndefined() + expect({}).requireTobeUndefined() + expect({}).defineTobeUndefined() + expect(window.requirejs).not.toBeDefined() + expect(window.require).not.toBeDefined() + expect(window.define).not.toBeDefined() + + +describe "RequireJS module creation", -> + inDefineCallback = undefined + inRequireCallback = undefined + it "check that we can use RequireJS to define() and require() a module", -> + + # Because Require JS works asynchronously when defining and requiring + # modules, we need to use the special Jasmine functions runs(), and + # waitsFor() to set up this test. + runs -> + + # Initialize the variable that we will test for. They will be set + # to true in the appropriate callback functions called by Require + # JS. If their values do not change, this will mean that something + # is not working as is intended. + inDefineCallback = false + inRequireCallback = false + + # Define our test module. + RequireJS.define "test_module", [], -> + inDefineCallback = true + + # This module returns an object. It can be accessed via the + # Require JS require() function. + module_status: "OK" + + + # Require our defined test module. + RequireJS.require ["test_module"], (test_module) -> + inRequireCallback = true + + # If our test module was defined properly, then we should + # be able to get the object it returned, and query some + # property. + expect(test_module.module_status).toBe "OK" + + + + # We will wait for a specified amount of time (1 second), before + # checking if our module was defined and that we were able to + # require() the module. + waitsFor (-> + + # If at least one of the callback functions was not reached, we + # fail this test. + return false if (inDefineCallback isnt true) or (inRequireCallback isnt true) + + # Both of the callbacks were reached. + true + ), "We should eventually end up in the defined callback", 1000 + + # The final test behavior, after waitsFor() finishes waiting. + runs -> + expect(inDefineCallback).toBeTruthy() + expect(inRequireCallback).toBeTruthy() + + diff --git a/lms/static/files/edx-identity.zip b/lms/static/files/edx-identity.zip new file mode 100644 index 0000000000..a37d2b4e20 Binary files /dev/null and b/lms/static/files/edx-identity.zip differ diff --git a/lms/static/images/press-kit/3.091x_high-res.png b/lms/static/images/press-kit/3.091x_high-res.png new file mode 100644 index 0000000000..4bd1950734 Binary files /dev/null and b/lms/static/images/press-kit/3.091x_high-res.png differ diff --git a/lms/static/images/press-kit/3.091x_x200.jpg b/lms/static/images/press-kit/3.091x_x200.jpg new file mode 100644 index 0000000000..9dff993e6b Binary files /dev/null and b/lms/static/images/press-kit/3.091x_x200.jpg differ diff --git a/lms/static/images/press-kit/6.002x_high-res.png b/lms/static/images/press-kit/6.002x_high-res.png new file mode 100755 index 0000000000..b785b825fd Binary files /dev/null and b/lms/static/images/press-kit/6.002x_high-res.png differ diff --git a/lms/static/images/press-kit/6.002x_x200.jpg b/lms/static/images/press-kit/6.002x_x200.jpg new file mode 100644 index 0000000000..c12588797c Binary files /dev/null and b/lms/static/images/press-kit/6.002x_x200.jpg differ diff --git a/lms/static/images/press-kit/anant-agarwal_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/anant-agarwal_high-res.jpg.REMOVED.git-id new file mode 100644 index 0000000000..414d117127 --- /dev/null +++ b/lms/static/images/press-kit/anant-agarwal_high-res.jpg.REMOVED.git-id @@ -0,0 +1 @@ +b154ce99fb5c8d413ba769e8cc0df94ed674c3f4 \ No newline at end of file diff --git a/lms/static/images/press-kit/anant-agarwal_x200.jpg b/lms/static/images/press-kit/anant-agarwal_x200.jpg new file mode 100644 index 0000000000..a004b7ecdb Binary files /dev/null and b/lms/static/images/press-kit/anant-agarwal_x200.jpg differ diff --git a/lms/static/images/press-kit/anant-tablet_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/anant-tablet_high-res.jpg.REMOVED.git-id new file mode 100644 index 0000000000..c3ba812427 --- /dev/null +++ b/lms/static/images/press-kit/anant-tablet_high-res.jpg.REMOVED.git-id @@ -0,0 +1 @@ +2b8c58b098bdb17f9ddcbb2098f94c50fdcedf60 \ No newline at end of file diff --git a/lms/static/images/press-kit/anant-tablet_x200.jpg b/lms/static/images/press-kit/anant-tablet_x200.jpg new file mode 100644 index 0000000000..1d35360745 Binary files /dev/null and b/lms/static/images/press-kit/anant-tablet_x200.jpg differ diff --git a/lms/static/images/press-kit/edx-video-editing_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/edx-video-editing_high-res.jpg.REMOVED.git-id new file mode 100644 index 0000000000..2b3ee55249 --- /dev/null +++ b/lms/static/images/press-kit/edx-video-editing_high-res.jpg.REMOVED.git-id @@ -0,0 +1 @@ +7d8b9879f7e5b859910edba7249661eedd3fcf37 \ No newline at end of file diff --git a/lms/static/images/press-kit/edx-video-editing_x200.jpg b/lms/static/images/press-kit/edx-video-editing_x200.jpg new file mode 100644 index 0000000000..c4ed87a503 Binary files /dev/null and b/lms/static/images/press-kit/edx-video-editing_x200.jpg differ diff --git a/lms/static/images/press-kit/piotr-mitros_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/piotr-mitros_high-res.jpg.REMOVED.git-id new file mode 100644 index 0000000000..23fb583f92 --- /dev/null +++ b/lms/static/images/press-kit/piotr-mitros_high-res.jpg.REMOVED.git-id @@ -0,0 +1 @@ +caf8b43337faa75cef5da5cd090010215a67b1bd \ No newline at end of file diff --git a/lms/static/images/press-kit/piotr-mitros_x200.jpg b/lms/static/images/press-kit/piotr-mitros_x200.jpg new file mode 100644 index 0000000000..36c21becd6 Binary files /dev/null and b/lms/static/images/press-kit/piotr-mitros_x200.jpg differ diff --git a/lms/static/sass/application.scss b/lms/static/sass/application.scss index ba485a4633..1b60622e57 100644 --- a/lms/static/sass/application.scss +++ b/lms/static/sass/application.scss @@ -22,6 +22,7 @@ @import 'multicourse/courses'; @import 'multicourse/course_about'; @import 'multicourse/jobs'; +@import 'multicourse/media-kit'; @import 'multicourse/about_pages'; @import 'multicourse/press_release'; @import 'multicourse/password_reset'; diff --git a/lms/static/sass/multicourse/_home.scss b/lms/static/sass/multicourse/_home.scss index 3d04165484..669bd889b0 100644 --- a/lms/static/sass/multicourse/_home.scss +++ b/lms/static/sass/multicourse/_home.scss @@ -336,6 +336,7 @@ border-bottom: 1px solid rgb(200,200,200); @include clearfix; padding: 10px 20px 8px; + position: relative; h2 { float: left; @@ -343,16 +344,27 @@ text-shadow: 0 1px rgba(255,255,255, 0.6); } - a { - color: $lighter-base-font-color; + .action.action-mediakit { float: right; - font-style: italic; - font-family: $serif; - padding-top: 3px; + position: relative; + top: 1px; + font-family: $sans-serif; + font-size: 14px; text-shadow: 0 1px rgba(255,255,255, 0.6); - &:hover { - color: $base-font-color; + &:after { + position: relative; + top: -1px; + display: inline-block; + margin: 0 0 0 5px; + content: "➤"; + font-size: 11px; + } + + .org-name { + color: $blue; + font-family: $sans-serif; + text-transform: none; } } } diff --git a/lms/static/sass/multicourse/_media-kit.scss b/lms/static/sass/multicourse/_media-kit.scss new file mode 100644 index 0000000000..db73029fd3 --- /dev/null +++ b/lms/static/sass/multicourse/_media-kit.scss @@ -0,0 +1,260 @@ +// vars +$baseline: 20px; +$white: rgb(255,255,255); + +.mediakit { + @include box-sizing(border-box); + margin: 0 auto; + padding: ($baseline*3) 0; + width: 980px; + + .wrapper-mediakit { + @include border-radius(4px); + @include box-sizing(border-box); + @include box-shadow(0 1px 10px 0 rgba(0,0,0, 0.1)); + margin: ($baseline*3) 0 0 0; + border: 1px solid $border-color; + padding: ($baseline*2) ($baseline*3); + + > section { + margin: 0 0 ($baseline*2) 0; + + &:last-child { + margin-bottom: 0; + } + + header { + + } + } + } + + h1 { + margin: 0 0 $baseline 0; + position: relative; + font-size: 36px; + } + + hr { + @extend .faded-hr-divider-light; + border: none; + margin: 0px; + position: relative; + z-index: 2; + + &::after { + @extend .faded-hr-divider; + bottom: 0px; + content: ""; + display: block; + position: absolute; + top: -1px; + } + } + + // general + a.action-download { + position: relative; + color: $blue; + font-family: $sans-serif; + text-decoration: none; + @include transition(all, 0.1s, linear); + + .note { + position: relative; + color: $blue; + font-family: $sans-serif; + font-size: 13px; + text-decoration: none; + @include transition(all, 0.1s, linear); + + &:before { + position: relative; + top: -1px; + margin: 0 5px 0 0; + content: "➤"; + font-size: 11px; + } + } + + &:hover { + + .note { + color: shade($blue, 25%); + } + } + } + + // introduction section + .introduction { + @include clearfix(); + + header { + margin: 0 0 ($baseline*1.5) 0; + + h2 { + margin: 0; + color: rgb(178, 181, 185); + font-size: 32px; + + .org-name { + color: rgb(178, 181, 185); + font-family: $serif; + text-transform: none; + } + } + } + + article { + @include box-sizing(border-box); + width: 500px; + margin-right: $baseline; + float: left; + } + + aside { + @include border-radius(2px); + @include box-sizing(border-box); + @include box-shadow(0 1px 4px 0 rgba(0,0,0, 0.2)); + width: 330px; + float: left; + border: 3px solid tint(rgb(96, 155, 216), 35%); + background: tint(rgb(96, 155, 216), 35%); + + h3 { + padding: ($baseline/2) ($baseline*0.75); + font-family: $sans-serif; + font-weight: bold; + font-size: 16px; + letter-spacing: 0; + color: $white; + text-transform: uppercase; + + .org-name { + color: $white !important; + font-weight: bold; + text-transform: none; + } + } + + a.action-download { + + .note { + width: 100%; + display: inline-block; + text-align: center; + } + } + + figure { + @include box-sizing(border-box); + background: $white; + width: 100%; + + figcaption { + display: none; + } + + a { + display: block; + padding: ($baseline/2); + } + + img { + display: block; + margin: 0 auto; + width: 60%; + } + } + } + } + + // library section + .library { + @include border-radius(2px); + @include box-sizing(border-box); + @include box-shadow(0 1px 4px 0 rgba(0,0,0, 0.2)); + border: 3px solid tint($light-gray,50%); + padding: 0; + background: tint($light-gray,50%); + + header { + padding: ($baseline*0.75) $baseline; + + h2 { + margin: 0; + padding: 0; + color: $dark-gray; + font-size: 16px; + font-family: $sans-serif; + font-weight: bold; + letter-spacing: 0; + + .org-name { + color: $dark-gray !important; + font-weight: bold; + text-transform: none; + } + } + } + + .listing { + @include clearfix(); + background: $white; + margin: 0; + padding: ($baseline*2); + list-style: none; + + li { + @include box-sizing(border-box); + overflow-y: auto; + float: left; + width: 350px; + margin: 0 0 $baseline 0; + + &:nth-child(odd) { + margin-right: ($baseline*3.5); + } + } + + figure { + + a { + @include border-radius(2px); + @include box-sizing(border-box); + @include box-shadow(0 1px 2px 0 rgba(0,0,0, 0.1)); + display: block; + min-height: 380px; + border: 2px solid tint($light-gray,75%); + padding: $baseline; + + &:hover { + border-color: $blue; + } + } + + img { + display: block; + border: 2px solid tint($light-gray,80%); + margin: 0 auto ($baseline*0.75) auto; + } + + figcaption { + font-size: 13px; + line-height: 18px; + color: $text-color; + } + + .note { + display: inline-block; + margin-top: ($baseline/2); + } + } + } + } + + // share + .share { + + } +} \ No newline at end of file diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 81268ff081..fb163d112d 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -18,7 +18,7 @@ diff --git a/lms/templates/courseware/progress_graph.js b/lms/templates/courseware/progress_graph.js index 189137ada3..449cad766f 100644 --- a/lms/templates/courseware/progress_graph.js +++ b/lms/templates/courseware/progress_graph.js @@ -1,4 +1,4 @@ -<%page args="grade_summary, grade_cutoffs, graph_div_id, **kwargs"/> +<%page args="grade_summary, grade_cutoffs, graph_div_id, show_grade_breakdown = True, show_grade_cutoffs = True, **kwargs"/> <%! import json import math @@ -70,25 +70,26 @@ $(function () { series = categories.values() overviewBarX = tickIndex extraColorIndex = len(categories) #Keeping track of the next color to use for categories not in categories[] - - for section in grade_summary['grade_breakdown']: - if section['percent'] > 0: - if section['category'] in categories: - color = categories[ section['category'] ]['color'] - else: - color = colors[ extraColorIndex % len(colors) ] - extraColorIndex += 1 - - series.append({ - 'label' : section['category'] + "-grade_breakdown", - 'data' : [ [overviewBarX, section['percent']] ], - 'color' : color - }) - - detail_tooltips[section['category'] + "-grade_breakdown"] = [ section['detail'] ] - ticks += [ [overviewBarX, "Total"] ] - tickIndex += 1 + sectionSpacer + if show_grade_breakdown: + for section in grade_summary['grade_breakdown']: + if section['percent'] > 0: + if section['category'] in categories: + color = categories[ section['category'] ]['color'] + else: + color = colors[ extraColorIndex % len(colors) ] + extraColorIndex += 1 + + series.append({ + 'label' : section['category'] + "-grade_breakdown", + 'data' : [ [overviewBarX, section['percent']] ], + 'color' : color + }) + + detail_tooltips[section['category'] + "-grade_breakdown"] = [ section['detail'] ] + + ticks += [ [overviewBarX, "Total"] ] + tickIndex += 1 + sectionSpacer totalScore = grade_summary['percent'] detail_tooltips['Dropped Scores'] = dropped_score_tooltips @@ -97,10 +98,14 @@ $(function () { ## ----------------------------- Grade cutoffs ------------------------- ## grade_cutoff_ticks = [ [1, "100%"], [0, "0%"] ] - descending_grades = sorted(grade_cutoffs, key=lambda x: grade_cutoffs[x], reverse=True) - for grade in descending_grades: - percent = grade_cutoffs[grade] - grade_cutoff_ticks.append( [ percent, "{0} {1:.0%}".format(grade, percent) ] ) + if show_grade_cutoffs: + grade_cutoff_ticks = [ [1, "100%"], [0, "0%"] ] + descending_grades = sorted(grade_cutoffs, key=lambda x: grade_cutoffs[x], reverse=True) + for grade in descending_grades: + percent = grade_cutoffs[grade] + grade_cutoff_ticks.append( [ percent, "{0} {1:.0%}".format(grade, percent) ] ) + else: + grade_cutoff_ticks = [ ] %> var series = ${ json.dumps( series ) }; @@ -135,9 +140,11 @@ $(function () { var $grade_detail_graph = $("#${graph_div_id}"); if ($grade_detail_graph.length > 0) { var plot = $.plot($grade_detail_graph, series, options); - //We need to put back the plotting of the percent here - var o = plot.pointOffset({x: ${overviewBarX} , y: ${totalScore}}); - $grade_detail_graph.append('
                  ${"{totalscore:.0%}".format(totalscore=totalScore)}
                  '); + + %if show_grade_breakdown: + var o = plot.pointOffset({x: ${overviewBarX} , y: ${totalScore}}); + $grade_detail_graph.append('
                  ${"{totalscore:.0%}".format(totalscore=totalScore)}
                  '); + %endif } var previousPoint = null; diff --git a/lms/templates/footer.html b/lms/templates/footer.html index 96c80d151d..7fe7c18ccc 100644 --- a/lms/templates/footer.html +++ b/lms/templates/footer.html @@ -6,7 +6,7 @@