Merge branch 'feature/cale/cms-master' into feature/christina/tiny-mce
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ lms/lib/comment_client/python
|
||||
nosetests.xml
|
||||
cover_html/
|
||||
.idea/
|
||||
chromedriver.log
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "common/test/phantom-jasmine"]
|
||||
path = common/test/phantom-jasmine
|
||||
url = https://github.com/jcarver989/phantom-jasmine.git
|
||||
path = common/test/phantom-jasmine
|
||||
url = https://github.com/jcarver989/phantom-jasmine.git
|
||||
25
apt-packages.txt
Normal file
25
apt-packages.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
python-software-properties
|
||||
pkg-config
|
||||
curl
|
||||
git
|
||||
python-virtualenv
|
||||
build-essential
|
||||
python-dev
|
||||
gfortran
|
||||
liblapack-dev
|
||||
libfreetype6-dev
|
||||
libpng12-dev
|
||||
libxml2-dev
|
||||
libxslt-dev
|
||||
yui-compressor
|
||||
graphviz
|
||||
graphviz-dev
|
||||
mysql-server
|
||||
libmysqlclient-dev
|
||||
libgeos-dev
|
||||
libreadline6
|
||||
libreadline6-dev
|
||||
mongodb
|
||||
nodejs
|
||||
npm
|
||||
coffeescript
|
||||
3
apt-repos.txt
Normal file
3
apt-repos.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
ppa:chris-lea/node.js
|
||||
ppa:chris-lea/node.js-libs
|
||||
ppa:chris-lea/libjs-underscore
|
||||
@@ -1,10 +1,12 @@
|
||||
readline
|
||||
sqlite
|
||||
gdbm
|
||||
pkg-config
|
||||
gfortran
|
||||
python
|
||||
yuicompressor
|
||||
readline
|
||||
sqlite
|
||||
gdbm
|
||||
pkg-config
|
||||
gfortran
|
||||
python
|
||||
yuicompressor
|
||||
node
|
||||
graphviz
|
||||
mysql
|
||||
geos
|
||||
mongodb
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
[run]
|
||||
data_file = reports/cms/.coverage
|
||||
source = cms
|
||||
omit = cms/envs/*, cms/manage.py
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
|
||||
[html]
|
||||
title = CMS Python Test Coverage Report
|
||||
directory = reports/cms/cover
|
||||
|
||||
[xml]
|
||||
|
||||
@@ -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("<ol></ol>")
|
||||
course_html_parsed = html.fromstring(course_updates.definition['data'])
|
||||
except:
|
||||
course_html_parsed = html.fromstring("<ol></ol>")
|
||||
|
||||
# Confirm that root is <ol>, iterate over <li>, pull out <h2> 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("<ol></ol>")
|
||||
course_html_parsed = html.fromstring(course_updates.definition['data'])
|
||||
except:
|
||||
course_html_parsed = html.fromstring("<ol></ol>")
|
||||
|
||||
# No try/catch b/c failure generates an error back to client
|
||||
new_html_parsed = etree.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
|
||||
new_html_parsed = html.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
|
||||
|
||||
# Confirm that root is <ol>, iterate over <li>, pull out <h2> 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("<ol></ol>")
|
||||
course_html_parsed = html.fromstring(course_updates.definition['data'])
|
||||
except:
|
||||
course_html_parsed = html.fromstring("<ol></ol>")
|
||||
|
||||
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'])
|
||||
|
||||
|
||||
127
cms/djangoapps/contentstore/features/common.py
Normal file
127
cms/djangoapps/contentstore/features/common.py
Normal file
@@ -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)
|
||||
13
cms/djangoapps/contentstore/features/courses.feature
Normal file
13
cms/djangoapps/contentstore/features/courses.feature
Normal file
@@ -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
|
||||
50
cms/djangoapps/contentstore/features/courses.py
Normal file
50
cms/djangoapps/contentstore/features/courses.py
Normal file
@@ -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')
|
||||
31
cms/djangoapps/contentstore/features/factories.py
Normal file
31
cms/djangoapps/contentstore/features/factories.py
Normal file
@@ -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()
|
||||
26
cms/djangoapps/contentstore/features/section.feature
Normal file
26
cms/djangoapps/contentstore/features/section.feature
Normal file
@@ -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
|
||||
82
cms/djangoapps/contentstore/features/section.py
Normal file
82
cms/djangoapps/contentstore/features/section.py
Normal file
@@ -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'
|
||||
12
cms/djangoapps/contentstore/features/signup.feature
Normal file
12
cms/djangoapps/contentstore/features/signup.feature
Normal file
@@ -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."
|
||||
23
cms/djangoapps/contentstore/features/signup.py
Normal file
23
cms/djangoapps/contentstore/features/signup.py
Normal file
@@ -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)
|
||||
18
cms/djangoapps/contentstore/features/subsection.feature
Normal file
18
cms/djangoapps/contentstore/features/subsection.feature
Normal file
@@ -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
|
||||
39
cms/djangoapps/contentstore/features/subsection.py
Normal file
39
cms/djangoapps/contentstore/features/subsection.py
Normal file
@@ -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)
|
||||
@@ -73,6 +73,10 @@ class XModuleItemFactory(Factory):
|
||||
|
||||
@classmethod
|
||||
def _create(cls, target_class, *args, **kwargs):
|
||||
"""
|
||||
kwargs must include parent_location, template. Can contain display_name
|
||||
target_class is ignored
|
||||
"""
|
||||
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
30
cms/djangoapps/contentstore/tests/test_course_updates.py
Normal file
30
cms/djangoapps/contentstore/tests/test_course_updates.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
import json
|
||||
|
||||
class CourseUpdateTest(CourseTestCase):
|
||||
def test_course_update(self):
|
||||
# first get the update to force the creation
|
||||
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
|
||||
'name' : self.course_location.name })
|
||||
self.client.get(url)
|
||||
|
||||
content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0"></iframe>'
|
||||
payload = { 'content' : content,
|
||||
'date' : 'January 8, 2013'}
|
||||
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
|
||||
'provided_id' : ''})
|
||||
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
payload= json.loads(resp.content)
|
||||
|
||||
self.assertHTMLEqual(content, payload['content'], "single iframe")
|
||||
|
||||
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
|
||||
'provided_id' : payload['id']})
|
||||
content += '<div>div <p>p</p></div>'
|
||||
payload['content'] = content
|
||||
resp = self.client.post(url, json.dumps(payload), "application/json")
|
||||
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div")
|
||||
@@ -22,6 +22,8 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from cms.djangoapps.contentstore.utils import get_modulestore
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
|
||||
def parse_json(response):
|
||||
"""Parse response, which is assumed to be json"""
|
||||
@@ -422,10 +424,12 @@ class ContentStoreTest(TestCase):
|
||||
ms = modulestore('direct')
|
||||
cs = contentstore()
|
||||
|
||||
# import a test course
|
||||
import_from_xml(ms, 'common/test/data/', ['full'])
|
||||
|
||||
handout_location= Location(['i4x', 'edX', 'full', 'course_info', 'handouts'])
|
||||
|
||||
# get module info
|
||||
resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location}))
|
||||
|
||||
# make sure we got a successful response
|
||||
@@ -436,13 +440,24 @@ class ContentStoreTest(TestCase):
|
||||
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
|
||||
|
||||
|
||||
def test_capa_module(self):
|
||||
"""Test that a problem treats markdown specially."""
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
problem_data = {
|
||||
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template' : 'i4x://edx/templates/problem/Empty'
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), problem_data)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = parse_json(resp)
|
||||
problem_loc = payload['id']
|
||||
problem = get_modulestore(problem_loc).get_item(problem_loc)
|
||||
# should be a CapaDescriptor
|
||||
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
|
||||
context = problem.get_context()
|
||||
self.assertIn('markdown', context, "markdown is missing from context")
|
||||
self.assertIn('markdown', problem.metadata, "markdown is missing from metadata")
|
||||
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")
|
||||
@@ -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)
|
||||
|
||||
@@ -271,6 +271,8 @@ def edit_unit(request, location):
|
||||
component_templates[template.location.category].append((
|
||||
template.display_name,
|
||||
template.location.url(),
|
||||
'markdown' in template.metadata,
|
||||
template.location.name == 'Empty'
|
||||
))
|
||||
|
||||
components = [
|
||||
@@ -973,6 +975,11 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
# ??? No way to check for access permission afaik
|
||||
# get current updates
|
||||
location = ['i4x', org, course, 'course_info', "updates"]
|
||||
|
||||
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
|
||||
# Possibly due to my removing the seemingly redundant pattern in urls.py
|
||||
if provided_id == '':
|
||||
provided_id = None
|
||||
|
||||
# check that logged in user has permissions to this item
|
||||
if not has_access(request.user, location):
|
||||
@@ -991,7 +998,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")
|
||||
|
||||
|
||||
@@ -1023,7 +1030,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
|
||||
|
||||
38
cms/envs/acceptance.py
Normal file
38
cms/envs/acceptance.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
clickSaveButton: (event) =>
|
||||
event.preventDefault()
|
||||
data = @module.save()
|
||||
data.metadata = @metadata()
|
||||
$modalCover.hide()
|
||||
data.metadata = _.extend(data.metadata, @metadata())
|
||||
@hideModal()
|
||||
@model.save(data).done( =>
|
||||
# # showToastMessage("Your changes have been saved.", null, 3)
|
||||
@module = null
|
||||
@@ -70,11 +70,15 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
event.preventDefault()
|
||||
@$el.removeClass('editing')
|
||||
@$component_editor().slideUp(150)
|
||||
@hideModal()
|
||||
|
||||
hideModal: ->
|
||||
$modalCover.hide()
|
||||
$modalCover.removeClass('is-fixed')
|
||||
|
||||
clickEditButton: (event) ->
|
||||
event.preventDefault()
|
||||
@$el.addClass('editing')
|
||||
$modalCover.show()
|
||||
$modalCover.show().addClass('is-fixed')
|
||||
@$component_editor().slideDown(150)
|
||||
@loadEdit()
|
||||
|
||||
@@ -59,6 +59,9 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
type = $(event.currentTarget).data('type')
|
||||
@$newComponentTypePicker.slideUp(250)
|
||||
@$(".new-component-#{type}").slideDown(250)
|
||||
$('html, body').animate({
|
||||
scrollTop: @$(".new-component-#{type}").offset().top
|
||||
}, 500)
|
||||
|
||||
closeNewComponent: (event) =>
|
||||
event.preventDefault()
|
||||
|
||||
BIN
cms/static/img/choice-example.png
Normal file
BIN
cms/static/img/choice-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
cms/static/img/multi-example.png
Normal file
BIN
cms/static/img/multi-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
cms/static/img/number-example.png
Normal file
BIN
cms/static/img/number-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
cms/static/img/problem-editor-icons.png
Normal file
BIN
cms/static/img/problem-editor-icons.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
cms/static/img/select-example.png
Normal file
BIN
cms/static/img/select-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
cms/static/img/string-example.png
Normal file
BIN
cms/static/img/string-example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -29,9 +29,7 @@ $(document).ready(function() {
|
||||
$('.expand-collapse-icon').bind('click', toggleSubmodules);
|
||||
$('.visibility-options').bind('change', setVisibility);
|
||||
|
||||
$('.unit-history ol a').bind('click', showHistoryModal);
|
||||
$modal.bind('click', hideModal);
|
||||
$modalCover.bind('click', hideHistoryModal);
|
||||
$modalCover.bind('click', hideModal);
|
||||
$('.assets .upload-button').bind('click', showUploadModal);
|
||||
$('.upload-modal .close-button').bind('click', hideModal);
|
||||
@@ -41,7 +39,13 @@ $(document).ready(function() {
|
||||
$('.unit .item-actions .delete-button').bind('click', deleteUnit);
|
||||
$('.new-unit-item').bind('click', createNewUnit);
|
||||
|
||||
$('.collapse-all-button').bind('click', collapseAll);
|
||||
// toggling overview section details
|
||||
$(function(){
|
||||
if($('.courseware-section').length > 0) {
|
||||
$('.toggle-button-sections').addClass('is-shown');
|
||||
}
|
||||
});
|
||||
$('.toggle-button-sections').bind('click', toggleSections);
|
||||
|
||||
// autosave when a field is updated on the subsection page
|
||||
$body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue);
|
||||
@@ -125,9 +129,30 @@ $(document).ready(function() {
|
||||
});
|
||||
});
|
||||
|
||||
function collapseAll(e) {
|
||||
$('.branch').addClass('collapsed');
|
||||
$('.expand-collapse-icon').removeClass('collapse').addClass('expand');
|
||||
// function collapseAll(e) {
|
||||
// $('.branch').addClass('collapsed');
|
||||
// $('.expand-collapse-icon').removeClass('collapse').addClass('expand');
|
||||
// }
|
||||
|
||||
function toggleSections(e) {
|
||||
e.preventDefault();
|
||||
|
||||
$section = $('.courseware-section');
|
||||
sectionCount = $section.length;
|
||||
$button = $(this);
|
||||
$labelCollapsed = $('<i class="ss-icon ss-symbolicons-block">up</i> <span class="label">Collapse All Sections</span>');
|
||||
$labelExpanded = $('<i class="ss-icon ss-symbolicons-block">down</i> <span class="label">Expand All Sections</span>');
|
||||
|
||||
var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded;
|
||||
$button.toggleClass('is-activated').html(buttonLabel);
|
||||
|
||||
if($button.hasClass('is-activated')) {
|
||||
$section.addClass('collapsed');
|
||||
$section.find('.expand-collapse-icon').removeClass('collapsed').addClass('expand');
|
||||
} else {
|
||||
$section.removeClass('collapsed');
|
||||
$section.find('.expand-collapse-icon').removeClass('expand').addClass('collapse');
|
||||
}
|
||||
}
|
||||
|
||||
function editSectionPublishDate(e) {
|
||||
@@ -498,9 +523,14 @@ function hideModal(e) {
|
||||
if(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
$('.file-input').unbind('change', startUpload);
|
||||
$modal.hide();
|
||||
$modalCover.hide();
|
||||
// Unit editors do not want the modal cover to hide when users click outside
|
||||
// of the editor. Users must press Cancel or Save to exit the editor.
|
||||
// module_edit adds and removes the "is-fixed" class.
|
||||
if (!$modalCover.hasClass("is-fixed")) {
|
||||
$('.file-input').unbind('change', startUpload);
|
||||
$modal.hide();
|
||||
$modalCover.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(e) {
|
||||
@@ -530,21 +560,6 @@ function closeComponentEditor(e) {
|
||||
$(this).closest('.xmodule_edit').removeClass('editing').find('.component-editor').slideUp(150);
|
||||
}
|
||||
|
||||
|
||||
function showHistoryModal(e) {
|
||||
e.preventDefault();
|
||||
|
||||
$modal.show();
|
||||
$modalCover.show();
|
||||
}
|
||||
|
||||
function hideHistoryModal(e) {
|
||||
e.preventDefault();
|
||||
|
||||
$modal.hide();
|
||||
$modalCover.hide();
|
||||
}
|
||||
|
||||
function showDateSetter(e) {
|
||||
e.preventDefault();
|
||||
var $block = $(this).closest('.due-date-input');
|
||||
|
||||
@@ -388,6 +388,9 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
var graceEle = this.$el.find('#course-grading-graceperiod');
|
||||
graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
|
||||
if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
|
||||
// remove any existing listeners to keep them from piling on b/c render gets called frequently
|
||||
graceEle.off('change', this.setGracePeriod);
|
||||
graceEle.on('change', this, this.setGracePeriod);
|
||||
|
||||
return this;
|
||||
},
|
||||
@@ -398,14 +401,16 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
|
||||
fieldToSelectorMap : {
|
||||
'grace_period' : 'course-grading-graceperiod'
|
||||
},
|
||||
setGracePeriod : function(event) {
|
||||
event.data.clearValidationErrors();
|
||||
var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
|
||||
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal);
|
||||
},
|
||||
updateModel : function(event) {
|
||||
if (!this.selectorToField[event.currentTarget.id]) return;
|
||||
|
||||
switch (this.selectorToField[event.currentTarget.id]) {
|
||||
case 'grace_period':
|
||||
this.clearValidationErrors();
|
||||
var newVal = this.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
|
||||
if (this.model.get('grace_period') != newVal) this.model.save('grace_period', newVal);
|
||||
case 'grace_period': // handled above
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -98,69 +98,69 @@
|
||||
}
|
||||
|
||||
.upload-modal {
|
||||
display: none;
|
||||
width: 640px !important;
|
||||
margin-left: -320px !important;
|
||||
display: none;
|
||||
width: 640px !important;
|
||||
margin-left: -320px !important;
|
||||
|
||||
.modal-body {
|
||||
height: auto !important;
|
||||
overflow-y: auto !important;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-body {
|
||||
height: auto !important;
|
||||
overflow-y: auto !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.choose-file-button {
|
||||
@include blue-button;
|
||||
padding: 10px 82px 12px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.choose-file-button {
|
||||
@include blue-button;
|
||||
padding: 10px 82px 12px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
display: none;
|
||||
width: 350px;
|
||||
height: 50px;
|
||||
margin: 30px auto 10px;
|
||||
border: 1px solid $blue;
|
||||
.progress-bar {
|
||||
display: none;
|
||||
width: 350px;
|
||||
height: 50px;
|
||||
margin: 30px auto 10px;
|
||||
border: 1px solid $blue;
|
||||
|
||||
&.loaded {
|
||||
border-color: #66b93d;
|
||||
&.loaded {
|
||||
border-color: #66b93d;
|
||||
|
||||
.progress-fill {
|
||||
background: #66b93d;
|
||||
}
|
||||
}
|
||||
}
|
||||
.progress-fill {
|
||||
background: #66b93d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
width: 0%;
|
||||
height: 50px;
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
line-height: 48px;
|
||||
}
|
||||
.progress-fill {
|
||||
width: 0%;
|
||||
height: 50px;
|
||||
background: $blue;
|
||||
color: #fff;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
float: none;
|
||||
margin: 40px 0 30px;
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
}
|
||||
h1 {
|
||||
float: none;
|
||||
margin: 40px 0 30px;
|
||||
font-size: 34px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@include white-button;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 15px;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
padding: 0 !important;
|
||||
border-radius: 17px !important;
|
||||
line-height: 29px;
|
||||
text-align: center;
|
||||
}
|
||||
.close-button {
|
||||
@include white-button;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 15px;
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
padding: 0 !important;
|
||||
border-radius: 17px !important;
|
||||
line-height: 29px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.embeddable {
|
||||
display: none;
|
||||
@@ -178,9 +178,9 @@
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
@include white-button;
|
||||
display: none;
|
||||
margin-bottom: 100px;
|
||||
}
|
||||
.copy-button {
|
||||
@include white-button;
|
||||
display: none;
|
||||
margin-bottom: 100px;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// -------------------------------------
|
||||
//
|
||||
// Universal
|
||||
// Universal
|
||||
//
|
||||
// -------------------------------------
|
||||
|
||||
|
||||
@@ -422,6 +422,14 @@ input.courseware-unit-search-input {
|
||||
float: left;
|
||||
margin: 29px 6px 16px 16px;
|
||||
@include transition(none);
|
||||
|
||||
&.expand {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
@@ -501,14 +509,31 @@ input.courseware-unit-search-input {
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-all-button {
|
||||
.toggle-button-sections {
|
||||
display: none;
|
||||
position: relative;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
|
||||
font-size: 13px;
|
||||
color: $darkGrey;
|
||||
|
||||
.collapse-all-icon {
|
||||
margin-right: 6px;
|
||||
&.is-shown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ss-icon {
|
||||
@include border-radius(20px);
|
||||
position: relative;
|
||||
top: -1px;
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
line-height: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@
|
||||
z-index: 10;
|
||||
margin: 20px 40px;
|
||||
|
||||
|
||||
.title {
|
||||
margin: 0 0 15px 0;
|
||||
color: $mediumGrey;
|
||||
|
||||
.value {
|
||||
}
|
||||
}
|
||||
|
||||
&.new-component-item {
|
||||
padding: 20px;
|
||||
border: none;
|
||||
@@ -116,7 +125,7 @@
|
||||
a {
|
||||
position: relative;
|
||||
border: 1px solid $darkGreen;
|
||||
background: $green;
|
||||
background: tint($green,20%);
|
||||
color: #fff;
|
||||
@include transition(background-color .15s);
|
||||
|
||||
@@ -129,23 +138,71 @@
|
||||
.new-component-template {
|
||||
margin-bottom: 20px;
|
||||
|
||||
li:first-child {
|
||||
li:last-child {
|
||||
a {
|
||||
border-radius: 0 0 3px 3px;
|
||||
border-bottom: 1px solid $darkGreen;
|
||||
}
|
||||
}
|
||||
|
||||
li:nth-child(2) {
|
||||
a {
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
a {
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@include clearfix();
|
||||
display: block;
|
||||
padding: 7px 20px;
|
||||
border-bottom: none;
|
||||
font-weight: 300;
|
||||
|
||||
.name {
|
||||
float: left;
|
||||
|
||||
.ss-icon {
|
||||
@include transition(opacity .15s);
|
||||
position: relative;
|
||||
top: 1px;
|
||||
font-size: 13px;
|
||||
margin-right: 5px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-indicator {
|
||||
@include transition(opacity .15s);
|
||||
float: right;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
font-size: 12px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
.ss-icon {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.editor-indicator {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// specific editor types
|
||||
.empty {
|
||||
@include box-shadow(0 1px 3px rgba(0,0,0,0.2));
|
||||
margin-bottom: 10px;
|
||||
|
||||
a {
|
||||
border-bottom: 1px solid $darkGreen;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
background: $green;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,8 +245,8 @@
|
||||
|
||||
&.editing {
|
||||
border: 1px solid $lightBluishGrey2;
|
||||
z-index: 9999;
|
||||
|
||||
z-index: auto;
|
||||
|
||||
.drag-handle,
|
||||
.component-actions {
|
||||
display: none;
|
||||
@@ -211,7 +268,7 @@
|
||||
display: block;
|
||||
top: -1px;
|
||||
right: -16px;
|
||||
z-index: -1;
|
||||
z-index: 10;
|
||||
width: 15px;
|
||||
height: 100%;
|
||||
border-radius: 0 3px 3px 0;
|
||||
@@ -224,14 +281,20 @@
|
||||
|
||||
.xmodule_display {
|
||||
padding: 40px 20px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wrapper-component-editor {
|
||||
z-index: 9999;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.component-editor {
|
||||
@include edit-box;
|
||||
@include box-shadow(none);
|
||||
display: none;
|
||||
padding: 20px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
@include box-shadow(none);
|
||||
|
||||
.metadata_edit {
|
||||
margin-bottom: 20px;
|
||||
@@ -473,7 +536,7 @@ body.unit {
|
||||
.component-actions {
|
||||
@include box-sizing(border-box);
|
||||
position: absolute;
|
||||
width: 811px;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<div class="component-editor">
|
||||
<div class="module-editor">
|
||||
${editor}
|
||||
</div>
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
<div class="wrapper wrapper-component-editor">
|
||||
<div class="component-editor">
|
||||
<div class="module-editor">
|
||||
${editor}
|
||||
</div>
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-actions">
|
||||
<a href="#" class="edit-button standard"><span class="edit-icon"></span>Edit</a>
|
||||
<a href="#" class="delete-button standard"><span class="delete-icon"></span>Delete</a>
|
||||
</div>
|
||||
<a data-tooltip="Drag to reorder" href="#" class="drag-handle"></a>
|
||||
${preview}
|
||||
${preview}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
<div class="inner-wrapper">
|
||||
<div class="page-actions">
|
||||
<a href="#" class="new-button new-courseware-section-button"><span class="plus-icon white"></span> New Section</a>
|
||||
<a href="#" class="collapse-all-button"><span class="collapse-all-icon"></span>Collapse All</a>
|
||||
<a href="#" class="toggle-button toggle-button-sections"><i class="ss-icon ss-symbolicons-block">up</i> <span class="label">Collapse All Sections</span></a>
|
||||
</div>
|
||||
<article class="courseware-overview" data-course-id="${context_course.location.url()}">
|
||||
% for section in sections:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<%block name="title">CMS Unit</%block>
|
||||
<%block name="jsextra">
|
||||
<script type='text/javascript'>
|
||||
$(document).ready(function() {
|
||||
new CMS.Views.UnitEdit({
|
||||
el: $('.main-wrapper'),
|
||||
model: new CMS.Models.Module({
|
||||
@@ -12,7 +13,14 @@
|
||||
state: '${unit_state}'
|
||||
})
|
||||
});
|
||||
|
||||
$('.new-component-template').each(function(){
|
||||
$emptyEditor = $(this).find('.empty');
|
||||
$(this).prepend($emptyEditor);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</%block>
|
||||
<%block name="content">
|
||||
<div class="main-wrapper edit-state-${unit_state}" data-id="${unit_location}">
|
||||
@@ -46,20 +54,43 @@
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
% for type, templates in sorted(component_templates.items()):
|
||||
<div class="new-component-templates new-component-${type}">
|
||||
<ul class="new-component-template">
|
||||
% for name, location in templates:
|
||||
<li>
|
||||
<a href="#" data-location="${location}">
|
||||
<span class="name">${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
% endfor
|
||||
% for type, templates in sorted(component_templates.items()):
|
||||
<div class="new-component-templates new-component-${type}">
|
||||
<h3 class="title">Select <span class="type">${type}</span> component type:</h3>
|
||||
|
||||
<ul class="new-component-template">
|
||||
% for name, location, has_markdown, is_empty in templates:
|
||||
|
||||
% if is_empty:
|
||||
<li class="editor-md empty">
|
||||
<a href="#" data-location="${location}">
|
||||
<span class="name"><i class="ss-icon ss-symbolicons-block"></i> ${name}</span>
|
||||
<span class="editor-indicator">Simple <span class="sr">Editor</span></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
% elif has_markdown:
|
||||
<li class="editor-md">
|
||||
<a href="#" data-location="${location}">
|
||||
<span class="name"><i class="ss-icon ss-symbolicons-block"></i> ${name}</span>
|
||||
<span class="editor-indicator">Simple <span class="sr">Editor</span></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
% else:
|
||||
<li class="editor-manual">
|
||||
<a href="#" data-location="${location}">
|
||||
<span class="name"><i class="ss-icon ss-symbolicons-block">🔧</i> ${name}</span>
|
||||
<span class="editor-indicator">Advanced <span class="sr">Editor</span></span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
|
||||
%endfor
|
||||
</ul>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
% endfor
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
83
cms/templates/widgets/problem-edit.html
Normal file
83
cms/templates/widgets/problem-edit.html
Normal file
@@ -0,0 +1,83 @@
|
||||
<%include file="metadata-edit.html" />
|
||||
<section class="problem-editor editor">
|
||||
<div class="row">
|
||||
%if markdown != '' or data == '<problem>\n</problem>\n':
|
||||
<div class="editor-bar">
|
||||
<ul class="format-buttons">
|
||||
<li><a href="#" class="multiple-choice-button" data-tooltip="Multiple Choice"><span
|
||||
class="problem-editor-icon multiple-choice"></span></a></li>
|
||||
<li><a href="#" class="checks-button" data-tooltip="Check Multiple"><span
|
||||
class="problem-editor-icon checks"></span></a></li>
|
||||
<li><a href="#" class="string-button" data-tooltip="String Response"><span
|
||||
class="problem-editor-icon string"></span></a></li>
|
||||
<li><a href="#" class="number-button" data-tooltip="Numerical Response"><span
|
||||
class="problem-editor-icon number"></span></a></li>
|
||||
<li><a href="#" class="dropdown-button" data-tooltip="Option Response"><span
|
||||
class="problem-editor-icon dropdown"></span></a></li>
|
||||
</ul>
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="xml-tab tab" data-tab="xml">Use Advanced Editor</a></li>
|
||||
<li><a href="#" class="cheatsheet-toggle" data-tooltip="Toggle Cheatsheet">?</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<textarea class="markdown-box">${markdown}</textarea>
|
||||
%endif
|
||||
<textarea class="xml-box" rows="8" cols="40">${data | h}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="text/template" id="simple-editor-cheatsheet">
|
||||
<article class="simple-editor-cheatsheet">
|
||||
<div class="cheatsheet-wrapper">
|
||||
<div class="row">
|
||||
<h6>Multiple Choice</h6>
|
||||
<div class="col sample">
|
||||
<img src="/static/img/choice-example.png" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>( ) red
|
||||
( ) green
|
||||
(x) blue</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h6>Check Multiple</h6>
|
||||
<div class="col sample">
|
||||
<img src="/static/img/multi-example.png" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>[x] earth
|
||||
[ ] wind
|
||||
[x] water</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h6>String Response</h6>
|
||||
<div class="col sample">
|
||||
<img src="/static/img/string-example.png" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>= dog</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h6>Numerical Response</h6>
|
||||
<div class="col sample">
|
||||
<img src="/static/img/number-example.png" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>= 3.14 +- 2%</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h6>Option Response</h6>
|
||||
<div class="col sample">
|
||||
<img src="/static/img/select-example.png" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>[[wrong, (right)]]</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</script>
|
||||
@@ -12,10 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("mitx." + __name__)
|
||||
|
||||
from django.template import Context
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
@@ -54,5 +54,4 @@ class Template(MakoTemplate):
|
||||
context_dictionary['MITX_ROOT_URL'] = settings.MITX_ROOT_URL
|
||||
context_dictionary['django_context'] = context_instance
|
||||
|
||||
return super(Template, self).render(**context_dictionary)
|
||||
|
||||
return super(Template, self).render_unicode(**context_dictionary)
|
||||
|
||||
@@ -36,10 +36,12 @@ file and check it in at the same time as your model changes. To do that,
|
||||
3. Add the migration file created in mitx/common/djangoapps/student/migrations/
|
||||
"""
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
@@ -47,7 +49,6 @@ from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
import comment_client as cc
|
||||
from django_comment_client.models import Role
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -125,9 +126,9 @@ class UserProfile(models.Model):
|
||||
self.meta = json.dumps(js)
|
||||
|
||||
class TestCenterUser(models.Model):
|
||||
"""This is our representation of the User for in-person testing, and
|
||||
"""This is our representation of the User for in-person testing, and
|
||||
specifically for Pearson at this point. A few things to note:
|
||||
|
||||
|
||||
* Pearson only supports Latin-1, so we have to make sure that the data we
|
||||
capture here will work with that encoding.
|
||||
* While we have a lot of this demographic data in UserProfile, it's much
|
||||
@@ -135,9 +136,9 @@ class TestCenterUser(models.Model):
|
||||
UserProfile, but we'll need to have a step where people who are signing
|
||||
up re-enter their demographic data into the fields we specify.
|
||||
* Users are only created here if they register to take an exam in person.
|
||||
|
||||
|
||||
The field names and lengths are modeled on the conventions and constraints
|
||||
of Pearson's data import system, including oddities such as suffix having
|
||||
of Pearson's data import system, including oddities such as suffix having
|
||||
a limit of 255 while last_name only gets 50.
|
||||
"""
|
||||
# Our own record keeping...
|
||||
@@ -148,21 +149,21 @@ class TestCenterUser(models.Model):
|
||||
# and is something Pearson needs to know to manage updates. Unlike
|
||||
# updated_at, this will not get incremented when we do a batch data import.
|
||||
user_updated_at = models.DateTimeField(db_index=True)
|
||||
|
||||
|
||||
# Unique ID given to us for this User by the Testing Center. It's null when
|
||||
# we first create the User entry, and is assigned by Pearson later.
|
||||
candidate_id = models.IntegerField(null=True, db_index=True)
|
||||
|
||||
|
||||
# Unique ID we assign our user for a the Test Center.
|
||||
client_candidate_id = models.CharField(max_length=50, db_index=True)
|
||||
|
||||
|
||||
# Name
|
||||
first_name = models.CharField(max_length=30, db_index=True)
|
||||
last_name = models.CharField(max_length=50, db_index=True)
|
||||
middle_name = models.CharField(max_length=30, blank=True)
|
||||
suffix = models.CharField(max_length=255, blank=True)
|
||||
salutation = models.CharField(max_length=50, blank=True)
|
||||
|
||||
|
||||
# Address
|
||||
address_1 = models.CharField(max_length=40)
|
||||
address_2 = models.CharField(max_length=40, blank=True)
|
||||
@@ -175,7 +176,7 @@ class TestCenterUser(models.Model):
|
||||
postal_code = models.CharField(max_length=16, blank=True, db_index=True)
|
||||
# country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG")
|
||||
country = models.CharField(max_length=3, db_index=True)
|
||||
|
||||
|
||||
# Phone
|
||||
phone = models.CharField(max_length=35)
|
||||
extension = models.CharField(max_length=8, blank=True, db_index=True)
|
||||
@@ -183,14 +184,27 @@ class TestCenterUser(models.Model):
|
||||
fax = models.CharField(max_length=35, blank=True)
|
||||
# fax_country_code required *if* fax is present.
|
||||
fax_country_code = models.CharField(max_length=3, blank=True)
|
||||
|
||||
|
||||
# Company
|
||||
company_name = models.CharField(max_length=50, blank=True)
|
||||
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
return self.user.email
|
||||
|
||||
def unique_id_for_user(user):
|
||||
"""
|
||||
Return a unique id for a user, suitable for inserting into
|
||||
e.g. personalized survey links.
|
||||
"""
|
||||
# include the secret key as a salt, and to make the ids unique accross
|
||||
# different LMS installs.
|
||||
h = hashlib.md5()
|
||||
h.update(settings.SECRET_KEY)
|
||||
h.update(str(user.id))
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
## TODO: Should be renamed to generic UserGroup, and possibly
|
||||
# Given an optional field for type of group
|
||||
class UserTestGroup(models.Model):
|
||||
@@ -247,15 +261,6 @@ class CourseEnrollment(models.Model):
|
||||
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
|
||||
|
||||
|
||||
@receiver(post_save, sender=CourseEnrollment)
|
||||
def assign_default_role(sender, instance, **kwargs):
|
||||
if instance.user.is_staff:
|
||||
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
|
||||
else:
|
||||
role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0]
|
||||
|
||||
logging.info("assign_default_role: adding %s as %s" % (instance.user, role))
|
||||
instance.user.roles.add(role)
|
||||
|
||||
#cache_relation(User.profile)
|
||||
|
||||
@@ -363,10 +368,10 @@ def replicate_user_save(sender, **kwargs):
|
||||
|
||||
# @receiver(post_save, sender=CourseEnrollment)
|
||||
def replicate_enrollment_save(sender, **kwargs):
|
||||
"""This is called when a Student enrolls in a course. It has to do the
|
||||
"""This is called when a Student enrolls in a course. It has to do the
|
||||
following:
|
||||
|
||||
1. Make sure the User is copied into the Course DB. It may already exist
|
||||
1. Make sure the User is copied into the Course DB. It may already exist
|
||||
(someone deleting and re-adding a course). This has to happen first or
|
||||
the foreign key constraint breaks.
|
||||
2. Replicate the CourseEnrollment.
|
||||
@@ -410,9 +415,9 @@ USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email",
|
||||
|
||||
def replicate_user(portal_user, course_db_name):
|
||||
"""Replicate a User to the correct Course DB. This is more complicated than
|
||||
it should be because Askbot extends the auth_user table and adds its own
|
||||
it should be because Askbot extends the auth_user table and adds its own
|
||||
fields. So we need to only push changes to the standard fields and leave
|
||||
the rest alone so that Askbot changes at the Course DB level don't get
|
||||
the rest alone so that Askbot changes at the Course DB level don't get
|
||||
overridden.
|
||||
"""
|
||||
try:
|
||||
@@ -457,7 +462,7 @@ def is_valid_course_id(course_id):
|
||||
"""Right now, the only database that's not a course database is 'default'.
|
||||
I had nicer checking in here originally -- it would scan the courses that
|
||||
were in the system and only let you choose that. But it was annoying to run
|
||||
tests with, since we don't have course data for some for our course test
|
||||
tests with, since we don't have course data for some for our course test
|
||||
databases. Hence the lazy version.
|
||||
"""
|
||||
return course_id != 'default'
|
||||
|
||||
@@ -6,11 +6,16 @@ Replace this with more appropriate tests for your application.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from hashlib import sha1
|
||||
|
||||
from django.test import TestCase
|
||||
from mock import patch, Mock
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY
|
||||
from .models import (User, UserProfile, CourseEnrollment,
|
||||
replicate_user, USER_FIELDS_TO_COPY,
|
||||
unique_id_for_user)
|
||||
from .views import process_survey_link, _cert_info
|
||||
|
||||
COURSE_1 = 'edX/toy/2012_Fall'
|
||||
COURSE_2 = 'edx/full/6.002_Spring_2012'
|
||||
@@ -55,7 +60,7 @@ class ReplicationTest(TestCase):
|
||||
# This hasattr lameness is here because we don't want this test to be
|
||||
# triggered when we're being run by CMS tests (Askbot doesn't exist
|
||||
# there, so the test will fail).
|
||||
#
|
||||
#
|
||||
# seen_response_count isn't a field we care about, so it shouldn't have
|
||||
# been copied over.
|
||||
if hasattr(portal_user, 'seen_response_count'):
|
||||
@@ -74,7 +79,7 @@ class ReplicationTest(TestCase):
|
||||
|
||||
# During this entire time, the user data should never have made it over
|
||||
# to COURSE_2
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
User.objects.using(COURSE_2).get,
|
||||
id=portal_user.id)
|
||||
|
||||
@@ -108,19 +113,19 @@ class ReplicationTest(TestCase):
|
||||
# Grab all the copies we expect
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEquals(portal_user, course_user)
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
User.objects.using(COURSE_2).get,
|
||||
id=portal_user.id)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
|
||||
self.assertEquals(portal_enrollment, course_enrollment)
|
||||
self.assertRaises(CourseEnrollment.DoesNotExist,
|
||||
self.assertRaises(CourseEnrollment.DoesNotExist,
|
||||
CourseEnrollment.objects.using(COURSE_2).get,
|
||||
id=portal_enrollment.id)
|
||||
|
||||
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
|
||||
self.assertEquals(portal_user_profile, course_user_profile)
|
||||
self.assertRaises(UserProfile.DoesNotExist,
|
||||
self.assertRaises(UserProfile.DoesNotExist,
|
||||
UserProfile.objects.using(COURSE_2).get,
|
||||
id=portal_user_profile.id)
|
||||
|
||||
@@ -174,30 +179,112 @@ class ReplicationTest(TestCase):
|
||||
portal_user.save()
|
||||
portal_user_profile.gender = 'm'
|
||||
portal_user_profile.save()
|
||||
|
||||
# Grab all the copies we expect, and make sure it doesn't end up in
|
||||
|
||||
# Grab all the copies we expect, and make sure it doesn't end up in
|
||||
# places we don't expect.
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEquals(portal_user, course_user)
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
User.objects.using(COURSE_2).get,
|
||||
id=portal_user.id)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
|
||||
self.assertEquals(portal_enrollment, course_enrollment)
|
||||
self.assertRaises(CourseEnrollment.DoesNotExist,
|
||||
self.assertRaises(CourseEnrollment.DoesNotExist,
|
||||
CourseEnrollment.objects.using(COURSE_2).get,
|
||||
id=portal_enrollment.id)
|
||||
|
||||
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
|
||||
self.assertEquals(portal_user_profile, course_user_profile)
|
||||
self.assertRaises(UserProfile.DoesNotExist,
|
||||
self.assertRaises(UserProfile.DoesNotExist,
|
||||
UserProfile.objects.using(COURSE_2).get,
|
||||
id=portal_user_profile.id)
|
||||
|
||||
|
||||
class CourseEndingTest(TestCase):
|
||||
"""Test things related to course endings: certificates, surveys, etc"""
|
||||
|
||||
def test_process_survey_link(self):
|
||||
username = "fred"
|
||||
user = Mock(username=username)
|
||||
id = unique_id_for_user(user)
|
||||
link1 = "http://www.mysurvey.com"
|
||||
self.assertEqual(process_survey_link(link1, user), link1)
|
||||
|
||||
link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}"
|
||||
link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=id)
|
||||
self.assertEqual(process_survey_link(link2, user), link2_expected)
|
||||
|
||||
def test_cert_info(self):
|
||||
user = Mock(username="fred")
|
||||
survey_url = "http://a_survey.com"
|
||||
course = Mock(end_of_course_survey_url=survey_url)
|
||||
|
||||
self.assertEqual(_cert_info(user, course, None),
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False,})
|
||||
|
||||
cert_status = {'status': 'unavailable'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'processing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False})
|
||||
|
||||
cert_status = {'status': 'generating', 'grade': '67'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
})
|
||||
|
||||
cert_status = {'status': 'regenerating', 'grade': '67'}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'generating',
|
||||
'show_disabled_download_button': True,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
})
|
||||
|
||||
download_url = 'http://s3.edx/cert'
|
||||
cert_status = {'status': 'downloadable', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'ready',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': True,
|
||||
'download_url': download_url,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
})
|
||||
|
||||
cert_status = {'status': 'notpassing', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
self.assertEqual(_cert_info(user, course, cert_status),
|
||||
{'status': 'notpassing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': True,
|
||||
'survey_url': survey_url,
|
||||
'grade': '67'
|
||||
})
|
||||
|
||||
# Test a course that doesn't have a survey specified
|
||||
course2 = Mock(end_of_course_survey_url=None)
|
||||
cert_status = {'status': 'notpassing', 'grade': '67',
|
||||
'download_url': download_url}
|
||||
self.assertEqual(_cert_info(user, course2, cert_status),
|
||||
{'status': 'notpassing',
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False,
|
||||
'grade': '67'
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ from django.core.cache import cache
|
||||
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
from student.models import (Registration, UserProfile,
|
||||
PendingNameChange, PendingEmailChange,
|
||||
CourseEnrollment)
|
||||
CourseEnrollment, unique_id_for_user)
|
||||
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
|
||||
@@ -39,7 +39,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from datetime import date
|
||||
from collections import namedtuple
|
||||
from courseware.courses import get_courses_by_university
|
||||
|
||||
from courseware.courses import get_courses
|
||||
from courseware.access import has_access
|
||||
|
||||
from statsd import statsd
|
||||
@@ -68,31 +69,26 @@ def index(request, extra_context={}, user=None):
|
||||
extra_context is used to allow immediate display of certain modal windows, eg signup,
|
||||
as used by external_auth.
|
||||
'''
|
||||
feed_data = cache.get("students_index_rss_feed_data")
|
||||
if feed_data == None:
|
||||
if hasattr(settings, 'RSS_URL'):
|
||||
feed_data = urllib.urlopen(settings.RSS_URL).read()
|
||||
else:
|
||||
feed_data = render_to_string("feed.rss", None)
|
||||
cache.set("students_index_rss_feed_data", feed_data, settings.RSS_TIMEOUT)
|
||||
|
||||
feed = feedparser.parse(feed_data)
|
||||
entries = feed['entries'][0:3]
|
||||
for entry in entries:
|
||||
soup = BeautifulSoup(entry.description)
|
||||
entry.image = soup.img['src'] if soup.img else None
|
||||
entry.summary = soup.getText()
|
||||
|
||||
# The course selection work is done in courseware.courses.
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
if domain==False: # do explicit check, because domain=None is valid
|
||||
domain = request.META.get('HTTP_HOST')
|
||||
universities = get_courses_by_university(None,
|
||||
domain=domain)
|
||||
context = {'universities': universities, 'entries': entries}
|
||||
|
||||
courses = get_courses(None, domain=domain)
|
||||
|
||||
# Sort courses by how far are they from they start day
|
||||
key = lambda course: course.metadata['days_to_start']
|
||||
courses = sorted(courses, key=key, reverse=True)
|
||||
|
||||
# Get the 3 most recent news
|
||||
top_news = _get_news(top=3)
|
||||
|
||||
context = {'courses': courses, 'news': top_news}
|
||||
context.update(extra_context)
|
||||
return render_to_response('index.html', context)
|
||||
|
||||
|
||||
def course_from_id(course_id):
|
||||
"""Return the CourseDescriptor corresponding to this course_id"""
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
@@ -107,9 +103,9 @@ def get_date_for_press(publish_date):
|
||||
# strip off extra months, and just use the first:
|
||||
date = re.sub(multimonth_pattern, ", ", publish_date)
|
||||
if re.search(day_pattern, date):
|
||||
date = datetime.datetime.strptime(date, "%B %d, %Y")
|
||||
else:
|
||||
date = datetime.datetime.strptime(date, "%B, %Y")
|
||||
date = datetime.datetime.strptime(date, "%B %d, %Y")
|
||||
else:
|
||||
date = datetime.datetime.strptime(date, "%B, %Y")
|
||||
return date
|
||||
|
||||
def press(request):
|
||||
@@ -127,6 +123,87 @@ def press(request):
|
||||
return render_to_response('static_templates/press.html', {'articles': articles})
|
||||
|
||||
|
||||
def process_survey_link(survey_link, user):
|
||||
"""
|
||||
If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
|
||||
Currently, this is sha1(user.username). Otherwise, return survey_link.
|
||||
"""
|
||||
return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
|
||||
|
||||
|
||||
def cert_info(user, course):
|
||||
"""
|
||||
Get the certificate info needed to render the dashboard section for the given
|
||||
student and course. Returns a dictionary with keys:
|
||||
|
||||
'status': one of 'generating', 'ready', 'notpassing', 'processing'
|
||||
'show_download_url': bool
|
||||
'download_url': url, only present if show_download_url is True
|
||||
'show_disabled_download_button': bool -- true if state is 'generating'
|
||||
'show_survey_button': bool
|
||||
'survey_url': url, only if show_survey_button is True
|
||||
'grade': if status is not 'processing'
|
||||
"""
|
||||
if not course.has_ended():
|
||||
return {}
|
||||
|
||||
return _cert_info(user, course, certificate_status_for_student(user, course.id))
|
||||
|
||||
def _cert_info(user, course, cert_status):
|
||||
"""
|
||||
Implements the logic for cert_info -- split out for testing.
|
||||
"""
|
||||
default_status = 'processing'
|
||||
|
||||
default_info = {'status': default_status,
|
||||
'show_disabled_download_button': False,
|
||||
'show_download_url': False,
|
||||
'show_survey_button': False}
|
||||
|
||||
if cert_status is None:
|
||||
return default_info
|
||||
|
||||
# simplify the status for the template using this lookup table
|
||||
template_state = {
|
||||
CertificateStatuses.generating: 'generating',
|
||||
CertificateStatuses.regenerating: 'generating',
|
||||
CertificateStatuses.downloadable: 'ready',
|
||||
CertificateStatuses.notpassing: 'notpassing',
|
||||
}
|
||||
|
||||
status = template_state.get(cert_status['status'], default_status)
|
||||
|
||||
d = {'status': status,
|
||||
'show_download_url': status == 'ready',
|
||||
'show_disabled_download_button': status == 'generating',}
|
||||
|
||||
if (status in ('generating', 'ready', 'notpassing') and
|
||||
course.end_of_course_survey_url is not None):
|
||||
d.update({
|
||||
'show_survey_button': True,
|
||||
'survey_url': process_survey_link(course.end_of_course_survey_url, user)})
|
||||
else:
|
||||
d['show_survey_button'] = False
|
||||
|
||||
if status == 'ready':
|
||||
if 'download_url' not in cert_status:
|
||||
log.warning("User %s has a downloadable cert for %s, but no download url",
|
||||
user.username, course.id)
|
||||
return default_info
|
||||
else:
|
||||
d['download_url'] = cert_status['download_url']
|
||||
|
||||
if status in ('generating', 'ready', 'notpassing'):
|
||||
if 'grade' not in cert_status:
|
||||
# Note: as of 11/20/2012, we know there are students in this state-- cs169.1x,
|
||||
# who need to be regraded (we weren't tracking 'notpassing' at first).
|
||||
# We can add a log.warning here once we think it shouldn't happen.
|
||||
return default_info
|
||||
else:
|
||||
d['grade'] = cert_status['grade']
|
||||
|
||||
return d
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def dashboard(request):
|
||||
@@ -160,12 +237,10 @@ def dashboard(request):
|
||||
show_courseware_links_for = frozenset(course.id for course in courses
|
||||
if has_access(request.user, course, 'load'))
|
||||
|
||||
# TODO: workaround to not have to zip courses and certificates in the template
|
||||
# since before there is a migration to certificates
|
||||
if settings.MITX_FEATURES.get('CERTIFICATES_ENABLED'):
|
||||
cert_statuses = { course.id: certificate_status_for_student(request.user, course.id) for course in courses}
|
||||
else:
|
||||
cert_statuses = {}
|
||||
cert_statuses = { course.id: cert_info(request.user, course) for course in courses}
|
||||
|
||||
# Get the 3 most recent news
|
||||
top_news = _get_news(top=3)
|
||||
|
||||
context = {'courses': courses,
|
||||
'message': message,
|
||||
@@ -173,6 +248,7 @@ def dashboard(request):
|
||||
'errored_courses': errored_courses,
|
||||
'show_courseware_links_for' : show_courseware_links_for,
|
||||
'cert_statuses': cert_statuses,
|
||||
'news': top_news,
|
||||
}
|
||||
|
||||
return render_to_response('dashboard.html', context)
|
||||
@@ -262,6 +338,14 @@ def change_enrollment(request):
|
||||
return {'success': False, 'error': 'We weren\'t able to unenroll you. Please try again.'}
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def accounts_login(request, error=""):
|
||||
|
||||
|
||||
return render_to_response('accounts_login.html', { 'error': error })
|
||||
|
||||
|
||||
|
||||
# Need different levels of logging
|
||||
@ensure_csrf_cookie
|
||||
def login_user(request, error=""):
|
||||
@@ -820,3 +904,24 @@ def test_center_login(request):
|
||||
return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/')
|
||||
else:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
|
||||
def _get_news(top=None):
|
||||
"Return the n top news items on settings.RSS_URL"
|
||||
|
||||
feed_data = cache.get("students_index_rss_feed_data")
|
||||
if feed_data == None:
|
||||
if hasattr(settings, 'RSS_URL'):
|
||||
feed_data = urllib.urlopen(settings.RSS_URL).read()
|
||||
else:
|
||||
feed_data = render_to_string("feed.rss", None)
|
||||
cache.set("students_index_rss_feed_data", feed_data, settings.RSS_TIMEOUT)
|
||||
|
||||
feed = feedparser.parse(feed_data)
|
||||
entries = feed['entries'][0:top] # all entries if top is None
|
||||
for entry in entries:
|
||||
soup = BeautifulSoup(entry.description)
|
||||
entry.image = soup.img['src'] if soup.img else None
|
||||
entry.summary = soup.getText()
|
||||
|
||||
return entries
|
||||
|
||||
48
common/djangoapps/track/migrations/0001_initial.py
Normal file
48
common/djangoapps/track/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'TrackingLog'
|
||||
db.create_table('track_trackinglog', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('dtcreated', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('username', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)),
|
||||
('ip', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)),
|
||||
('event_source', self.gf('django.db.models.fields.CharField')(max_length=32)),
|
||||
('event_type', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)),
|
||||
('event', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
('agent', self.gf('django.db.models.fields.CharField')(max_length=256, blank=True)),
|
||||
('page', self.gf('django.db.models.fields.CharField')(max_length=32, null=True, blank=True)),
|
||||
('time', self.gf('django.db.models.fields.DateTimeField')()),
|
||||
))
|
||||
db.send_create_signal('track', ['TrackingLog'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'TrackingLog'
|
||||
db.delete_table('track_trackinglog')
|
||||
|
||||
|
||||
models = {
|
||||
'track.trackinglog': {
|
||||
'Meta': {'object_name': 'TrackingLog'},
|
||||
'agent': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
|
||||
'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'event': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'event_source': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'event_type': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'ip': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
|
||||
'page': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}),
|
||||
'time': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['track']
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding field 'TrackingLog.host'
|
||||
db.add_column('track_trackinglog', 'host',
|
||||
self.gf('django.db.models.fields.CharField')(default='', max_length=64, blank=True),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
# Changing field 'TrackingLog.event_type'
|
||||
db.alter_column('track_trackinglog', 'event_type', self.gf('django.db.models.fields.CharField')(max_length=512))
|
||||
|
||||
# Changing field 'TrackingLog.page'
|
||||
db.alter_column('track_trackinglog', 'page', self.gf('django.db.models.fields.CharField')(max_length=512, null=True))
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'TrackingLog.host'
|
||||
db.delete_column('track_trackinglog', 'host')
|
||||
|
||||
|
||||
# Changing field 'TrackingLog.event_type'
|
||||
db.alter_column('track_trackinglog', 'event_type', self.gf('django.db.models.fields.CharField')(max_length=32))
|
||||
|
||||
# Changing field 'TrackingLog.page'
|
||||
db.alter_column('track_trackinglog', 'page', self.gf('django.db.models.fields.CharField')(max_length=32, null=True))
|
||||
|
||||
models = {
|
||||
'track.trackinglog': {
|
||||
'Meta': {'object_name': 'TrackingLog'},
|
||||
'agent': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
|
||||
'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'event': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'event_source': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'event_type': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'host': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'ip': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
|
||||
'page': ('django.db.models.fields.CharField', [], {'max_length': '512', 'null': 'True', 'blank': 'True'}),
|
||||
'time': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['track']
|
||||
0
common/djangoapps/track/migrations/__init__.py
Normal file
0
common/djangoapps/track/migrations/__init__.py
Normal file
@@ -7,11 +7,12 @@ class TrackingLog(models.Model):
|
||||
username = models.CharField(max_length=32,blank=True)
|
||||
ip = models.CharField(max_length=32,blank=True)
|
||||
event_source = models.CharField(max_length=32)
|
||||
event_type = models.CharField(max_length=32,blank=True)
|
||||
event_type = models.CharField(max_length=512,blank=True)
|
||||
event = models.TextField(blank=True)
|
||||
agent = models.CharField(max_length=256,blank=True)
|
||||
page = models.CharField(max_length=32,blank=True,null=True)
|
||||
page = models.CharField(max_length=512,blank=True,null=True)
|
||||
time = models.DateTimeField('event time')
|
||||
host = models.CharField(max_length=64,blank=True)
|
||||
|
||||
def __unicode__(self):
|
||||
s = "[%s] %s@%s: %s | %s | %s | %s" % (self.time, self.username, self.ip, self.event_source,
|
||||
|
||||
@@ -17,7 +17,7 @@ from track.models import TrackingLog
|
||||
|
||||
log = logging.getLogger("tracking")
|
||||
|
||||
LOGFIELDS = ['username','ip','event_source','event_type','event','agent','page','time']
|
||||
LOGFIELDS = ['username','ip','event_source','event_type','event','agent','page','time','host']
|
||||
|
||||
def log_event(event):
|
||||
event_str = json.dumps(event)
|
||||
@@ -58,6 +58,7 @@ def user_track(request):
|
||||
"agent": agent,
|
||||
"page": request.GET['page'],
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
log_event(event)
|
||||
return HttpResponse('success')
|
||||
@@ -83,6 +84,7 @@ def server_track(request, event_type, event, page=None):
|
||||
"agent": agent,
|
||||
"page": page,
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
|
||||
@@ -4,6 +4,11 @@ import json
|
||||
|
||||
|
||||
def expect_json(view_function):
|
||||
"""
|
||||
View decorator for simplifying handing of requests that expect json. If the request's
|
||||
CONTENT_TYPE is application/json, parses the json dict from request.body, and updates
|
||||
request.POST with the contents.
|
||||
"""
|
||||
@wraps(view_function)
|
||||
def expect_json_with_cloned_request(request, *args, **kwargs):
|
||||
# cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information
|
||||
|
||||
@@ -7,6 +7,7 @@ source = common/lib/capa
|
||||
ignore_errors = True
|
||||
|
||||
[html]
|
||||
title = Capa Python Test Coverage Report
|
||||
directory = reports/common/lib/capa/cover
|
||||
|
||||
[xml]
|
||||
|
||||
@@ -33,6 +33,7 @@ from xml.sax.saxutils import unescape
|
||||
import chem
|
||||
import chem.chemcalc
|
||||
import chem.chemtools
|
||||
import chem.miller
|
||||
|
||||
import calc
|
||||
from correctmap import CorrectMap
|
||||
@@ -52,7 +53,7 @@ response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
|
||||
solution_tags = ['solution']
|
||||
|
||||
# these get captured as student responses
|
||||
response_properties = ["codeparam", "responseparam", "answer"]
|
||||
response_properties = ["codeparam", "responseparam", "answer", "openendedparam"]
|
||||
|
||||
# special problem tags which should be turned into innocuous HTML
|
||||
html_transforms = {'problem': {'tag': 'div'},
|
||||
@@ -67,10 +68,11 @@ global_context = {'random': random,
|
||||
'calc': calc,
|
||||
'eia': eia,
|
||||
'chemcalc': chem.chemcalc,
|
||||
'chemtools': chem.chemtools}
|
||||
'chemtools': chem.chemtools,
|
||||
'miller': chem.miller}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"]
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"]
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
@@ -184,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.
|
||||
|
||||
267
common/lib/capa/capa/chem/miller.py
Normal file
267
common/lib/capa/capa/chem/miller.py
Normal file
@@ -0,0 +1,267 @@
|
||||
""" Calculation of Miller indices """
|
||||
|
||||
import numpy as np
|
||||
import math
|
||||
import fractions as fr
|
||||
import decimal
|
||||
import json
|
||||
|
||||
|
||||
def lcm(a, b):
|
||||
"""
|
||||
Returns least common multiple of a, b
|
||||
|
||||
Args:
|
||||
a, b: floats
|
||||
|
||||
Returns:
|
||||
float
|
||||
"""
|
||||
return a * b / fr.gcd(a, b)
|
||||
|
||||
|
||||
def segment_to_fraction(distance):
|
||||
"""
|
||||
Converts lengths of which the plane cuts the axes to fraction.
|
||||
|
||||
Tries convert distance to closest nicest fraction with denominator less or
|
||||
equal than 10. It is
|
||||
purely for simplicity and clearance of learning purposes. Jenny: 'In typical
|
||||
courses students usually do not encounter indices any higher than 6'.
|
||||
|
||||
If distance is not a number (numpy nan), it means that plane is parallel to
|
||||
axis or contains it. Inverted fraction to nan (nan is 1/0) = 0 / 1 is
|
||||
returned
|
||||
|
||||
Generally (special cases):
|
||||
|
||||
a) if distance is smaller than some constant, i.g. 0.01011,
|
||||
than fraction's denominator usually much greater than 10.
|
||||
|
||||
b) Also, if student will set point on 0.66 -> 1/3, so it is 333 plane,
|
||||
But if he will slightly move the mouse and click on 0.65 -> it will be
|
||||
(16,15,16) plane. That's why we are doing adjustments for points coordinates,
|
||||
to the closest tick, tick + tick / 2 value. And now UI sends to server only
|
||||
values multiple to 0.05 (half of tick). Same rounding is implemented for
|
||||
unittests.
|
||||
|
||||
But if one will want to calculate miller indices with exact coordinates and
|
||||
with nice fractions (which produce small Miller indices), he may want shift
|
||||
to new origin if segments are like S = (0.015, > 0.05, >0.05) - close to zero
|
||||
in one coordinate. He may update S to (0, >0.05, >0.05) and shift origin.
|
||||
In this way he can recieve nice small fractions. Also there is can be
|
||||
degenerated case when S = (0.015, 0.012, >0.05) - if update S to (0, 0, >0.05) -
|
||||
it is a line. This case should be considered separately. Small nice Miller
|
||||
numbers and possibility to create very small segments can not be implemented
|
||||
at same time).
|
||||
|
||||
|
||||
Args:
|
||||
distance: float distance that plane cuts on axis, it must not be 0.
|
||||
Distance is multiple of 0.05.
|
||||
|
||||
Returns:
|
||||
Inverted fraction.
|
||||
0 / 1 if distance is nan
|
||||
|
||||
"""
|
||||
if np.isnan(distance):
|
||||
return fr.Fraction(0, 1)
|
||||
else:
|
||||
fract = fr.Fraction(distance).limit_denominator(10)
|
||||
return fr.Fraction(fract.denominator, fract.numerator)
|
||||
|
||||
|
||||
def sub_miller(segments):
|
||||
'''
|
||||
Calculates Miller indices from segments.
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. Obtain inverted fraction from segments
|
||||
|
||||
2. Find common denominator of inverted fractions
|
||||
|
||||
3. Lead fractions to common denominator and throws denominator away.
|
||||
|
||||
4. Return obtained values.
|
||||
|
||||
Args:
|
||||
List of 3 floats, meaning distances that plane cuts on x, y, z axes.
|
||||
Any float not equals zero, it means that plane does not intersect origin,
|
||||
i. e. shift of origin has already been done.
|
||||
|
||||
Returns:
|
||||
String that represents Miller indices, e.g: (-6,3,-6) or (2,2,2)
|
||||
'''
|
||||
fracts = [segment_to_fraction(segment) for segment in segments]
|
||||
common_denominator = reduce(lcm, [fract.denominator for fract in fracts])
|
||||
miller = ([fract.numerator * math.fabs(common_denominator) /
|
||||
fract.denominator for fract in fracts])
|
||||
return'(' + ','.join(map(str, map(decimal.Decimal, miller))) + ')'
|
||||
|
||||
|
||||
def miller(points):
|
||||
"""
|
||||
Calculates Miller indices from points.
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. Calculate normal vector to a plane that goes trough all points.
|
||||
|
||||
2. Set origin.
|
||||
|
||||
3. Create Cartesian coordinate system (Ccs).
|
||||
|
||||
4. Find the lengths of segments of which the plane cuts the axes. Equation
|
||||
of a line for axes: Origin + (Coordinate_vector - Origin) * parameter.
|
||||
|
||||
5. If plane goes trough Origin:
|
||||
|
||||
a) Find new random origin: find unit cube vertex, not crossed by a plane.
|
||||
|
||||
b) Repeat 2-4.
|
||||
|
||||
c) Fix signs of segments after Origin shift. This means to consider
|
||||
original directions of axes. I.g.: Origin was 0,0,0 and became
|
||||
new_origin. If new_origin has same Y coordinate as Origin, then segment
|
||||
does not change its sign. But if new_origin has another Y coordinate than
|
||||
origin (was 0, became 1), than segment has to change its sign (it now
|
||||
lies on negative side of Y axis). New Origin 0 value of X or Y or Z
|
||||
coordinate means that segment does not change sign, 1 value -> does
|
||||
change. So new sign is (1 - 2 * new_origin): 0 -> 1, 1 -> -1
|
||||
|
||||
6. Run function that calculates miller indices from segments.
|
||||
|
||||
Args:
|
||||
List of points. Each point is list of float coordinates. Order of
|
||||
coordinates in point's list: x, y, z. Points are different!
|
||||
|
||||
Returns:
|
||||
String that represents Miller indices, e.g: (-6,3,-6) or (2,2,2)
|
||||
"""
|
||||
|
||||
N = np.cross(points[1] - points[0], points[2] - points[0])
|
||||
O = np.array([0, 0, 0])
|
||||
P = points[0] # point of plane
|
||||
Ccs = map(np.array, [[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]])
|
||||
segments = ([np.dot(P - O, N) / np.dot(ort, N) if np.dot(ort, N) != 0 else
|
||||
np.nan for ort in Ccs])
|
||||
if any(x == 0 for x in segments): # Plane goes through origin.
|
||||
vertices = [ # top:
|
||||
np.array([1.0, 1.0, 1.0]),
|
||||
np.array([0.0, 0.0, 1.0]),
|
||||
np.array([1.0, 0.0, 1.0]),
|
||||
np.array([0.0, 1.0, 1.0]),
|
||||
# bottom, except 0,0,0:
|
||||
np.array([1.0, 0.0, 0.0]),
|
||||
np.array([0.0, 1.0, 0.0]),
|
||||
np.array([1.0, 1.0, 1.0]),
|
||||
]
|
||||
for vertex in vertices:
|
||||
if np.dot(vertex - O, N) != 0: # vertex not in plane
|
||||
new_origin = vertex
|
||||
break
|
||||
# obtain new axes with center in new origin
|
||||
X = np.array([1 - new_origin[0], new_origin[1], new_origin[2]])
|
||||
Y = np.array([new_origin[0], 1 - new_origin[1], new_origin[2]])
|
||||
Z = np.array([new_origin[0], new_origin[1], 1 - new_origin[2]])
|
||||
new_Ccs = [X - new_origin, Y - new_origin, Z - new_origin]
|
||||
segments = ([np.dot(P - new_origin, N) / np.dot(ort, N) if
|
||||
np.dot(ort, N) != 0 else np.nan for ort in new_Ccs])
|
||||
# fix signs of indices: 0 -> 1, 1 -> -1 (
|
||||
segments = (1 - 2 * new_origin) * segments
|
||||
|
||||
return sub_miller(segments)
|
||||
|
||||
|
||||
def grade(user_input, correct_answer):
|
||||
'''
|
||||
Grade crystallography problem.
|
||||
|
||||
Returns true if lattices are the same and Miller indices are same or minus
|
||||
same. E.g. (2,2,2) = (2, 2, 2) or (-2, -2, -2). Because sign depends only
|
||||
on student's selection of origin.
|
||||
|
||||
Args:
|
||||
user_input, correct_answer: json. Format:
|
||||
|
||||
user_input: {"lattice":"sc","points":[["0.77","0.00","1.00"],
|
||||
["0.78","1.00","0.00"],["0.00","1.00","0.72"]]}
|
||||
|
||||
correct_answer: {'miller': '(00-1)', 'lattice': 'bcc'}
|
||||
|
||||
"lattice" is one of: "", "sc", "bcc", "fcc"
|
||||
|
||||
Returns:
|
||||
True or false.
|
||||
'''
|
||||
def negative(m):
|
||||
"""
|
||||
Change sign of Miller indices.
|
||||
|
||||
Args:
|
||||
m: string with meaning of Miller indices. E.g.:
|
||||
(-6,3,-6) -> (6, -3, 6)
|
||||
|
||||
Returns:
|
||||
String with changed signs.
|
||||
"""
|
||||
output = ''
|
||||
i = 1
|
||||
while i in range(1, len(m) - 1):
|
||||
if m[i] in (',', ' '):
|
||||
output += m[i]
|
||||
elif m[i] not in ('-', '0'):
|
||||
output += '-' + m[i]
|
||||
elif m[i] == '0':
|
||||
output += m[i]
|
||||
else:
|
||||
i += 1
|
||||
output += m[i]
|
||||
i += 1
|
||||
return '(' + output + ')'
|
||||
|
||||
def round0_25(point):
|
||||
"""
|
||||
Rounds point coordinates to closest 0.5 value.
|
||||
|
||||
Args:
|
||||
point: list of float coordinates. Order of coordinates: x, y, z.
|
||||
|
||||
Returns:
|
||||
list of coordinates rounded to closes 0.5 value
|
||||
"""
|
||||
rounded_points = []
|
||||
for coord in point:
|
||||
base = math.floor(coord * 10)
|
||||
fractional_part = (coord * 10 - base)
|
||||
aliquot0_25 = math.floor(fractional_part / 0.25)
|
||||
if aliquot0_25 == 0.0:
|
||||
rounded_points.append(base / 10)
|
||||
if aliquot0_25 in (1.0, 2.0):
|
||||
rounded_points.append(base / 10 + 0.05)
|
||||
if aliquot0_25 == 3.0:
|
||||
rounded_points.append(base / 10 + 0.1)
|
||||
return rounded_points
|
||||
|
||||
user_answer = json.loads(user_input)
|
||||
|
||||
if user_answer['lattice'] != correct_answer['lattice']:
|
||||
return False
|
||||
|
||||
points = [map(float, p) for p in user_answer['points']]
|
||||
|
||||
if len(points) < 3:
|
||||
return False
|
||||
|
||||
# round point to closes 0.05 value
|
||||
points = [round0_25(point) for point in points]
|
||||
|
||||
points = [np.array(point) for point in points]
|
||||
# print miller(points), (correct_answer['miller'].replace(' ', ''),
|
||||
# negative(correct_answer['miller']).replace(' ', ''))
|
||||
if miller(points) in (correct_answer['miller'].replace(' ', ''), negative(correct_answer['miller']).replace(' ', '')):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -1,13 +1,15 @@
|
||||
import codecs
|
||||
from fractions import Fraction
|
||||
from pyparsing import ParseException
|
||||
import unittest
|
||||
|
||||
from chemcalc import (compare_chemical_expression, divide_chemical_expression,
|
||||
render_to_html, chemical_equations_equal)
|
||||
|
||||
import miller
|
||||
|
||||
local_debug = None
|
||||
|
||||
|
||||
def log(s, output_type=None):
|
||||
if local_debug:
|
||||
print s
|
||||
@@ -37,7 +39,6 @@ class Test_Compare_Equations(unittest.TestCase):
|
||||
self.assertFalse(chemical_equations_equal('2H2 + O2 -> H2O2',
|
||||
'2O2 + 2H2 -> 2H2O2'))
|
||||
|
||||
|
||||
def test_different_arrows(self):
|
||||
self.assertTrue(chemical_equations_equal('H2 + O2 -> H2O2',
|
||||
'2O2 + 2H2 -> 2H2O2'))
|
||||
@@ -56,7 +57,6 @@ class Test_Compare_Equations(unittest.TestCase):
|
||||
self.assertTrue(chemical_equations_equal('H2 + O2 -> H2O2',
|
||||
'O2 + H2 -> H2O2', exact=True))
|
||||
|
||||
|
||||
def test_syntax_errors(self):
|
||||
self.assertFalse(chemical_equations_equal('H2 + O2 a-> H2O2',
|
||||
'2O2 + 2H2 -> 2H2O2'))
|
||||
@@ -311,7 +311,6 @@ class Test_Render_Equations(unittest.TestCase):
|
||||
log(out + ' ------- ' + correct, 'html')
|
||||
self.assertEqual(out, correct)
|
||||
|
||||
|
||||
def test_render_eq3(self):
|
||||
s = "H^+ + OH^- <= H2O" # unsupported arrow
|
||||
out = render_to_html(s)
|
||||
@@ -320,10 +319,148 @@ class Test_Render_Equations(unittest.TestCase):
|
||||
self.assertEqual(out, correct)
|
||||
|
||||
|
||||
class Test_Crystallography_Miller(unittest.TestCase):
|
||||
''' Tests for crystallography grade function.'''
|
||||
|
||||
def test_empty_points(self):
|
||||
user_input = '{"lattice": "bcc", "points": []}'
|
||||
self.assertFalse(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_only_one_point(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"]]}'
|
||||
self.assertFalse(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_only_two_points(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"], ["0.00", "0.50", "0.00"]]}'
|
||||
self.assertFalse(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_1(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"], ["0.00", "0.50", "0.00"], ["0.00", "0.00", "0.50"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_2(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.00"], ["0.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_3(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.50", "1.00"], ["1.00", "1.00", "0.50"], ["0.50", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_4(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.33", "1.00", "0.00"], ["0.00", "0.664", "0.00"], ["0.00", "1.00", "0.33"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(-3, 3, -3)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_5(self):
|
||||
""" return true only in case points coordinates are exact.
|
||||
But if they transform to closest 0.05 value it is not true"""
|
||||
user_input = '{"lattice": "bcc", "points": [["0.33", "1.00", "0.00"], ["0.00", "0.33", "0.00"], ["0.00", "1.00", "0.33"]]}'
|
||||
self.assertFalse(miller.grade(user_input, {'miller': '(-6,3,-6)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_6(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.25", "0.00"], ["0.25", "0.00", "0.00"], ["0.00", "0.00", "0.25"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(4,4,4)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_7(self): # goes throug origin
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "1.00", "0.00"], ["1.00", "0.00", "0.00"], ["0.50", "1.00", "0.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,0,-1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_8(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "1.00", "0.50"], ["1.00", "0.00", "0.50"], ["0.50", "1.00", "0.50"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,0,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_9(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "1.00"], ["0.00", "1.00", "1.00"], ["1.00", "0.00", "0.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,0)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_10(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "1.00"], ["0.00", "0.00", "0.00"], ["0.00", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,-1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_11(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.50"], ["1.00", "1.00", "0.00"], ["0.00", "1.00", "0.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,1,2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_12(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.50"], ["0.00", "0.00", "0.50"], ["1.00", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,1,-2)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_13(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"], ["0.50", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(2,0,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_14(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["0.00", "0.00", "1.00"], ["0.50", "1.00", "0.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(2,-1,0)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_15(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,-1,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_16(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.00"], ["0.00", "1.00", "0.00"], ["1.00", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,-1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_17(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "0.00", "1.00"], ["1.00", "1.00", "0.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(-1,1,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_18(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,-1,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_19(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(-1,1,0)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_20(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,0,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_21(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["0.00", "1.00", "0.00"], ["1.00", "0.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(-1,0,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_22(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "1.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,1,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_23(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "0.00", "0.00"], ["1.00", "1.00", "1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,-1,1)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_24(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.66", "0.00", "0.00"], ["0.00", "0.66", "0.00"], ["0.00", "0.00", "0.66"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(3,3,3)', 'lattice': 'bcc'}))
|
||||
|
||||
def test_25(self):
|
||||
user_input = u'{"lattice":"","points":[["0.00","0.00","0.01"],["1.00","1.00","0.01"],["0.00","1.00","1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(1,-1,1)', 'lattice': ''}))
|
||||
|
||||
def test_26(self):
|
||||
user_input = u'{"lattice":"","points":[["0.00","0.01","0.00"],["1.00","0.00","0.00"],["0.00","0.00","1.00"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(0,-1,0)', 'lattice': ''}))
|
||||
|
||||
def test_27(self):
|
||||
""" rounding to 0.35"""
|
||||
user_input = u'{"lattice":"","points":[["0.33","0.00","0.00"],["0.00","0.33","0.00"],["0.00","0.00","0.33"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(3,3,3)', 'lattice': ''}))
|
||||
|
||||
def test_28(self):
|
||||
""" rounding to 0.30"""
|
||||
user_input = u'{"lattice":"","points":[["0.30","0.00","0.00"],["0.00","0.30","0.00"],["0.00","0.00","0.30"]]}'
|
||||
self.assertTrue(miller.grade(user_input, {'miller': '(10,10,10)', 'lattice': ''}))
|
||||
|
||||
def test_wrong_lattice(self):
|
||||
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "0.00", "0.00"], ["1.00", "1.00", "1.00"]]}'
|
||||
self.assertFalse(miller.grade(user_input, {'miller': '(3,3,3)', 'lattice': 'fcc'}))
|
||||
|
||||
|
||||
def suite():
|
||||
|
||||
testcases = [Test_Compare_Expressions, Test_Divide_Expressions, Test_Render_Equations]
|
||||
testcases = [Test_Compare_Expressions,
|
||||
Test_Divide_Expressions,
|
||||
Test_Render_Equations,
|
||||
Test_Crystallography_Miller]
|
||||
suites = []
|
||||
for testcase in testcases:
|
||||
suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase))
|
||||
|
||||
@@ -671,18 +671,15 @@ class Crystallography(InputTypeBase):
|
||||
"""
|
||||
Note: height, width are required.
|
||||
"""
|
||||
return [Attribute('size', None),
|
||||
Attribute('height'),
|
||||
return [Attribute('height'),
|
||||
Attribute('width'),
|
||||
|
||||
# can probably be removed (textline should prob be always-hidden)
|
||||
Attribute('hidden', ''),
|
||||
]
|
||||
|
||||
registry.register(Crystallography)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VseprInput(InputTypeBase):
|
||||
"""
|
||||
Input for molecular geometry--show possible structures, let student
|
||||
@@ -736,3 +733,53 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
|
||||
|
||||
registry.register(ChemicalEquationInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class OpenEndedInput(InputTypeBase):
|
||||
"""
|
||||
A text area input for code--uses codemirror, does syntax highlighting, special tab handling,
|
||||
etc.
|
||||
"""
|
||||
|
||||
template = "openendedinput.html"
|
||||
tags = ['openendedinput']
|
||||
|
||||
# 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.")
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Convert options to a convenient format.
|
||||
"""
|
||||
return [Attribute('rows', '30'),
|
||||
Attribute('cols', '80'),
|
||||
Attribute('hidden', ''),
|
||||
]
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
Implement special logic: handle queueing state, and default input.
|
||||
"""
|
||||
# if no student input yet, then use the default input given by the problem
|
||||
if not self.value:
|
||||
self.value = self.xml.text
|
||||
|
||||
# Check if problem has been queued
|
||||
self.queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
if self.status == 'incomplete':
|
||||
self.status = 'queued'
|
||||
self.queue_len = self.msg
|
||||
self.msg = self.submitted_msg
|
||||
|
||||
def _extra_context(self):
|
||||
"""Defined queue_len, add it """
|
||||
return {'queue_len': self.queue_len,}
|
||||
|
||||
registry.register(OpenEndedInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,22 +8,25 @@ Used by capa_problem.py
|
||||
'''
|
||||
|
||||
# standard library imports
|
||||
import abc
|
||||
import cgi
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import numbers
|
||||
import numpy
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import requests
|
||||
import traceback
|
||||
import hashlib
|
||||
import abc
|
||||
import os
|
||||
import subprocess
|
||||
import traceback
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
from collections import namedtuple
|
||||
from shapely.geometry import Point, MultiPoint
|
||||
|
||||
# specific library imports
|
||||
from calc import evaluator, UndefinedVariable
|
||||
from correctmap import CorrectMap
|
||||
@@ -1104,6 +1107,15 @@ class SymbolicResponse(CustomResponse):
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
"""
|
||||
valid: Flag indicating valid score_msg format (Boolean)
|
||||
correct: Correctness of submission (Boolean)
|
||||
score: Points to be assigned (numeric, can be float)
|
||||
msg: Message from grader to display to student (string)
|
||||
"""
|
||||
ScoreMessage = namedtuple('ScoreMessage',
|
||||
['valid', 'correct', 'points', 'msg'])
|
||||
|
||||
|
||||
class CodeResponse(LoncapaResponse):
|
||||
"""
|
||||
@@ -1149,7 +1161,7 @@ class CodeResponse(LoncapaResponse):
|
||||
else:
|
||||
self._parse_coderesponse_xml(codeparam)
|
||||
|
||||
def _parse_coderesponse_xml(self,codeparam):
|
||||
def _parse_coderesponse_xml(self, codeparam):
|
||||
'''
|
||||
Parse the new CodeResponse XML format. When successful, sets:
|
||||
self.initial_display
|
||||
@@ -1161,17 +1173,9 @@ class CodeResponse(LoncapaResponse):
|
||||
grader_payload = grader_payload.text if grader_payload is not None else ''
|
||||
self.payload = {'grader_payload': grader_payload}
|
||||
|
||||
answer_display = codeparam.find('answer_display')
|
||||
if answer_display is not None:
|
||||
self.answer = answer_display.text
|
||||
else:
|
||||
self.answer = 'No answer provided.'
|
||||
|
||||
initial_display = codeparam.find('initial_display')
|
||||
if initial_display is not None:
|
||||
self.initial_display = initial_display.text
|
||||
else:
|
||||
self.initial_display = ''
|
||||
self.initial_display = find_with_default(codeparam, 'initial_display', '')
|
||||
self.answer = find_with_default(codeparam, 'answer_display',
|
||||
'No answer provided.')
|
||||
|
||||
def _parse_externalresponse_xml(self):
|
||||
'''
|
||||
@@ -1325,8 +1329,6 @@ class CodeResponse(LoncapaResponse):
|
||||
# Sanity check on returned points
|
||||
if points < 0:
|
||||
points = 0
|
||||
elif points > self.maxpoints[self.answer_id]:
|
||||
points = self.maxpoints[self.answer_id]
|
||||
# Queuestate is consumed
|
||||
oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
|
||||
msg=msg.replace(' ', ' '), queuestate=None)
|
||||
@@ -1734,15 +1736,38 @@ class ImageResponse(LoncapaResponse):
|
||||
which produces an [x,y] coordinate pair. The click is correct if it falls
|
||||
within a region specified. This region is a union of rectangles.
|
||||
|
||||
Lon-CAPA requires that each <imageresponse> has a <foilgroup> inside it. That
|
||||
doesn't make sense to me (Ike). Instead, let's have it such that <imageresponse>
|
||||
should contain one or more <imageinput> stanzas. Each <imageinput> should specify
|
||||
a rectangle, given as an attribute, defining the correct answer.
|
||||
Lon-CAPA requires that each <imageresponse> has a <foilgroup> inside it.
|
||||
That doesn't make sense to me (Ike). Instead, let's have it such that
|
||||
<imageresponse> should contain one or more <imageinput> stanzas.
|
||||
Each <imageinput> should specify a rectangle(s) or region(s), given as an
|
||||
attribute, defining the correct answer.
|
||||
|
||||
<imageinput src="/static/images/Lecture2/S2_p04.png" width="811" height="610"
|
||||
rectangle="(10,10)-(20,30);(12,12)-(40,60)"
|
||||
regions="[[[10,10], [20,30], [40, 10]], [[100,100], [120,130], [110,150]]]"/>
|
||||
|
||||
Regions is list of lists [region1, region2, region3, ...] where regionN
|
||||
is disordered list of points: [[1,1], [100,100], [50,50], [20, 70]].
|
||||
|
||||
If there is only one region in the list, simpler notation can be used:
|
||||
regions="[[10,10], [30,30], [10, 30], [30, 10]]" (without explicitly
|
||||
setting outer list)
|
||||
|
||||
Returns:
|
||||
True, if click is inside any region or rectangle. Otherwise False.
|
||||
"""
|
||||
snippets = [{'snippet': '''<imageresponse>
|
||||
<imageinput src="image1.jpg" width="200" height="100" rectangle="(10,10)-(20,30)" />
|
||||
<imageinput src="image2.jpg" width="210" height="130" rectangle="(12,12)-(40,60)" />
|
||||
<imageinput src="image2.jpg" width="210" height="130" rectangle="(10,10)-(20,30);(12,12)-(40,60)" />
|
||||
<imageinput src="image1.jpg" width="200" height="100"
|
||||
rectangle="(10,10)-(20,30)" />
|
||||
<imageinput src="image2.jpg" width="210" height="130"
|
||||
rectangle="(12,12)-(40,60)" />
|
||||
<imageinput src="image3.jpg" width="210" height="130"
|
||||
rectangle="(10,10)-(20,30);(12,12)-(40,60)" />
|
||||
<imageinput src="image4.jpg" width="811" height="610"
|
||||
rectangle="(10,10)-(20,30);(12,12)-(40,60)"
|
||||
regions="[[[10,10], [20,30], [40, 10]], [[100,100], [120,130], [110,150]]]"/>
|
||||
<imageinput src="image5.jpg" width="200" height="200"
|
||||
regions="[[[10,10], [20,30], [40, 10]], [[100,100], [120,130], [110,150]]]"/>
|
||||
</imageresponse>'''}]
|
||||
|
||||
response_tag = 'imageresponse'
|
||||
@@ -1750,19 +1775,17 @@ class ImageResponse(LoncapaResponse):
|
||||
|
||||
def setup_response(self):
|
||||
self.ielements = self.inputfields
|
||||
self.answer_ids = [ie.get('id') for ie in self.ielements]
|
||||
self.answer_ids = [ie.get('id') for ie in self.ielements]
|
||||
|
||||
def get_score(self, student_answers):
|
||||
correct_map = CorrectMap()
|
||||
expectedset = self.get_answers()
|
||||
|
||||
for aid in self.answer_ids: # loop through IDs of <imageinput> fields in our stanza
|
||||
given = student_answers[aid] # this should be a string of the form '[x,y]'
|
||||
|
||||
for aid in self.answer_ids: # loop through IDs of <imageinput>
|
||||
# fields in our stanza
|
||||
given = student_answers[aid] # this should be a string of the form '[x,y]'
|
||||
correct_map.set(aid, 'incorrect')
|
||||
if not given: # No answer to parse. Mark as incorrect and move on
|
||||
if not given: # No answer to parse. Mark as incorrect and move on
|
||||
continue
|
||||
|
||||
# parse given answer
|
||||
m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
|
||||
if not m:
|
||||
@@ -1770,28 +1793,481 @@ class ImageResponse(LoncapaResponse):
|
||||
'error grading %s (input=%s)' % (aid, given))
|
||||
(gx, gy) = [int(x) for x in m.groups()]
|
||||
|
||||
# Check whether given point lies in any of the solution rectangles
|
||||
solution_rectangles = expectedset[aid].split(';')
|
||||
for solution_rectangle in solution_rectangles:
|
||||
# parse expected answer
|
||||
# TODO: Compile regexp on file load
|
||||
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
solution_rectangle.strip().replace(' ', ''))
|
||||
if not m:
|
||||
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
|
||||
etree.tostring(self.ielements[aid], pretty_print=True))
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg)
|
||||
(llx, lly, urx, ury) = [int(x) for x in m.groups()]
|
||||
|
||||
# answer is correct if (x,y) is within the specified rectangle
|
||||
if (llx <= gx <= urx) and (lly <= gy <= ury):
|
||||
correct_map.set(aid, 'correct')
|
||||
break
|
||||
rectangles, regions = expectedset
|
||||
if rectangles[aid]: # rectangles part - for backward compatibility
|
||||
# Check whether given point lies in any of the solution rectangles
|
||||
solution_rectangles = rectangles[aid].split(';')
|
||||
for solution_rectangle in solution_rectangles:
|
||||
# parse expected answer
|
||||
# TODO: Compile regexp on file load
|
||||
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
solution_rectangle.strip().replace(' ', ''))
|
||||
if not m:
|
||||
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
|
||||
etree.tostring(self.ielements[aid], pretty_print=True))
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg)
|
||||
(llx, lly, urx, ury) = [int(x) for x in m.groups()]
|
||||
|
||||
# answer is correct if (x,y) is within the specified rectangle
|
||||
if (llx <= gx <= urx) and (lly <= gy <= ury):
|
||||
correct_map.set(aid, 'correct')
|
||||
break
|
||||
if correct_map[aid]['correctness'] != 'correct' and regions[aid]:
|
||||
parsed_region = json.loads(regions[aid])
|
||||
if parsed_region:
|
||||
if type(parsed_region[0][0]) != list:
|
||||
# we have [[1,2],[3,4],[5,6]] - single region
|
||||
# instead of [[[1,2],[3,4],[5,6], [[1,2],[3,4],[5,6]]]
|
||||
# or [[[1,2],[3,4],[5,6]]] - multiple regions syntax
|
||||
parsed_region = [parsed_region]
|
||||
for region in parsed_region:
|
||||
polygon = MultiPoint(region).convex_hull
|
||||
if (polygon.type == 'Polygon' and
|
||||
polygon.contains(Point(gx, gy))):
|
||||
correct_map.set(aid, 'correct')
|
||||
break
|
||||
return correct_map
|
||||
|
||||
def get_answers(self):
|
||||
return dict([(ie.get('id'), ie.get('rectangle')) for ie in self.ielements])
|
||||
return (dict([(ie.get('id'), ie.get('rectangle')) for ie in self.ielements]),
|
||||
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class OpenEndedResponse(LoncapaResponse):
|
||||
"""
|
||||
Grade student open ended responses using an external grading system,
|
||||
accessed through the xqueue system.
|
||||
|
||||
Expects 'xqueue' dict in ModuleSystem with the following keys that are
|
||||
needed by OpenEndedResponse:
|
||||
|
||||
system.xqueue = { 'interface': XqueueInterface object,
|
||||
'callback_url': Per-StudentModule callback URL
|
||||
where results are posted (string),
|
||||
}
|
||||
|
||||
External requests are only submitted for student submission grading
|
||||
(i.e. and not for getting reference answers)
|
||||
|
||||
By default, uses the OpenEndedResponse.DEFAULT_QUEUE queue.
|
||||
"""
|
||||
|
||||
DEFAULT_QUEUE = 'open-ended'
|
||||
DEFAULT_MESSAGE_QUEUE = 'open-ended-message'
|
||||
response_tag = 'openendedresponse'
|
||||
allowed_inputfields = ['openendedinput']
|
||||
max_inputfields = 1
|
||||
|
||||
def setup_response(self):
|
||||
'''
|
||||
Configure OpenEndedResponse from XML.
|
||||
'''
|
||||
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:
|
||||
raise ValueError("No prompt found in problem xml.")
|
||||
if rubric is None:
|
||||
raise ValueError("No rubric found in problem xml.")
|
||||
|
||||
self._parse(oeparam, prompt, rubric)
|
||||
|
||||
@staticmethod
|
||||
def stringify_children(node):
|
||||
"""
|
||||
Modify code from stringify_children in xmodule. Didn't import directly
|
||||
in order to avoid capa depending on xmodule (seems to be avoided in
|
||||
code)
|
||||
"""
|
||||
parts=[node.text if node.text is not None else '']
|
||||
for p in node.getchildren():
|
||||
parts.append(etree.tostring(p, with_tail=True, encoding='unicode'))
|
||||
|
||||
return ' '.join(parts)
|
||||
|
||||
def _parse(self, oeparam, prompt, rubric):
|
||||
'''
|
||||
Parse OpenEndedResponse XML:
|
||||
self.initial_display
|
||||
self.payload - dict containing keys --
|
||||
'grader' : path to grader settings file, 'problem_id' : id of the problem
|
||||
|
||||
self.answer - What to display when show answer is clicked
|
||||
'''
|
||||
# Note that OpenEndedResponse is agnostic to the specific contents of grader_payload
|
||||
prompt_string = self.stringify_children(prompt)
|
||||
rubric_string = self.stringify_children(rubric)
|
||||
|
||||
grader_payload = oeparam.find('grader_payload')
|
||||
grader_payload = grader_payload.text if grader_payload is not None else ''
|
||||
|
||||
#Update grader payload with student id. If grader payload not json, error.
|
||||
try:
|
||||
parsed_grader_payload = json.loads(grader_payload)
|
||||
# NOTE: self.system.location is valid because the capa_module
|
||||
# __init__ adds it (easiest way to get problem location into
|
||||
# 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}
|
||||
|
||||
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:
|
||||
submission = student_answers[self.answer_id]
|
||||
except KeyError:
|
||||
msg = ('Cannot get student answer for answer_id: {0}. student_answers {1}'
|
||||
.format(self.answer_id, student_answers))
|
||||
log.exception(msg)
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
# Prepare xqueue request
|
||||
#------------------------------------------------------------
|
||||
|
||||
qinterface = self.system.xqueue['interface']
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
|
||||
anonymous_student_id = self.system.anonymous_student_id
|
||||
|
||||
# Generate header
|
||||
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.queue_name)
|
||||
|
||||
self.context.update({'submission': submission})
|
||||
|
||||
contents = self.payload.copy()
|
||||
|
||||
# Metadata related to the student submission revealed to the external grader
|
||||
student_info = {'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
}
|
||||
|
||||
#Update contents with student response and student info
|
||||
contents.update({
|
||||
'student_info': json.dumps(student_info),
|
||||
'student_response': submission,
|
||||
'max_score' : self.max_score,
|
||||
})
|
||||
|
||||
# Submit request. When successful, 'msg' is the prior length of the queue
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents))
|
||||
|
||||
# State associated with the queueing request
|
||||
queuestate = {'key': queuekey,
|
||||
'time': qtime,}
|
||||
|
||||
cmap = CorrectMap()
|
||||
if error:
|
||||
cmap.set(self.answer_id, queuestate=None,
|
||||
msg='Unable to deliver your submission to grader. (Reason: {0}.)'
|
||||
' Please try again later.'.format(msg))
|
||||
else:
|
||||
# Queueing mechanism flags:
|
||||
# 1) Backend: Non-null CorrectMap['queuestate'] indicates that
|
||||
# the problem has been queued
|
||||
# 2) Frontend: correctness='incomplete' eventually trickles down
|
||||
# through inputtypes.textbox and .filesubmission to inform the
|
||||
# browser that the submission is queued (and it could e.g. poll)
|
||||
cmap.set(self.answer_id, queuestate=queuestate,
|
||||
correctness='incomplete', msg=msg)
|
||||
|
||||
return cmap
|
||||
|
||||
def update_score(self, score_msg, oldcmap, queuekey):
|
||||
log.debug(score_msg)
|
||||
score_msg = self._parse_score_msg(score_msg)
|
||||
if not score_msg.valid:
|
||||
oldcmap.set(self.answer_id,
|
||||
msg = 'Invalid grader reply. Please contact the course staff.')
|
||||
return oldcmap
|
||||
|
||||
correctness = 'correct' if score_msg.correct else 'incorrect'
|
||||
|
||||
# TODO: Find out how this is used elsewhere, if any
|
||||
self.context['correct'] = correctness
|
||||
|
||||
# Replace 'oldcmap' with new grading results if queuekey matches. If queuekey
|
||||
# does not match, we keep waiting for the score_msg whose key actually matches
|
||||
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
|
||||
# Sanity check on returned points
|
||||
points = score_msg.points
|
||||
if points < 0:
|
||||
points = 0
|
||||
|
||||
# Queuestate is consumed, so reset it to None
|
||||
oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
|
||||
msg = score_msg.msg.replace(' ', ' '), queuestate=None)
|
||||
else:
|
||||
log.debug('OpenEndedResponse: queuekey {0} does not match for answer_id={1}.'.format(
|
||||
queuekey, self.answer_id))
|
||||
|
||||
return oldcmap
|
||||
|
||||
def get_answers(self):
|
||||
anshtml = '<span class="openended-answer"><pre><code>{0}</code></pre></span>'.format(self.answer)
|
||||
return {self.answer_id: anshtml}
|
||||
|
||||
def get_initial_display(self):
|
||||
return {self.answer_id: self.initial_display}
|
||||
|
||||
def _convert_longform_feedback_to_html(self, response_items):
|
||||
"""
|
||||
Take in a dictionary, and return html strings for display to student.
|
||||
Input:
|
||||
response_items: Dictionary with keys success, feedback.
|
||||
if success is True, feedback should be a dictionary, with keys for
|
||||
types of feedback, and the corresponding feedback values.
|
||||
if success is False, feedback is actually an error string.
|
||||
|
||||
NOTE: this will need to change when we integrate peer grading, because
|
||||
that will have more complex feedback.
|
||||
|
||||
Output:
|
||||
String -- html that can be displayed to the student.
|
||||
"""
|
||||
|
||||
# We want to display available feedback in a particular order.
|
||||
# This dictionary specifies which goes first--lower first.
|
||||
priorities = {# These go at the start of the feedback
|
||||
'spelling': 0,
|
||||
'grammar': 1,
|
||||
# needs to be after all the other feedback
|
||||
'markup_text': 3}
|
||||
|
||||
default_priority = 2
|
||||
|
||||
def get_priority(elt):
|
||||
"""
|
||||
Args:
|
||||
elt: a tuple of feedback-type, feedback
|
||||
Returns:
|
||||
the priority for this feedback type
|
||||
"""
|
||||
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):
|
||||
feedback_type,value=encode_values(feedback_type,value)
|
||||
feedback= """
|
||||
<div class="{feedback_type}">
|
||||
{value}
|
||||
</div>
|
||||
""".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 = """
|
||||
<div class="{feedback_type}" style="display: none;">
|
||||
{value}
|
||||
</div>
|
||||
""".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', 'submission_id', 'grader_id']:
|
||||
if tag not in response_items:
|
||||
return format_feedback('errors', 'Error getting feedback')
|
||||
|
||||
feedback_items = response_items['feedback']
|
||||
try:
|
||||
feedback = json.loads(feedback_items)
|
||||
except (TypeError, ValueError):
|
||||
log.exception("feedback_items have invalid json %r", feedback_items)
|
||||
return format_feedback('errors', 'Could not parse feedback')
|
||||
|
||||
if response_items['success']:
|
||||
if len(feedback) == 0:
|
||||
return format_feedback('errors', 'No feedback available')
|
||||
|
||||
feedback_lst = sorted(feedback.items(), key=get_priority)
|
||||
|
||||
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):
|
||||
"""
|
||||
Input:
|
||||
Dictionary called feedback. Must contain keys seen below.
|
||||
Output:
|
||||
Return error message or feedback template
|
||||
"""
|
||||
|
||||
feedback = self._convert_longform_feedback_to_html(response_items)
|
||||
|
||||
if not response_items['success']:
|
||||
return self.system.render_template("open_ended_error.html",
|
||||
{'errors' : feedback})
|
||||
|
||||
feedback_template = self.system.render_template("open_ended_feedback.html", {
|
||||
'grader_type': response_items['grader_type'],
|
||||
'score': "{0} / {1}".format(response_items['score'], self.max_score),
|
||||
'feedback': feedback,
|
||||
})
|
||||
|
||||
return feedback_template
|
||||
|
||||
|
||||
def _parse_score_msg(self, score_msg):
|
||||
"""
|
||||
Grader reply is a JSON-dump of the following dict
|
||||
{ 'correct': True/False,
|
||||
'score': Numeric value (floating point is okay) to assign to answer
|
||||
'msg': grader_msg
|
||||
'feedback' : feedback from grader
|
||||
}
|
||||
|
||||
Returns (valid_score_msg, correct, score, msg):
|
||||
valid_score_msg: Flag indicating valid score_msg format (Boolean)
|
||||
correct: Correctness of submission (Boolean)
|
||||
score: Points to be assigned (numeric, can be float)
|
||||
"""
|
||||
fail = ScoreMessage(valid=False, correct=False, points=0, msg='')
|
||||
try:
|
||||
score_result = json.loads(score_msg)
|
||||
except (TypeError, ValueError):
|
||||
log.error("External grader message should be a JSON-serialized dict."
|
||||
" Received score_msg = {0}".format(score_msg))
|
||||
return fail
|
||||
|
||||
if not isinstance(score_result, dict):
|
||||
log.error("External grader message should be a JSON-serialized dict."
|
||||
" Received score_result = {0}".format(score_result))
|
||||
return fail
|
||||
|
||||
|
||||
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))
|
||||
return fail
|
||||
|
||||
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']) / float(self.max_score)
|
||||
correct = (score_ratio >= 0.66)
|
||||
|
||||
#Currently ignore msg and only return feedback (which takes the place of msg)
|
||||
return ScoreMessage(valid=True, correct=correct,
|
||||
points=score_result['score'], msg=feedback)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# TEMPORARY: List of all response subclasses
|
||||
@@ -1810,4 +2286,5 @@ __all__ = [CodeResponse,
|
||||
ChoiceResponse,
|
||||
MultipleChoiceResponse,
|
||||
TrueFalseResponse,
|
||||
JavascriptResponse]
|
||||
JavascriptResponse,
|
||||
OpenEndedResponse]
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
<section id="inputtype_${id}" class="capa_inputtype" >
|
||||
<div id="holder" style="width:${width};height:${height}"></div>
|
||||
<div class="crystalography_problem" style="width:${width};height:${height}"></div>
|
||||
|
||||
<div class="input_lattice">
|
||||
Lattice: <select></select>
|
||||
</div>
|
||||
|
||||
<div class="script_placeholder" data-src="/static/js/raphael.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/sylvester.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/underscore-min.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/crystallography.js"></div>
|
||||
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
@@ -38,14 +32,15 @@
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
|
||||
56
common/lib/capa/capa/templates/openendedinput.html
Normal file
56
common/lib/capa/capa/templates/openendedinput.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<section id="openended_${id}" class="openended">
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" class="short-form-response" id="input_${id}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
>${value|h}</textarea>
|
||||
|
||||
<div class="grader-status">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
% elif status == 'queued':
|
||||
<span class="grading" id="status_${id}">Submitted for grading</span>
|
||||
% endif
|
||||
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
|
||||
% if status == 'queued':
|
||||
<input name="reload" class="reload" type="button" value="Recheck for Feedback" onclick="document.location.reload(true);" />
|
||||
% endif
|
||||
<div class="external-grader-message">
|
||||
${msg|n}
|
||||
% if status in ['correct','incorrect']:
|
||||
<div class="collapsible evaluation-response">
|
||||
<header>
|
||||
<a href="#">Respond to Feedback</a>
|
||||
</header>
|
||||
<section id="evaluation_${id}" class="evaluation">
|
||||
<p>How accurate do you find this feedback?</p>
|
||||
<div class="evaluation-scoring">
|
||||
<ul class="scoring-list">
|
||||
<li><input type="radio" name="evaluation-score" id="evaluation-score-5" value="5" /> <label for="evaluation-score-5"> Correct</label></li>
|
||||
<li><input type="radio" name="evaluation-score" id="evaluation-score-4" value="4" /> <label for="evaluation-score-4"> Partially Correct</label></li>
|
||||
<li><input type="radio" name="evaluation-score" id="evaluation-score-3" value="3" /> <label for="evaluation-score-3"> No Opinion</label></li>
|
||||
<li><input type="radio" name="evaluation-score" id="evaluation-score-2" value="2" /> <label for="evaluation-score-2"> Partially Incorrect</label></li>
|
||||
<li><input type="radio" name="evaluation-score" id="evaluation-score-1" value="1" /> <label for="evaluation-score-1"> Incorrect</label></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Additional comments:</p>
|
||||
<textarea rows="${rows}" cols="${cols}" name="feedback_${id}" class="feedback-on-feedback" id="feedback_${id}"></textarea>
|
||||
<div class="submit-message-container">
|
||||
<input name="submit-message" class="submit-message" type="button" value="Submit your message"/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -18,4 +18,23 @@ Hello</p></text>
|
||||
<text><p>Use conservation of energy.</p></text>
|
||||
</hintgroup>
|
||||
</imageresponse>
|
||||
|
||||
|
||||
<imageresponse max="1" loncapaid="12">
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)" regions='[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]'/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]]]"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[10,10], [30,30], [15, 15]]"/>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[10,10], [30,30], [10, 30], [30, 10]]"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<hintgroup showoncorrect="no">
|
||||
<text><p>Use conservation of energy.</p></text>
|
||||
</hintgroup>
|
||||
</imageresponse>
|
||||
|
||||
|
||||
</problem>
|
||||
|
||||
@@ -407,13 +407,11 @@ class CrystallographyTest(unittest.TestCase):
|
||||
def test_rendering(self):
|
||||
height = '12'
|
||||
width = '33'
|
||||
size = '10'
|
||||
|
||||
xml_str = """<crystallography id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
size="{s}"
|
||||
/>""".format(h=height, w=width, s=size)
|
||||
/>""".format(h=height, w=width)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -428,9 +426,7 @@ class CrystallographyTest(unittest.TestCase):
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': value,
|
||||
'status': 'unsubmitted',
|
||||
'size': size,
|
||||
'msg': '',
|
||||
'hidden': '',
|
||||
'width': width,
|
||||
'height': height,
|
||||
}
|
||||
|
||||
@@ -52,24 +52,57 @@ class ImageResponseTest(unittest.TestCase):
|
||||
def test_ir_grade(self):
|
||||
imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': '(490,11)-(556,98)',
|
||||
'1_2_2': '(242,202)-(296,276)',
|
||||
'1_2_3': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_4': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_5': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
# testing regions only
|
||||
correct_answers = {
|
||||
#regions
|
||||
'1_2_1': '(490,11)-(556,98)',
|
||||
'1_2_2': '(242,202)-(296,276)',
|
||||
'1_2_3': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_4': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_5': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
#testing regions and rectanges
|
||||
'1_3_1': 'rectangle="(490,11)-(556,98)" \
|
||||
regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_2': 'rectangle="(490,11)-(556,98)" \
|
||||
regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_3': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_4': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
|
||||
'1_3_5': 'regions="[[[10,10], [20,10], [20, 30]]]"',
|
||||
'1_3_6': 'regions="[[10,10], [30,30], [15, 15]]"',
|
||||
'1_3_7': 'regions="[[10,10], [30,30], [10, 30], [30, 10]]"',
|
||||
}
|
||||
test_answers = {'1_2_1': '[500,20]',
|
||||
'1_2_2': '[250,300]',
|
||||
'1_2_3': '[500,20]',
|
||||
'1_2_4': '[250,250]',
|
||||
'1_2_5': '[10,10]',
|
||||
test_answers = {
|
||||
'1_2_1': '[500,20]',
|
||||
'1_2_2': '[250,300]',
|
||||
'1_2_3': '[500,20]',
|
||||
'1_2_4': '[250,250]',
|
||||
'1_2_5': '[10,10]',
|
||||
|
||||
'1_3_1': '[500,20]',
|
||||
'1_3_2': '[15,15]',
|
||||
'1_3_3': '[500,20]',
|
||||
'1_3_4': '[115,115]',
|
||||
'1_3_5': '[15,15]',
|
||||
'1_3_6': '[20,20]',
|
||||
'1_3_7': '[20,15]',
|
||||
}
|
||||
|
||||
# regions
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_3'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_4'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_5'), 'incorrect')
|
||||
|
||||
# regions and rectangles
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_2'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_3'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_4'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_5'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_6'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_7'), 'correct')
|
||||
|
||||
|
||||
class SymbolicResponseTest(unittest.TestCase):
|
||||
def test_sr_grade(self):
|
||||
|
||||
@@ -65,3 +65,25 @@ def is_file(file_to_test):
|
||||
Duck typing to check if 'file_to_test' is a File object
|
||||
'''
|
||||
return all(hasattr(file_to_test, method) for method in ['read', 'name'])
|
||||
|
||||
|
||||
def find_with_default(node, path, default):
|
||||
"""
|
||||
Look for a child of node using , and return its text if found.
|
||||
Otherwise returns default.
|
||||
|
||||
Arguments:
|
||||
node: lxml node
|
||||
path: xpath search expression
|
||||
default: value to return if nothing found
|
||||
|
||||
Returns:
|
||||
node.find(path).text if the find succeeds, default otherwise.
|
||||
|
||||
"""
|
||||
v = node.find(path)
|
||||
if v is not None:
|
||||
return v.text
|
||||
else:
|
||||
return default
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ def parse_xreply(xreply):
|
||||
|
||||
return_code = xreply['return_code']
|
||||
content = xreply['content']
|
||||
|
||||
return (return_code, content)
|
||||
|
||||
|
||||
@@ -80,7 +81,11 @@ class XQueueInterface(object):
|
||||
|
||||
# Log in, then try again
|
||||
if error and (msg == 'login_required'):
|
||||
self._login()
|
||||
(error, content) = self._login()
|
||||
if error != 0:
|
||||
# when the login fails
|
||||
log.debug("Failed to login to queue: %s", content)
|
||||
return (error, content)
|
||||
if files_to_upload is not None:
|
||||
# Need to rewind file pointers
|
||||
for f in files_to_upload:
|
||||
|
||||
@@ -45,7 +45,7 @@ def get_logger_config(log_dir,
|
||||
logging_env=logging_env, hostname=hostname)
|
||||
|
||||
handlers = ['console', 'local'] if debug else ['console',
|
||||
'syslogger-remote', 'local', 'newrelic']
|
||||
'syslogger-remote', 'local']
|
||||
|
||||
logger_config = {
|
||||
'version': 1,
|
||||
|
||||
@@ -7,6 +7,7 @@ source = common/lib/xmodule
|
||||
ignore_errors = True
|
||||
|
||||
[html]
|
||||
title = XModule Python Test Coverage Report
|
||||
directory = reports/common/lib/xmodule/cover
|
||||
|
||||
[xml]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<script type="text/javascript" src="<%= common_coffee_root %>/logger.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jasmine-jquery.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.cookie.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/CodeMirror/codemirror.js"></script>
|
||||
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>
|
||||
|
||||
|
||||
@@ -145,6 +145,11 @@ class CapaModule(XModule):
|
||||
else:
|
||||
self.seed = None
|
||||
|
||||
# Need the problem location in openendedresponse to send out. Adding
|
||||
# it to the system here seems like the least clunky way to get it
|
||||
# there.
|
||||
self.system.set('location', self.location.url())
|
||||
|
||||
try:
|
||||
# TODO (vshnayder): move as much as possible of this work and error
|
||||
# checking to descriptor load time
|
||||
@@ -366,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:
|
||||
@@ -380,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:
|
||||
@@ -650,11 +670,29 @@ class CapaDescriptor(RawDescriptor):
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = 'problem'
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]}
|
||||
js_module_name = "MarkdownEditingDescriptor"
|
||||
css = {'scss': [resource_string(__name__, 'css/problem/edit.scss')]}
|
||||
|
||||
# Capa modules have some additional metadata:
|
||||
# TODO (vshnayder): do problems have any other metadata? Do they
|
||||
# actually use type and points?
|
||||
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
|
||||
|
||||
def get_context(self):
|
||||
_context = RawDescriptor.get_context(self)
|
||||
_context.update({'markdown': self.metadata.get('markdown', '')})
|
||||
return _context
|
||||
|
||||
@property
|
||||
def editable_metadata_fields(self):
|
||||
"""Remove metadata from the editable fields since it has its own editor"""
|
||||
subset = super(CapaDescriptor,self).editable_metadata_fields
|
||||
if 'markdown' in subset:
|
||||
subset.remove('markdown')
|
||||
return subset
|
||||
|
||||
|
||||
# VS[compat]
|
||||
# TODO (cpennington): Delete this method once all fall 2012 course are being
|
||||
|
||||
@@ -6,6 +6,7 @@ from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.timeparse import parse_time, stringify_time
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
@@ -18,6 +19,8 @@ log = logging.getLogger(__name__)
|
||||
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
|
||||
remove_comments=True, remove_blank_text=True)
|
||||
|
||||
_cached_toc = {}
|
||||
|
||||
class CourseDescriptor(SequenceDescriptor):
|
||||
module_class = SequenceModule
|
||||
|
||||
@@ -50,6 +53,24 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
"""
|
||||
toc_url = self.book_url + 'toc.xml'
|
||||
|
||||
# cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores)
|
||||
# course modules have a very short lifespan and are constantly being created and torn down.
|
||||
# Since this module in the __init__() method does a synchronous call to AWS to get the TOC
|
||||
# this is causing a big performance problem. So let's be a bit smarter about this and cache
|
||||
# each fetch and store in-mem for 10 minutes.
|
||||
# NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and
|
||||
# rewrite to use the traditional Django in-memory cache.
|
||||
try:
|
||||
# see if we already fetched this
|
||||
if toc_url in _cached_toc:
|
||||
(table_of_contents, timestamp) = _cached_toc[toc_url]
|
||||
age = datetime.now() - timestamp
|
||||
# expire every 10 minutes
|
||||
if age.seconds < 600:
|
||||
return table_of_contents
|
||||
except Exception as err:
|
||||
pass
|
||||
|
||||
# Get the table of contents from S3
|
||||
log.info("Retrieving textbook table of contents from %s" % toc_url)
|
||||
try:
|
||||
@@ -62,6 +83,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# TOC is XML. Parse it
|
||||
try:
|
||||
table_of_contents = etree.fromstring(r.text)
|
||||
_cached_toc[toc_url] = (table_of_contents, datetime.now())
|
||||
except Exception as err:
|
||||
msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url)
|
||||
log.error(msg)
|
||||
@@ -97,7 +119,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):
|
||||
@@ -186,7 +207,8 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course)
|
||||
|
||||
# bleh, have to parse the XML here to just pull out the url_name attribute
|
||||
course_file = StringIO(xml_data)
|
||||
# I don't think it's stored anywhere in the instance.
|
||||
course_file = StringIO(xml_data.encode('ascii','ignore'))
|
||||
xml_obj = etree.parse(course_file,parser=edx_xml_parser).getroot()
|
||||
|
||||
policy_dir = None
|
||||
@@ -209,7 +231,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
instance.set_grading_policy(policy)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
@@ -293,6 +315,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
|
||||
|
||||
|
||||
@property
|
||||
def lowest_passing_grade(self):
|
||||
return min(self._grading_policy['GRADE_CUTOFFS'].values())
|
||||
|
||||
@property
|
||||
def tabs(self):
|
||||
"""
|
||||
@@ -395,7 +421,20 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def start_date_text(self):
|
||||
displayed_start = self._try_parse_time('advertised_start') or self.start
|
||||
parsed_advertised_start = self._try_parse_time('advertised_start')
|
||||
|
||||
# If the advertised start isn't a real date string, we assume it's free
|
||||
# form text...
|
||||
if parsed_advertised_start is None and \
|
||||
('advertised_start' in self.metadata):
|
||||
return self.metadata['advertised_start']
|
||||
|
||||
displayed_start = parsed_advertised_start or self.start
|
||||
|
||||
# If we have neither an advertised start or a real start, just return TBD
|
||||
if not displayed_start:
|
||||
return "TBD"
|
||||
|
||||
return time.strftime("%b %d, %Y", displayed_start)
|
||||
|
||||
@property
|
||||
@@ -440,7 +479,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
return False
|
||||
except:
|
||||
log.exception("Error parsing discussion_blackouts for course {0}".format(self.id))
|
||||
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
|
||||
@@ -121,16 +121,6 @@ section.problem {
|
||||
}
|
||||
}
|
||||
|
||||
&.processing {
|
||||
p.status {
|
||||
@include inline-block();
|
||||
background: url('../images/spinner.gif') center center no-repeat;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
text-indent: -9999px;
|
||||
}
|
||||
}
|
||||
|
||||
&.correct, &.ui-icon-check {
|
||||
p.status {
|
||||
@include inline-block();
|
||||
@@ -250,6 +240,13 @@ section.problem {
|
||||
}
|
||||
}
|
||||
|
||||
.reload
|
||||
{
|
||||
float:right;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
|
||||
.grader-status {
|
||||
padding: 9px;
|
||||
background: #F6F6F6;
|
||||
@@ -266,6 +263,13 @@ section.problem {
|
||||
margin: -7px 7px 0 0;
|
||||
}
|
||||
|
||||
.grading {
|
||||
background: url('../images/info-icon.png') left center no-repeat;
|
||||
padding-left: 25px;
|
||||
text-indent: 0px;
|
||||
margin: 0px 7px 0 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 20px;
|
||||
text-transform: capitalize;
|
||||
@@ -293,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 ;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,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;
|
||||
@@ -685,6 +738,21 @@ section.problem {
|
||||
color: #B00;
|
||||
}
|
||||
}
|
||||
|
||||
.markup-text{
|
||||
margin: 5px;
|
||||
padding: 20px 0px 15px 50px;
|
||||
border-top: 1px solid #DDD;
|
||||
border-left: 20px solid #FAFAFA;
|
||||
|
||||
bs {
|
||||
color: #BB0000;
|
||||
}
|
||||
|
||||
bg {
|
||||
color: #BDA046;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
153
common/lib/xmodule/xmodule/css/problem/edit.scss
Normal file
153
common/lib/xmodule/xmodule/css/problem/edit.scss
Normal file
@@ -0,0 +1,153 @@
|
||||
.editor-bar {
|
||||
position: relative;
|
||||
@include linear-gradient(top, #d4dee8, #c9d5e2);
|
||||
padding: 5px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 3px 3px 0 0;
|
||||
border-bottom-color: #a5aaaf;
|
||||
@include clearfix;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 3px 10px 7px;
|
||||
margin-left: 7px;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, .5);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
|
||||
li {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 24px;
|
||||
padding: 7px 20px 3px;
|
||||
border: 1px solid #a5aaaf;
|
||||
border-radius: 3px 3px 0 0;
|
||||
@include linear-gradient(top, rgba(0, 0, 0, 0) 87%, rgba(0, 0, 0, .06));
|
||||
background-color: #e5ecf3;
|
||||
font-size: 13px;
|
||||
color: #3c3c3c;
|
||||
box-shadow: 1px -1px 1px rgba(0, 0, 0, .05);
|
||||
|
||||
&.current {
|
||||
background: #fff;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.cheatsheet-toggle {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
padding: 0;
|
||||
margin: 3px 5px 0 16px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid #a5aaaf;
|
||||
background: #e5ecf3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #565d64;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.simple-editor-cheatsheet {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
width: 0;
|
||||
border-radius: 0 3px 3px 0;
|
||||
@include linear-gradient(left, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0) 15px);
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
@include transition(width .3s);
|
||||
|
||||
&.shown {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.cheatsheet-wrapper {
|
||||
width: 240px;
|
||||
padding: 20px 30px;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin-bottom: 7px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row {
|
||||
@include clearfix;
|
||||
padding-bottom: 5px !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-bottom: 1px solid #ddd !important;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.col {
|
||||
float: left;
|
||||
|
||||
&.sample {
|
||||
width: 60px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.problem-editor-icon {
|
||||
display: inline-block;
|
||||
width: 26px;
|
||||
height: 21px;
|
||||
vertical-align: middle;
|
||||
background: url(../img/problem-editor-icons.png) no-repeat;
|
||||
}
|
||||
|
||||
.problem-editor-icon.multiple-choice {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
.problem-editor-icon.checks {
|
||||
background-position: -56px 0;
|
||||
}
|
||||
|
||||
.problem-editor-icon.string {
|
||||
width: 28px;
|
||||
background-position: -111px 0;
|
||||
}
|
||||
|
||||
.problem-editor-icon.number {
|
||||
width: 24px;
|
||||
background-position: -168px 0;
|
||||
}
|
||||
|
||||
.problem-editor-icon.dropdown {
|
||||
width: 17px;
|
||||
background-position: -220px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -149,14 +149,14 @@ class ErrorDescriptor(JSONEditingDescriptor):
|
||||
'''
|
||||
try:
|
||||
xml = etree.fromstring(self.definition['data']['contents'])
|
||||
return etree.tostring(xml)
|
||||
return etree.tostring(xml, encoding='unicode')
|
||||
except etree.XMLSyntaxError:
|
||||
# still not valid.
|
||||
root = etree.Element('error')
|
||||
root.text = self.definition['data']['contents']
|
||||
err_node = etree.SubElement(root, 'error_msg')
|
||||
err_node.text = self.definition['data']['error_msg']
|
||||
return etree.tostring(root)
|
||||
return etree.tostring(root, encoding='unicode')
|
||||
|
||||
|
||||
class NonStaffErrorDescriptor(ErrorDescriptor):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,15 +6,14 @@ import sys
|
||||
from lxml import etree
|
||||
from path import path
|
||||
|
||||
from .x_module import XModule
|
||||
from pkg_resources import resource_string
|
||||
from .xml_module import XmlDescriptor, name_to_pathname
|
||||
from .editing_module import EditingDescriptor
|
||||
from .stringify import stringify_children
|
||||
from .html_checker import check_html
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent
|
||||
from xmodule.editing_module import EditingDescriptor
|
||||
from xmodule.html_checker import check_html
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.stringify import stringify_children
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor, name_to_pathname
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -121,7 +120,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
|
||||
try:
|
||||
with system.resources_fs.open(filepath) as file:
|
||||
html = file.read()
|
||||
html = file.read().decode('utf-8')
|
||||
# Log a warning if we can't parse the file, but don't error
|
||||
if not check_html(html):
|
||||
msg = "Couldn't parse html in {0}.".format(filepath)
|
||||
@@ -162,7 +161,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
|
||||
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
|
||||
with resource_fs.open(filepath, 'w') as file:
|
||||
file.write(self.definition['data'])
|
||||
file.write(self.definition['data'].encode('utf-8'))
|
||||
|
||||
# write out the relative name
|
||||
relname = path(pathname).basename()
|
||||
|
||||
@@ -70,7 +70,8 @@ describe 'Problem', ->
|
||||
it 'bind the math input', ->
|
||||
expect($('input.math')).toHandleWith 'keyup', @problem.refreshMath
|
||||
|
||||
it 'replace math content on the page', ->
|
||||
# TODO: figure out why failing
|
||||
xit 'replace math content on the page', ->
|
||||
expect(MathJax.Hub.Queue.mostRecentCall.args).toEqual [
|
||||
['Text', @stubbedJax, ''],
|
||||
[@problem.updateMathML, @stubbedJax, $('#input_example_1').get(0)]
|
||||
@@ -137,10 +138,11 @@ describe 'Problem', ->
|
||||
@problem.check()
|
||||
expect(@problem.el.html()).toEqual 'Incorrect!'
|
||||
|
||||
describe 'when the response is undetermined', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'when the response is undetermined', ->
|
||||
it 'alert the response', ->
|
||||
spyOn window, 'alert'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
callback(success: 'Number Only!')
|
||||
@problem.check()
|
||||
expect(window.alert).toHaveBeenCalledWith 'Number Only!'
|
||||
@@ -262,7 +264,8 @@ describe 'Problem', ->
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
|
||||
'foo=1&bar=2', jasmine.any(Function)
|
||||
|
||||
it 'alert to the user', ->
|
||||
# TODO: figure out why failing
|
||||
xit 'alert to the user', ->
|
||||
spyOn window, 'alert'
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'OK')
|
||||
@problem.save()
|
||||
@@ -320,7 +323,8 @@ describe 'Problem', ->
|
||||
@problem.refreshAnswers()
|
||||
expect(@stubCodeMirror.save).toHaveBeenCalled()
|
||||
|
||||
it 'serialize all answers', ->
|
||||
# TODO: figure out why failing
|
||||
xit 'serialize all answers', ->
|
||||
@problem.refreshAnswers()
|
||||
expect(@problem.answers).toEqual "input_1_1=one&input_1_2=two"
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# Stub Youtube API
|
||||
window.YT =
|
||||
PlayerState:
|
||||
UNSTARTED: -1
|
||||
ENDED: 0
|
||||
PLAYING: 1
|
||||
PAUSED: 2
|
||||
BUFFERING: 3
|
||||
CUED: 5
|
||||
|
||||
jasmine.getFixtures().fixturesPath = 'xmodule/js/fixtures'
|
||||
|
||||
jasmine.stubbedMetadata =
|
||||
@@ -56,16 +66,6 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
|
||||
spyOn(window, 'onunload')
|
||||
|
||||
# Stub Youtube API
|
||||
window.YT =
|
||||
PlayerState:
|
||||
UNSTARTED: -1
|
||||
ENDED: 0
|
||||
PLAYING: 1
|
||||
PAUSED: 2
|
||||
BUFFERING: 3
|
||||
CUED: 5
|
||||
|
||||
# Stub jQuery.cookie
|
||||
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
|
||||
|
||||
|
||||
338
common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee
Normal file
338
common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee
Normal file
@@ -0,0 +1,338 @@
|
||||
describe 'MarkdownEditingDescriptor', ->
|
||||
|
||||
describe 'insertMultipleChoice', ->
|
||||
it 'inserts the template if selection is empty', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('')
|
||||
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.multipleChoiceTemplate)
|
||||
it 'wraps existing text', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('foo\nbar')
|
||||
expect(revisedSelection).toEqual('( ) foo\n( ) bar\n')
|
||||
it 'recognizes x as a selection if there is non-whitespace after x', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('a\nx b\nc\nx \nd\n x e')
|
||||
expect(revisedSelection).toEqual('( ) a\n(x) b\n( ) c\n( ) x \n( ) d\n(x) e\n')
|
||||
it 'recognizes x as a selection if it is first non whitespace and has whitespace with other non-whitespace', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(' x correct\n x \nex post facto\nb x c\nx c\nxxp')
|
||||
expect(revisedSelection).toEqual('(x) correct\n( ) x \n( ) ex post facto\n( ) b x c\n(x) c\n( ) xxp\n')
|
||||
it 'removes multiple newlines but not last one', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('a\nx b\n\n\nc\n')
|
||||
expect(revisedSelection).toEqual('( ) a\n(x) b\n( ) c\n')
|
||||
|
||||
describe 'insertCheckboxChoice', ->
|
||||
# Note, shares code with insertMultipleChoice. Therefore only doing smoke test.
|
||||
it 'inserts the template if selection is empty', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice('')
|
||||
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.checkboxChoiceTemplate)
|
||||
it 'wraps existing text', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice('foo\nbar')
|
||||
expect(revisedSelection).toEqual('[ ] foo\n[ ] bar\n')
|
||||
|
||||
describe 'insertStringInput', ->
|
||||
it 'inserts the template if selection is empty', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertStringInput('')
|
||||
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.stringInputTemplate)
|
||||
it 'wraps existing text', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertStringInput('my text')
|
||||
expect(revisedSelection).toEqual('= my text')
|
||||
|
||||
describe 'insertNumberInput', ->
|
||||
it 'inserts the template if selection is empty', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertNumberInput('')
|
||||
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.numberInputTemplate)
|
||||
it 'wraps existing text', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertNumberInput('my text')
|
||||
expect(revisedSelection).toEqual('= my text')
|
||||
|
||||
describe 'insertSelect', ->
|
||||
it 'inserts the template if selection is empty', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertSelect('')
|
||||
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.selectTemplate)
|
||||
it 'wraps existing text', ->
|
||||
revisedSelection = MarkdownEditingDescriptor.insertSelect('my text')
|
||||
expect(revisedSelection).toEqual('[[my text]]')
|
||||
|
||||
describe 'markdownToXml', ->
|
||||
it 'converts raw text to paragraph', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml('foo')
|
||||
expect(data).toEqual('<problem>\n<p>foo</p>\n</problem>')
|
||||
# test default templates
|
||||
it 'converts numerical response to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.
|
||||
|
||||
The answer is correct if it is within a specified numerical tolerance of the expected answer.
|
||||
|
||||
Enter the numerical value of Pi:
|
||||
= 3.14159 +- .02
|
||||
|
||||
Enter the approximate value of 502*9:
|
||||
= 4518 +- 15%
|
||||
|
||||
Enter the number of fingers on a human hand:
|
||||
= 5
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
Explanation
|
||||
|
||||
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
|
||||
|
||||
Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.
|
||||
|
||||
If you look at your hand, you can count that you have five fingers.
|
||||
</div>
|
||||
</solution>
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.</p>
|
||||
|
||||
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
|
||||
|
||||
<p>Enter the numerical value of Pi:</p>
|
||||
<numericalresponse answer="3.14159 ">
|
||||
<responseparam type="tolerance" default=".02" />
|
||||
<textline />
|
||||
</numericalresponse>
|
||||
|
||||
<p>Enter the approximate value of 502*9:</p>
|
||||
<numericalresponse answer="4518 ">
|
||||
<responseparam type="tolerance" default="15%" />
|
||||
<textline />
|
||||
</numericalresponse>
|
||||
|
||||
<p>Enter the number of fingers on a human hand:</p>
|
||||
<numericalresponse answer="5">
|
||||
<textline />
|
||||
</numericalresponse>
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
<p>Explanation</p>
|
||||
|
||||
<p>Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.</p>
|
||||
|
||||
<p>Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.</p>
|
||||
|
||||
<p>If you look at your hand, you can count that you have five fingers.</p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
it 'converts multiple choice to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
|
||||
|
||||
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
|
||||
|
||||
What Apple device competed with the portable CD player?
|
||||
( ) The iPad
|
||||
( ) Napster
|
||||
(x) The iPod
|
||||
( ) The vegetable peeler
|
||||
( ) Android
|
||||
( ) The Beatles
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
Explanation
|
||||
|
||||
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
|
||||
</div>
|
||||
</solution>
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.</p>
|
||||
|
||||
<p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p>
|
||||
|
||||
<p>What Apple device competed with the portable CD player?</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">The iPad</choice>
|
||||
<choice correct="false">Napster</choice>
|
||||
<choice correct="true">The iPod</choice>
|
||||
<choice correct="false">The vegetable peeler</choice>
|
||||
<choice correct="false">Android</choice>
|
||||
<choice correct="false">The Beatles</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
<p>Explanation</p>
|
||||
|
||||
<p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
it 'converts OptionResponse to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
|
||||
|
||||
The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.
|
||||
|
||||
Translation between Option Response and __________ is extremely straightforward:
|
||||
[[(Multiple Choice), String Response, Numerical Response, External Response, Image Response]]
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
Explanation
|
||||
|
||||
Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.
|
||||
</div>
|
||||
</solution>
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.</p>
|
||||
|
||||
<p>The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.</p>
|
||||
|
||||
<p>Translation between Option Response and __________ is extremely straightforward:</p>
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('Multiple Choice','String Response','Numerical Response','External Response','Image Response')" correct="Multiple Choice"></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
<p>Explanation</p>
|
||||
|
||||
<p>Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.</p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
it 'converts OptionResponse to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.
|
||||
|
||||
The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.
|
||||
|
||||
Which US state has Lansing as its capital?
|
||||
= Michigan
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
Explanation
|
||||
|
||||
Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.
|
||||
|
||||
</div>
|
||||
</solution>
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.</p>
|
||||
|
||||
<p>The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.</p>
|
||||
|
||||
<p>Which US state has Lansing as its capital?</p>
|
||||
<stringresponse answer="Michigan" type="ci">
|
||||
<textline size="20"/>
|
||||
</stringresponse>
|
||||
|
||||
<solution>
|
||||
<div class='detailed-solution'>
|
||||
<p>Explanation</p>
|
||||
|
||||
<p>Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.</p>
|
||||
|
||||
</div>
|
||||
</solution>
|
||||
</problem>""")
|
||||
# test oddities
|
||||
it 'converts headers and oddities to xml', ->
|
||||
data = MarkdownEditingDescriptor.markdownToXml("""Not a header
|
||||
A header
|
||||
==============
|
||||
|
||||
Multiple choice w/ parentheticals
|
||||
( ) option (with parens)
|
||||
( ) xd option (x)
|
||||
()) parentheses inside
|
||||
() no space b4 close paren
|
||||
|
||||
Choice checks
|
||||
[ ] option1 [x]
|
||||
[x] correct
|
||||
[x] redundant
|
||||
[(] distractor
|
||||
[] no space
|
||||
|
||||
{{video abcd1s}}
|
||||
|
||||
Option with multiple correct ones
|
||||
[[one option, (correct one), (should not be correct)]]
|
||||
|
||||
Option with embedded parens
|
||||
[[My (heart), another, (correct)]]
|
||||
|
||||
What happens w/ empty correct options?
|
||||
[[()]]
|
||||
|
||||
No p tags in the below
|
||||
<script type='javascript'>
|
||||
var two = 2;
|
||||
|
||||
console.log(two * 2);
|
||||
</script>
|
||||
|
||||
But in this there should be
|
||||
<div>
|
||||
Great ideas require offsetting.
|
||||
|
||||
bad tests require drivel
|
||||
</div>
|
||||
""")
|
||||
expect(data).toEqual("""<problem>
|
||||
<p>Not a header</p>
|
||||
<h1>A header</h1>
|
||||
|
||||
<p>Multiple choice w/ parentheticals</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">option (with parens)</choice>
|
||||
<choice correct="false">xd option (x)</choice>
|
||||
<choice correct="false">parentheses inside</choice>
|
||||
<choice correct="false">no space b4 close paren</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<p>Choice checks</p>
|
||||
<multiplechoiceresponse>
|
||||
<choicegroup type="MultipleChoiceChecks">
|
||||
<choice correct="false">option1 [x]</choice>
|
||||
<choice correct="true">correct</choice>
|
||||
<choice correct="true">redundant</choice>
|
||||
<choice correct="false">distractor</choice>
|
||||
<choice correct="false">no space</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<video youtube="1.0:abcd1s" />
|
||||
|
||||
<p>Option with multiple correct ones</p>
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('one option','correct one','should not be correct')" correct="correct one"></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
<p>Option with embedded parens</p>
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('My (heart)','another','correct')" correct="correct"></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
<p>What happens w/ empty correct options?</p>
|
||||
|
||||
<optionresponse>
|
||||
<optioninput options="('')" correct=""></optioninput>
|
||||
</optionresponse>
|
||||
|
||||
<p>No p tags in the below</p>
|
||||
<script type='javascript'>
|
||||
var two = 2;
|
||||
|
||||
console.log(two * 2);
|
||||
</script>
|
||||
|
||||
<p>But in this there should be</p>
|
||||
<div>
|
||||
<p>Great ideas require offsetting.</p>
|
||||
|
||||
<p>bad tests require drivel</p>
|
||||
</div>
|
||||
</problem>""")
|
||||
# failure tests
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'Sequence', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'Sequence', ->
|
||||
beforeEach ->
|
||||
# Stub MathJax
|
||||
window.MathJax = { Hub: { Queue: -> } }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'VideoCaption', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoCaption', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.subtitles').remove()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'VideoControl', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoControl', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.video-controls').html ''
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'VideoPlayer', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoPlayer', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @, [], false
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'VideoProgressSlider', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoProgressSlider', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'VideoSpeedControl', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoSpeedControl', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.speeds').remove()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'VideoVolumeControl', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoVolumeControl', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.volume').remove()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
describe 'Video', ->
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'Video', ->
|
||||
beforeEach ->
|
||||
loadFixtures 'video.html'
|
||||
jasmine.stubRequests()
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1953,7 +1953,7 @@ cktsim = (function() {
|
||||
var module = {
|
||||
'Circuit': Circuit,
|
||||
'parse_number': parse_number,
|
||||
'parse_source': parse_source,
|
||||
'parse_source': parse_source
|
||||
}
|
||||
return module;
|
||||
}());
|
||||
@@ -2068,7 +2068,7 @@ schematic = (function() {
|
||||
'n': [NFet, 'NFet'],
|
||||
'p': [PFet, 'PFet'],
|
||||
's': [Probe, 'Voltage Probe'],
|
||||
'a': [Ammeter, 'Current Probe'],
|
||||
'a': [Ammeter, 'Current Probe']
|
||||
};
|
||||
|
||||
// global clipboard
|
||||
@@ -5502,7 +5502,7 @@ schematic = (function() {
|
||||
'magenta' : 'rgb(255,64,255)',
|
||||
'yellow': 'rgb(255,255,64)',
|
||||
'black': 'rgb(0,0,0)',
|
||||
'x-axis': undefined,
|
||||
'x-axis': undefined
|
||||
};
|
||||
|
||||
function Probe(x,y,rotation,color,offset) {
|
||||
@@ -6100,7 +6100,7 @@ schematic = (function() {
|
||||
'Amplitude',
|
||||
'Frequency (Hz)',
|
||||
'Delay until sin starts (secs)',
|
||||
'Phase offset (degrees)'],
|
||||
'Phase offset (degrees)']
|
||||
}
|
||||
|
||||
// build property editor div
|
||||
@@ -6300,7 +6300,7 @@ schematic = (function() {
|
||||
|
||||
var module = {
|
||||
'Schematic': Schematic,
|
||||
'component_slider': component_slider,
|
||||
'component_slider': component_slider
|
||||
}
|
||||
return module;
|
||||
}());
|
||||
|
||||
289
common/lib/xmodule/xmodule/js/src/problem/edit.coffee
Normal file
289
common/lib/xmodule/xmodule/js/src/problem/edit.coffee
Normal file
@@ -0,0 +1,289 @@
|
||||
class @MarkdownEditingDescriptor extends XModule.Descriptor
|
||||
@multipleChoiceTemplate : "( ) incorrect\n( ) incorrect\n(x) correct\n"
|
||||
@checkboxChoiceTemplate: "[x] correct\n[ ] incorrect\n[x] correct\n"
|
||||
@stringInputTemplate: "= answer\n"
|
||||
@numberInputTemplate: "= answer +- x%\n"
|
||||
@selectTemplate: "[[incorrect, (correct), incorrect]]\n"
|
||||
|
||||
constructor: (element) ->
|
||||
@element = element
|
||||
|
||||
if $(".markdown-box", @element).length != 0
|
||||
@markdown_editor = CodeMirror.fromTextArea($(".markdown-box", element)[0], {
|
||||
lineWrapping: true
|
||||
mode: null
|
||||
})
|
||||
@setCurrentEditor(@markdown_editor)
|
||||
# Add listeners for toolbar buttons (only present for markdown editor)
|
||||
@element.on('click', '.xml-tab', @onShowXMLButton)
|
||||
@element.on('click', '.format-buttons a', @onToolbarButton)
|
||||
@element.on('click', '.cheatsheet-toggle', @toggleCheatsheet)
|
||||
# Hide the XML text area
|
||||
$(@element.find('.xml-box')).hide()
|
||||
else
|
||||
@createXMLEditor()
|
||||
|
||||
###
|
||||
Creates the XML Editor and sets it as the current editor. If text is passed in,
|
||||
it will replace the text present in the HTML template.
|
||||
|
||||
text: optional argument to override the text passed in via the HTML template
|
||||
###
|
||||
createXMLEditor: (text) ->
|
||||
@xml_editor = CodeMirror.fromTextArea($(".xml-box", @element)[0], {
|
||||
mode: "xml"
|
||||
lineNumbers: true
|
||||
lineWrapping: true
|
||||
})
|
||||
if text
|
||||
@xml_editor.setValue(text)
|
||||
@setCurrentEditor(@xml_editor)
|
||||
|
||||
###
|
||||
User has clicked to show the XML editor. Before XML editor is swapped in,
|
||||
the user will need to confirm the one-way conversion.
|
||||
###
|
||||
onShowXMLButton: (e) =>
|
||||
e.preventDefault();
|
||||
if @confirmConversionToXml()
|
||||
@createXMLEditor(MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()))
|
||||
# Need to refresh to get line numbers to display properly (and put cursor position to 0)
|
||||
@xml_editor.setCursor(0)
|
||||
@xml_editor.refresh()
|
||||
# Hide markdown-specific toolbar buttons
|
||||
$(@element.find('.editor-bar')).hide()
|
||||
|
||||
###
|
||||
Have the user confirm the one-way conversion to XML.
|
||||
Returns true if the user clicked OK, else false.
|
||||
###
|
||||
confirmConversionToXml: ->
|
||||
# TODO: use something besides a JavaScript confirm dialog?
|
||||
return confirm("If you convert to the XML source representation, which is used by the Advanced Editor, you cannot go back to using the Simple Editor.\n\nProceed with conversion to XML?")
|
||||
|
||||
###
|
||||
Event listener for toolbar buttons (only possible when markdown editor is visible).
|
||||
###
|
||||
onToolbarButton: (e) =>
|
||||
e.preventDefault();
|
||||
selection = @markdown_editor.getSelection()
|
||||
revisedSelection = null
|
||||
switch $(e.currentTarget).attr('class')
|
||||
when "multiple-choice-button" then revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(selection)
|
||||
when "string-button" then revisedSelection = MarkdownEditingDescriptor.insertStringInput(selection)
|
||||
when "number-button" then revisedSelection = MarkdownEditingDescriptor.insertNumberInput(selection)
|
||||
when "checks-button" then revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice(selection)
|
||||
when "dropdown-button" then revisedSelection = MarkdownEditingDescriptor.insertSelect(selection)
|
||||
else # ignore click
|
||||
|
||||
if revisedSelection != null
|
||||
@markdown_editor.replaceSelection(revisedSelection)
|
||||
@markdown_editor.focus()
|
||||
|
||||
###
|
||||
Event listener for toggling cheatsheet (only possible when markdown editor is visible).
|
||||
###
|
||||
toggleCheatsheet: (e) =>
|
||||
e.preventDefault();
|
||||
if !$(@markdown_editor.getWrapperElement()).find('.simple-editor-cheatsheet')[0]
|
||||
@cheatsheet = $($('#simple-editor-cheatsheet').html())
|
||||
$(@markdown_editor.getWrapperElement()).append(@cheatsheet)
|
||||
|
||||
setTimeout (=> @cheatsheet.toggleClass('shown')), 10
|
||||
|
||||
###
|
||||
Stores the current editor and hides the one that is not displayed.
|
||||
###
|
||||
setCurrentEditor: (editor) ->
|
||||
if @current_editor
|
||||
$(@current_editor.getWrapperElement()).hide()
|
||||
@current_editor = editor
|
||||
$(@current_editor.getWrapperElement()).show()
|
||||
$(@current_editor).focus();
|
||||
|
||||
###
|
||||
Called when save is called. Listeners are unregistered because editing the block again will
|
||||
result in a new instance of the descriptor. Note that this is NOT the case for cancel--
|
||||
when cancel is called the instance of the descriptor is reused if edit is selected again.
|
||||
###
|
||||
save: ->
|
||||
@element.off('click', '.xml-tab', @changeEditor)
|
||||
@element.off('click', '.format-buttons a', @onToolbarButton)
|
||||
@element.off('click', '.cheatsheet-toggle', @toggleCheatsheet)
|
||||
if @current_editor == @markdown_editor
|
||||
{
|
||||
data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue())
|
||||
metadata:
|
||||
markdown: @markdown_editor.getValue()
|
||||
}
|
||||
else
|
||||
{
|
||||
data: @xml_editor.getValue()
|
||||
metadata:
|
||||
markdown: null
|
||||
}
|
||||
|
||||
@insertMultipleChoice: (selectedText) ->
|
||||
return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '(', ')', MarkdownEditingDescriptor.multipleChoiceTemplate)
|
||||
|
||||
@insertCheckboxChoice: (selectedText) ->
|
||||
return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '[', ']', MarkdownEditingDescriptor.checkboxChoiceTemplate)
|
||||
|
||||
@insertGenericChoice: (selectedText, choiceStart, choiceEnd, template) ->
|
||||
if selectedText.length > 0
|
||||
# Replace adjacent newlines with a single newline, strip any trailing newline
|
||||
cleanSelectedText = selectedText.replace(/\n+/g, '\n').replace(/\n$/,'')
|
||||
lines = cleanSelectedText.split('\n')
|
||||
revisedLines = ''
|
||||
for line in lines
|
||||
revisedLines += choiceStart
|
||||
# a stand alone x before other text implies that this option is "correct"
|
||||
if /^\s*x\s+(\S)/i.test(line)
|
||||
# Remove the x and any initial whitespace as long as there's more text on the line
|
||||
line = line.replace(/^\s*x\s+(\S)/i, '$1')
|
||||
revisedLines += 'x'
|
||||
else
|
||||
revisedLines += ' '
|
||||
revisedLines += choiceEnd + ' ' + line + '\n'
|
||||
return revisedLines
|
||||
else
|
||||
return template
|
||||
|
||||
@insertStringInput: (selectedText) ->
|
||||
return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.stringInputTemplate)
|
||||
|
||||
@insertNumberInput: (selectedText) ->
|
||||
return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.numberInputTemplate)
|
||||
|
||||
@insertSelect: (selectedText) ->
|
||||
return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[[', ']]', MarkdownEditingDescriptor.selectTemplate)
|
||||
|
||||
@insertGenericInput: (selectedText, lineStart, lineEnd, template) ->
|
||||
if selectedText.length > 0
|
||||
# TODO: should this insert a newline afterwards?
|
||||
return lineStart + selectedText + lineEnd
|
||||
else
|
||||
return template
|
||||
|
||||
# We may wish to add insertHeader and insertVideo. Here is Tom's code.
|
||||
# function makeHeader() {
|
||||
# var selection = simpleEditor.getSelection();
|
||||
# var revisedSelection = selection + '\n';
|
||||
# for(var i = 0; i < selection.length; i++) {
|
||||
#revisedSelection += '=';
|
||||
# }
|
||||
# simpleEditor.replaceSelection(revisedSelection);
|
||||
#}
|
||||
#
|
||||
#function makeVideo() {
|
||||
#var selection = simpleEditor.getSelection();
|
||||
#simpleEditor.replaceSelection('{{video ' + selection + '}}');
|
||||
#}
|
||||
#
|
||||
@markdownToXml: (markdown)->
|
||||
toXml = `function(markdown) {
|
||||
var xml = markdown;
|
||||
|
||||
// replace headers
|
||||
xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '<h1>$1</h1>');
|
||||
xml = xml.replace(/\n^\=\=+$/gm, '');
|
||||
|
||||
// group multiple choice answers
|
||||
xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function(match, p) {
|
||||
var groupString = '<multiplechoiceresponse>\n';
|
||||
groupString += ' <choicegroup type="MultipleChoice">\n';
|
||||
var options = match.split('\n');
|
||||
for(var i = 0; i < options.length; i++) {
|
||||
if(options[i].length > 0) {
|
||||
var value = options[i].split(/^\s*\(.?\)\s*/)[1];
|
||||
var correct = /^\s*\(x\)/i.test(options[i]);
|
||||
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n';
|
||||
}
|
||||
}
|
||||
groupString += ' </choicegroup>\n';
|
||||
groupString += '</multiplechoiceresponse>\n\n';
|
||||
return groupString;
|
||||
});
|
||||
|
||||
// group check answers
|
||||
xml = xml.replace(/(^\s*\[.?\].*?$\n*)+/gm, function(match, p) {
|
||||
var groupString = '<multiplechoiceresponse>\n';
|
||||
groupString += ' <choicegroup type="MultipleChoiceChecks">\n';
|
||||
var options = match.split('\n');
|
||||
for(var i = 0; i < options.length; i++) {
|
||||
if(options[i].length > 0) {
|
||||
var value = options[i].split(/^\s*\[.?\]\s*/)[1];
|
||||
var correct = /^\s*\[x\]/i.test(options[i]);
|
||||
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n';
|
||||
}
|
||||
}
|
||||
groupString += ' </choicegroup>\n';
|
||||
groupString += '</multiplechoiceresponse>\n\n';
|
||||
return groupString;
|
||||
});
|
||||
|
||||
// replace videos
|
||||
xml = xml.replace(/\{\{video\s(.*?)\}\}/g, '<video youtube="1.0:$1" />\n\n');
|
||||
|
||||
// replace string and numerical
|
||||
xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) {
|
||||
var string;
|
||||
var params = /(.*?)\+\-\s*(.*?$)/.exec(p);
|
||||
if(parseFloat(p)) {
|
||||
if(params) {
|
||||
string = '<numericalresponse answer="' + params[1] + '">\n';
|
||||
string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n';
|
||||
} else {
|
||||
string = '<numericalresponse answer="' + p + '">\n';
|
||||
}
|
||||
string += ' <textline />\n';
|
||||
string += '</numericalresponse>\n\n';
|
||||
} else {
|
||||
string = '<stringresponse answer="' + p + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n';
|
||||
}
|
||||
return string;
|
||||
});
|
||||
|
||||
// replace selects
|
||||
xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) {
|
||||
var selectString = '\n<optionresponse>\n';
|
||||
selectString += ' <optioninput options="(';
|
||||
var options = p.split(/\,\s*/g);
|
||||
for(var i = 0; i < options.length; i++) {
|
||||
selectString += "'" + options[i].replace(/(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g, '$1') + "'" + (i < options.length -1 ? ',' : '');
|
||||
}
|
||||
selectString += ')" correct="';
|
||||
var correct = /(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g.exec(p);
|
||||
if (correct) selectString += correct[1];
|
||||
selectString += '"></optioninput>\n';
|
||||
selectString += '</optionresponse>\n\n';
|
||||
return selectString;
|
||||
});
|
||||
|
||||
// split scripts and wrap paragraphs
|
||||
var splits = xml.split(/(\<\/?script.*?\>)/g);
|
||||
var scriptFlag = false;
|
||||
for(var i = 0; i < splits.length; i++) {
|
||||
if(/\<script/.test(splits[i])) {
|
||||
scriptFlag = true;
|
||||
}
|
||||
if(!scriptFlag) {
|
||||
splits[i] = splits[i].replace(/(^(?!\s*\<|$).*$)/gm, '<p>$1</p>');
|
||||
}
|
||||
if(/\<\/script/.test(splits[i])) {
|
||||
scriptFlag = false;
|
||||
}
|
||||
}
|
||||
xml = splits.join('');
|
||||
|
||||
// rid white space
|
||||
xml = xml.replace(/\n\n\n/g, '\n');
|
||||
|
||||
// surround w/ problem tag
|
||||
xml = '<problem>\n' + xml + '\n</problem>';
|
||||
|
||||
return xml;
|
||||
}
|
||||
`
|
||||
return toXml markdown
|
||||
|
||||
@@ -120,7 +120,7 @@ class @SelfAssessment
|
||||
if @state == 'done'
|
||||
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
|
||||
if response.success
|
||||
@answer_area.html('')
|
||||
@answer_area.val('')
|
||||
@rubric_wrapper.html('')
|
||||
@hint_wrapper.html('')
|
||||
@message_wrapper.html('')
|
||||
|
||||
@@ -2,6 +2,8 @@ class @Video
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('.video')
|
||||
@id = @el.attr('id').replace(/video_/, '')
|
||||
@start = @el.data('start')
|
||||
@end = @el.data('end')
|
||||
@caption_data_dir = @el.data('caption-data-dir')
|
||||
@caption_asset_path = @el.data('caption-asset-path')
|
||||
@show_captions = @el.data('show-captions') == "true"
|
||||
|
||||
@@ -36,14 +36,21 @@ class @VideoPlayer extends Subview
|
||||
@volumeControl = new VideoVolumeControl el: @$('.secondary-controls')
|
||||
@speedControl = new VideoSpeedControl el: @$('.secondary-controls'), speeds: @video.speeds, currentSpeed: @currentSpeed()
|
||||
@progressSlider = new VideoProgressSlider el: @$('.slider')
|
||||
@playerVars =
|
||||
controls: 0
|
||||
wmode: 'transparent'
|
||||
rel: 0
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
if @video.start
|
||||
@playerVars.start = @video.start
|
||||
if @video.end
|
||||
# work in AS3, not HMLT5. but iframe use AS3
|
||||
@playerVars.end = @video.end
|
||||
|
||||
@player = new YT.Player @video.id,
|
||||
playerVars:
|
||||
controls: 0
|
||||
wmode: 'transparent'
|
||||
rel: 0
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
playerVars: @playerVars
|
||||
videoId: @video.youtubeId()
|
||||
events:
|
||||
onReady: @onReady
|
||||
|
||||
@@ -284,7 +284,7 @@ class ModuleStore(object):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_items(self, location, depth=0):
|
||||
def get_items(self, location, course_id=None, depth=0):
|
||||
"""
|
||||
Returns a list of XModuleDescriptor instances for the items
|
||||
that match location. Any element of location that is None is treated
|
||||
@@ -352,6 +352,18 @@ class ModuleStore(object):
|
||||
course_filter = Location("i4x", category="course")
|
||||
return self.get_items(course_filter)
|
||||
|
||||
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_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().
|
||||
@@ -413,3 +425,10 @@ class ModuleStoreBase(ModuleStore):
|
||||
|
||||
errorlog = self._get_errorlog(location)
|
||||
return errorlog.errors
|
||||
|
||||
def get_course(self, course_id):
|
||||
"""Default impl--linear search through course list"""
|
||||
for c in self.get_courses():
|
||||
if c.id == course_id:
|
||||
return c
|
||||
return None
|
||||
|
||||
@@ -262,7 +262,7 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
"""
|
||||
return self.get_item(location)
|
||||
|
||||
def get_items(self, location, depth=0):
|
||||
def get_items(self, location, course_id=None, depth=0):
|
||||
items = self.collection.find(
|
||||
location_to_query(location),
|
||||
sort=[('revision', pymongo.ASCENDING)],
|
||||
|
||||
@@ -157,7 +157,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
|
||||
make_name_unique(xml_data)
|
||||
|
||||
descriptor = XModuleDescriptor.load_from_xml(
|
||||
etree.tostring(xml_data), self, self.org,
|
||||
etree.tostring(xml_data, encoding='unicode'), self, self.org,
|
||||
self.course, xmlstore.default_class)
|
||||
except Exception as err:
|
||||
print err, self.load_error_modules
|
||||
@@ -419,7 +419,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
self.load_error_modules,
|
||||
)
|
||||
|
||||
course_descriptor = system.process_xml(etree.tostring(course_data))
|
||||
course_descriptor = system.process_xml(etree.tostring(course_data, encoding='unicode'))
|
||||
|
||||
# NOTE: The descriptors end up loading somewhat bottom up, which
|
||||
# breaks metadata inheritance via get_children(). Instead
|
||||
@@ -513,6 +513,24 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
raise NotImplementedError("XMLModuleStores can't guarantee that definitions"
|
||||
" are unique. Use get_instance.")
|
||||
|
||||
def get_items(self, location, course_id=None, depth=0):
|
||||
items = []
|
||||
|
||||
def _add_get_items(self, location, modules):
|
||||
for mod_loc, module in modules.iteritems():
|
||||
# Locations match if each value in `location` is None or if the value from `location`
|
||||
# matches the value from `mod_loc`
|
||||
if all(goal is None or goal == value for goal, value in zip(location, mod_loc)):
|
||||
items.append(module)
|
||||
|
||||
if course_id is None:
|
||||
for _, modules in self.modules.iteritems():
|
||||
_add_get_items(self, location, modules)
|
||||
else:
|
||||
_add_get_items(self, location, self.modules[course_id])
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def get_courses(self, depth=0):
|
||||
"""
|
||||
|
||||
@@ -120,47 +120,16 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
course_data_path = None
|
||||
course_location = None
|
||||
# Quick scan to get course Location as well as the course_data_path
|
||||
|
||||
# Quick scan to get course module as we need some info from there. Also we need to make sure that the
|
||||
# course module is committed first into the store
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
if module.category == 'course':
|
||||
course_data_path = path(data_dir) / module.metadata['data_dir']
|
||||
course_location = module.location
|
||||
|
||||
if static_content_store is not None:
|
||||
_namespace_rename = target_location_namespace if target_location_namespace is not None else module_store.modules[course_id].location
|
||||
|
||||
# first pass to find everything in /static/
|
||||
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
|
||||
_namespace_rename, subpath='static')
|
||||
module = remap_namespace(module, target_location_namespace)
|
||||
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
# This looks a bit wonky as we need to also change the 'name' of the imported course to be what
|
||||
# the caller passed in
|
||||
if module.location.category != 'course':
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
else:
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course, name=target_location_namespace.name)
|
||||
|
||||
# then remap children pointers since they too will be re-namespaced
|
||||
children_locs = module.definition.get('children')
|
||||
if children_locs is not None:
|
||||
new_locs = []
|
||||
for child in children_locs:
|
||||
child_loc = Location(child)
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
|
||||
new_locs.append(new_child_loc.url())
|
||||
|
||||
module.definition['children'] = new_locs
|
||||
|
||||
|
||||
if module.category == 'course':
|
||||
# HACK: for now we don't support progress tabs. There's a special metadata configuration setting for this.
|
||||
module.metadata['hide_progress_tab'] = True
|
||||
|
||||
@@ -174,12 +143,41 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
|
||||
|
||||
|
||||
store.update_item(module.location, module.definition['data'])
|
||||
if 'children' in module.definition:
|
||||
store.update_children(module.location, module.definition['children'])
|
||||
store.update_metadata(module.location, dict(module.own_metadata))
|
||||
|
||||
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
|
||||
# so let's make sure we import in case there are no other references to it in the modules
|
||||
verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
|
||||
|
||||
course_items.append(module)
|
||||
|
||||
|
||||
|
||||
# then import all the static content
|
||||
if static_content_store is not None:
|
||||
_namespace_rename = target_location_namespace if target_location_namespace is not None else module_store.modules[course_id].location
|
||||
|
||||
# first pass to find everything in /static/
|
||||
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
|
||||
_namespace_rename, subpath='static')
|
||||
|
||||
# finally loop through all the modules
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
|
||||
if module.category == 'course':
|
||||
# we've already saved the course module up at the top of the loop
|
||||
# so just skip over it in the inner loop
|
||||
continue
|
||||
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
module = remap_namespace(module, target_location_namespace)
|
||||
|
||||
|
||||
if 'data' in module.definition:
|
||||
module_data = module.definition['data']
|
||||
|
||||
@@ -216,6 +214,33 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
return module_store, course_items
|
||||
|
||||
def remap_namespace(module, target_location_namespace):
|
||||
if target_location_namespace is None:
|
||||
return module
|
||||
|
||||
# This looks a bit wonky as we need to also change the 'name' of the imported course to be what
|
||||
# the caller passed in
|
||||
if module.location.category != 'course':
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
else:
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course, name=target_location_namespace.name)
|
||||
|
||||
# then remap children pointers since they too will be re-namespaced
|
||||
children_locs = module.definition.get('children')
|
||||
if children_locs is not None:
|
||||
new_locs = []
|
||||
for child in children_locs:
|
||||
child_loc = Location(child)
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
|
||||
new_locs.append(new_child_loc.url())
|
||||
|
||||
module.definition['children'] = new_locs
|
||||
|
||||
return module
|
||||
|
||||
def validate_category_hierarcy(module_store, course_id, parent_category, expected_child_category):
|
||||
err_cnt = 0
|
||||
|
||||
@@ -13,7 +13,7 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
"""
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
return {'data': etree.tostring(xml_object, pretty_print=True)}
|
||||
return {'data': etree.tostring(xml_object, pretty_print=True,encoding='unicode')}
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
try:
|
||||
|
||||
@@ -7,20 +7,21 @@ Parses xml definition file--see below for exact format.
|
||||
|
||||
import copy
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from path import path
|
||||
import json
|
||||
from progress import Progress
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .capa_module import only_one, ComplexEncoder
|
||||
from .editing_module import EditingDescriptor
|
||||
from .html_checker import check_html
|
||||
from progress import Progress
|
||||
from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
@@ -52,6 +53,8 @@ class SelfAssessmentModule(XModule):
|
||||
submissions too.)
|
||||
"""
|
||||
|
||||
STATE_VERSION = 1
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
@@ -102,35 +105,130 @@ class SelfAssessmentModule(XModule):
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
# Note: score responses are on scale from 0 to max_score
|
||||
self.student_answers = instance_state.get('student_answers', [])
|
||||
self.scores = instance_state.get('scores', [])
|
||||
self.hints = instance_state.get('hints', [])
|
||||
instance_state = self.convert_state_to_current_format(instance_state)
|
||||
|
||||
# History is a list of tuples of (answer, score, hint), where hint may be
|
||||
# None for any element, and score and hint can be None for the last (current)
|
||||
# element.
|
||||
# Scores are on scale from 0 to max_score
|
||||
self.history = instance_state.get('history', [])
|
||||
|
||||
self.state = instance_state.get('state', 'initial')
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
|
||||
self.rubric = definition['rubric']
|
||||
self.prompt = definition['prompt']
|
||||
self.submit_message = definition['submitmessage']
|
||||
self.hint_prompt = definition['hintprompt']
|
||||
|
||||
|
||||
def latest_answer(self):
|
||||
"""None if not available"""
|
||||
if not self.history:
|
||||
return None
|
||||
return self.history[-1].get('answer')
|
||||
|
||||
def latest_score(self):
|
||||
"""None if not available"""
|
||||
if not self.history:
|
||||
return None
|
||||
return self.history[-1].get('score')
|
||||
|
||||
def latest_hint(self):
|
||||
"""None if not available"""
|
||||
if not self.history:
|
||||
return None
|
||||
return self.history[-1].get('hint')
|
||||
|
||||
def new_history_entry(self, answer):
|
||||
self.history.append({'answer': answer})
|
||||
|
||||
def record_latest_score(self, score):
|
||||
"""Assumes that state is right, so we're adding a score to the latest
|
||||
history element"""
|
||||
self.history[-1]['score'] = score
|
||||
|
||||
def record_latest_hint(self, hint):
|
||||
"""Assumes that state is right, so we're adding a score to the latest
|
||||
history element"""
|
||||
self.history[-1]['hint'] = hint
|
||||
|
||||
|
||||
def change_state(self, new_state):
|
||||
"""
|
||||
A centralized place for state changes--allows for hooks. If the
|
||||
current state matches the old state, don't run any hooks.
|
||||
"""
|
||||
if self.state == new_state:
|
||||
return
|
||||
|
||||
self.state = new_state
|
||||
|
||||
if self.state == self.DONE:
|
||||
self.attempts += 1
|
||||
|
||||
@staticmethod
|
||||
def convert_state_to_current_format(old_state):
|
||||
"""
|
||||
This module used to use a problematic state representation. This method
|
||||
converts that into the new format.
|
||||
|
||||
Args:
|
||||
old_state: dict of state, as passed in. May be old.
|
||||
|
||||
Returns:
|
||||
new_state: dict of new state
|
||||
"""
|
||||
if old_state.get('version', 0) == SelfAssessmentModule.STATE_VERSION:
|
||||
# already current
|
||||
return old_state
|
||||
|
||||
# for now, there's only one older format.
|
||||
|
||||
new_state = {'version': SelfAssessmentModule.STATE_VERSION}
|
||||
|
||||
def copy_if_present(key):
|
||||
if key in old_state:
|
||||
new_state[key] = old_state[key]
|
||||
|
||||
for to_copy in ['attempts', 'state']:
|
||||
copy_if_present(to_copy)
|
||||
|
||||
# The answers, scores, and hints need to be kept together to avoid them
|
||||
# getting out of sync.
|
||||
|
||||
# NOTE: Since there's only one problem with a few hundred submissions
|
||||
# in production so far, not trying to be smart about matching up hints
|
||||
# and submissions in cases where they got out of sync.
|
||||
|
||||
student_answers = old_state.get('student_answers', [])
|
||||
scores = old_state.get('scores', [])
|
||||
hints = old_state.get('hints', [])
|
||||
|
||||
new_state['history'] = [
|
||||
{'answer': answer,
|
||||
'score': score,
|
||||
'hint': hint}
|
||||
for answer, score, hint in itertools.izip_longest(
|
||||
student_answers, scores, hints)]
|
||||
return new_state
|
||||
|
||||
|
||||
def _allow_reset(self):
|
||||
"""Can the module be reset?"""
|
||||
return self.state == self.DONE and self.attempts < self.max_attempts
|
||||
|
||||
def get_html(self):
|
||||
#set context variables and render template
|
||||
if self.state != self.INITIAL and self.student_answers:
|
||||
previous_answer = self.student_answers[-1]
|
||||
if self.state != self.INITIAL:
|
||||
latest = self.latest_answer()
|
||||
previous_answer = latest if latest is not None else ''
|
||||
else:
|
||||
previous_answer = ''
|
||||
|
||||
@@ -149,26 +247,19 @@ class SelfAssessmentModule(XModule):
|
||||
# cdodge: perform link substitutions for any references to course static content (e.g. images)
|
||||
return rewrite_links(html, self.rewrite_content_links)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Returns dict with 'score' key
|
||||
"""
|
||||
return {'score': self.get_last_score()}
|
||||
|
||||
def max_score(self):
|
||||
"""
|
||||
Return max_score
|
||||
"""
|
||||
return self._max_score
|
||||
|
||||
def get_last_score(self):
|
||||
def get_score(self):
|
||||
"""
|
||||
Returns the last score in the list
|
||||
"""
|
||||
last_score=0
|
||||
if(len(self.scores)>0):
|
||||
last_score=self.scores[len(self.scores)-1]
|
||||
return last_score
|
||||
score = self.latest_score()
|
||||
return {'score': score if score is not None else 0,
|
||||
'total': self._max_score}
|
||||
|
||||
def get_progress(self):
|
||||
'''
|
||||
@@ -176,7 +267,7 @@ class SelfAssessmentModule(XModule):
|
||||
'''
|
||||
if self._max_score > 0:
|
||||
try:
|
||||
return Progress(self.get_last_score(), self._max_score)
|
||||
return Progress(self.get_score()['score'], self._max_score)
|
||||
except Exception as err:
|
||||
log.exception("Got bad progress")
|
||||
return None
|
||||
@@ -250,9 +341,10 @@ class SelfAssessmentModule(XModule):
|
||||
if self.state in (self.INITIAL, self.ASSESSING):
|
||||
return ''
|
||||
|
||||
if self.state == self.DONE and len(self.hints) > 0:
|
||||
if self.state == self.DONE:
|
||||
# display the previous hint
|
||||
hint = self.hints[-1]
|
||||
latest = self.latest_hint()
|
||||
hint = latest if latest is not None else ''
|
||||
else:
|
||||
hint = ''
|
||||
|
||||
@@ -281,6 +373,14 @@ class SelfAssessmentModule(XModule):
|
||||
def save_answer(self, get):
|
||||
"""
|
||||
After the answer is submitted, show the rubric.
|
||||
|
||||
Args:
|
||||
get: the GET dictionary passed to the ajax request. Should contain
|
||||
a key 'student_answer'
|
||||
|
||||
Returns:
|
||||
Dictionary with keys 'success' and either 'error' (if not success),
|
||||
or 'rubric_html' (if success).
|
||||
"""
|
||||
# Check to see if attempts are less than max
|
||||
if self.attempts > self.max_attempts:
|
||||
@@ -295,8 +395,9 @@ class SelfAssessmentModule(XModule):
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
self.student_answers.append(get['student_answer'])
|
||||
self.state = self.ASSESSING
|
||||
# add new history element with answer and empty score and hint.
|
||||
self.new_history_entry(get['student_answer'])
|
||||
self.change_state(self.ASSESSING)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -318,27 +419,24 @@ class SelfAssessmentModule(XModule):
|
||||
'message_html' only if success is true
|
||||
"""
|
||||
|
||||
n_answers = len(self.student_answers)
|
||||
n_scores = len(self.scores)
|
||||
if (self.state != self.ASSESSING or n_answers != n_scores + 1):
|
||||
msg = "%d answers, %d scores" % (n_answers, n_scores)
|
||||
return self.out_of_sync_error(get, msg)
|
||||
if self.state != self.ASSESSING:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
try:
|
||||
score = int(get['assessment'])
|
||||
except:
|
||||
except ValueError:
|
||||
return {'success': False, 'error': "Non-integer score value"}
|
||||
|
||||
self.scores.append(score)
|
||||
self.record_latest_score(score)
|
||||
|
||||
d = {'success': True,}
|
||||
|
||||
if score == self.max_score():
|
||||
self.state = self.DONE
|
||||
self.change_state(self.DONE)
|
||||
d['message_html'] = self.get_message_html()
|
||||
d['allow_reset'] = self._allow_reset()
|
||||
else:
|
||||
self.state = self.REQUEST_HINT
|
||||
self.change_state(self.REQUEST_HINT)
|
||||
d['hint_html'] = self.get_hint_html()
|
||||
|
||||
d['state'] = self.state
|
||||
@@ -360,19 +458,15 @@ class SelfAssessmentModule(XModule):
|
||||
# the same number of hints and answers.
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
self.hints.append(get['hint'].lower())
|
||||
self.state = self.DONE
|
||||
|
||||
# increment attempts
|
||||
self.attempts = self.attempts + 1
|
||||
self.record_latest_hint(get['hint'])
|
||||
self.change_state(self.DONE)
|
||||
|
||||
# To the tracking logs!
|
||||
event_info = {
|
||||
'selfassessment_id': self.location.url(),
|
||||
'state': {
|
||||
'student_answers': self.student_answers,
|
||||
'score': self.scores,
|
||||
'hints': self.hints,
|
||||
'version': self.STATE_VERSION,
|
||||
'history': self.history,
|
||||
}
|
||||
}
|
||||
self.system.track_function('save_hint', event_info)
|
||||
@@ -397,7 +491,7 @@ class SelfAssessmentModule(XModule):
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
self.change_state(self.INITIAL)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@@ -407,12 +501,11 @@ class SelfAssessmentModule(XModule):
|
||||
"""
|
||||
|
||||
state = {
|
||||
'student_answers': self.student_answers,
|
||||
'hints': self.hints,
|
||||
'version': self.STATE_VERSION,
|
||||
'history': self.history,
|
||||
'state': self.state,
|
||||
'scores': self.scores,
|
||||
'max_score': self._max_score,
|
||||
'attempts': self.attempts
|
||||
'attempts': self.attempts,
|
||||
}
|
||||
return json.dumps(state)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from xmodule.progress import Progress
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from pkg_resources import resource_string
|
||||
|
||||
log = logging.getLogger("mitx.common.lib.seq_module")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# HACK: This shouldn't be hard-coded to two types
|
||||
# OBSOLETE: This obsoletes 'type'
|
||||
@@ -126,8 +126,8 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
|
||||
children = []
|
||||
for child in xml_object:
|
||||
try:
|
||||
children.append(system.process_xml(etree.tostring(child)).location.url())
|
||||
except Exception, e:
|
||||
children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url())
|
||||
except Exception as e:
|
||||
log.exception("Unable to load child when parsing Sequence. Continuing...")
|
||||
if system.error_tracker is not None:
|
||||
system.error_tracker("ERROR: " + str(e))
|
||||
|
||||
@@ -22,7 +22,7 @@ def stringify_children(node):
|
||||
# next element.
|
||||
parts = [node.text]
|
||||
for c in node.getchildren():
|
||||
parts.append(etree.tostring(c, with_tail=True))
|
||||
parts.append(etree.tostring(c, with_tail=True, encoding='unicode'))
|
||||
|
||||
# filter removes possible Nones in texts and tails
|
||||
return ''.join(filter(None, parts))
|
||||
return u''.join(filter(None, parts))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user