Merge branch 'master' into jnater/courseware_tests
Conflicts: lms/djangoapps/open_ended_grading/tests.py
This commit is contained in:
4
AUTHORS
4
AUTHORS
@@ -75,4 +75,6 @@ Frances Botsford <frances@edx.org>
|
||||
Jonah Stanley <Jonah_Stanley@brown.edu>
|
||||
Slater Victoroff <slater.r.victoroff@gmail.com>
|
||||
Peter Fogg <peter.p.fogg@gmail.com>
|
||||
Renzo Lucioni <renzolucioni@gmail.com>
|
||||
Bethany LaPenta <lapentab@mit.edu>
|
||||
Renzo Lucioni <renzolucioni@gmail.com>
|
||||
Felix Sun <felixsun@mit.edu>
|
||||
|
||||
@@ -5,16 +5,60 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Remove XML from the video component editor. All settings are
|
||||
moved to be edited as metadata.
|
||||
|
||||
XModule: Only write out assets files if the contents have changed.
|
||||
|
||||
XModule: Don't delete generated xmodule asset files when compiling (for
|
||||
instance, when XModule provides a coffeescript file, don't delete
|
||||
the associated javascript)
|
||||
|
||||
Studio: For courses running on edx.org (marketing site), disable fields in
|
||||
Course Settings that do not apply.
|
||||
|
||||
Common: Make asset watchers run as singletons (so they won't start if the
|
||||
watcher is already running in another shell).
|
||||
|
||||
Common: Use coffee directly when watching for coffeescript file changes.
|
||||
|
||||
Common: Make rake provide better error messages if packages are missing.
|
||||
|
||||
Common: Repairs development documentation generation by sphinx.
|
||||
|
||||
LMS: Problem rescoring. Added options on the Grades tab of the
|
||||
Instructor Dashboard to allow all students' submissions for a
|
||||
particular problem to be rescored. Also supports resetting all
|
||||
students' number of attempts to zero. Provides a list of background
|
||||
tasks that are currently running for the course, and an option to
|
||||
see a history of background tasks for a given problem.
|
||||
|
||||
LMS: Fixed the preferences scope for storing data in xmodules.
|
||||
|
||||
LMS: Forums. Added handling for case where discussion module can get `None` as
|
||||
value of lms.start in `lms/djangoapps/django_comment_client/utils.py`
|
||||
|
||||
Studio, LMS: Make ModelTypes more strict about their expected content (for
|
||||
instance, Boolean, Integer, String), but also allow them to hold either the
|
||||
typed value, or a String that can be converted to their typed value. For example,
|
||||
an Integer can contain 3 or '3'. This changed an update to the xblock library.
|
||||
|
||||
LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django
|
||||
setting now run entirely outside the Python sandbox.
|
||||
|
||||
Blades: Added tests for Video Alpha player.
|
||||
|
||||
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
|
||||
|
||||
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
|
||||
captions.
|
||||
|
||||
CMS: Allow editors to delete uploaded files/assets
|
||||
|
||||
XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the
|
||||
`location` parameter (and added it as a field), and renamed `system` to `runtime`,
|
||||
to accord more closely to `XBlock.__init__`
|
||||
|
||||
LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
|
||||
|
||||
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
|
||||
@@ -39,6 +83,9 @@ Blades: Staff debug info is now accessible for Graphical Slider Tool problems.
|
||||
Blades: For Video Alpha the events ready, play, pause, seek, and speed change
|
||||
are logged on the server (in the logs).
|
||||
|
||||
Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive
|
||||
datetimes.
|
||||
|
||||
Common: Developers can now have private Django settings files.
|
||||
|
||||
Common: Safety code added to prevent anything above the vertical level in the
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -4,3 +4,4 @@ gem 'sass', '3.1.15'
|
||||
gem 'bourbon', '~> 1.3.6'
|
||||
gem 'colorize', '~> 0.5.8'
|
||||
gem 'launchy', '~> 2.1.2'
|
||||
gem 'sys-proctable', '~> 0.9.3'
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
Instructions
|
||||
============
|
||||
For each pull request, add one or more lines to the bottom of the change list. When
|
||||
code is released to production, change the `Upcoming` entry to todays date, and add
|
||||
a new block at the bottom of the file.
|
||||
|
||||
Upcoming
|
||||
--------
|
||||
|
||||
Change log entries should be targeted at end users. A good place to start is the
|
||||
user story that instigated the pull request.
|
||||
|
||||
|
||||
Changes
|
||||
=======
|
||||
|
||||
Upcoming
|
||||
--------
|
||||
* Fix: Deleting last component in a unit does not work
|
||||
* Fix: Unit name is editable when a unit is public
|
||||
* Fix: Visual feedback inconsistent when saving a unit name change
|
||||
@@ -39,8 +39,6 @@ def get_users_in_course_group_by_role(location, role):
|
||||
'''
|
||||
Create all permission groups for a new course and subscribe the caller into those roles
|
||||
'''
|
||||
|
||||
|
||||
def create_all_course_groups(creator, location):
|
||||
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
|
||||
create_new_course_group(creator, location, STAFF_ROLE_NAME)
|
||||
@@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role):
|
||||
|
||||
return
|
||||
|
||||
'''
|
||||
This is to be called only by either a command line code path or through a app which has already
|
||||
asserted permissions
|
||||
'''
|
||||
|
||||
|
||||
def _delete_course_group(location):
|
||||
'''
|
||||
This is to be called only by either a command line code path or through a app which has already
|
||||
asserted permissions
|
||||
'''
|
||||
# remove all memberships
|
||||
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
|
||||
for user in instructors.user_set.all():
|
||||
@@ -75,13 +71,11 @@ def _delete_course_group(location):
|
||||
user.groups.remove(staff)
|
||||
user.save()
|
||||
|
||||
'''
|
||||
This is to be called only by either a command line code path or through an app which has already
|
||||
asserted permissions to do this action
|
||||
'''
|
||||
|
||||
|
||||
def _copy_course_group(source, dest):
|
||||
'''
|
||||
This is to be called only by either a command line code path or through an app which has already
|
||||
asserted permissions to do this action
|
||||
'''
|
||||
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
|
||||
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
|
||||
for user in instructors.user_set.all():
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches
|
||||
|
||||
"""
|
||||
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
|
||||
"""
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
|
||||
from common import type_in_codemirror
|
||||
|
||||
KEY_CSS = '.key input.policy-key'
|
||||
VALUE_CSS = 'textarea.json'
|
||||
@@ -32,18 +28,20 @@ def i_am_on_advanced_course_settings(step):
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(step, name):
|
||||
css = 'a.%s-button' % name.lower()
|
||||
world.css_click(css)
|
||||
|
||||
# Save was clicked if either the save notification bar is gone, or we have a error notification
|
||||
# overlaying it (expected in the case of typing Object into display_name).
|
||||
def save_clicked():
|
||||
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
|
||||
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
|
||||
return confirmation_dismissed or error_showing
|
||||
|
||||
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.')
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key$')
|
||||
def edit_the_value_of_a_policy_key(step):
|
||||
"""
|
||||
It is hard to figure out how to get into the CodeMirror
|
||||
area, so cheat and do it from the policy key field :)
|
||||
"""
|
||||
world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click()
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
g._element.send_keys(Keys.ARROW_LEFT, ' ', 'X')
|
||||
type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
|
||||
|
||||
|
||||
@step(u'I edit the value of a policy key and save$')
|
||||
@@ -132,13 +130,5 @@ def change_display_name_value(step, new_value):
|
||||
|
||||
|
||||
def change_value(step, key, new_value):
|
||||
index = get_index_of(key)
|
||||
world.css_find(".CodeMirror")[index].click()
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
current_value = world.css_find(VALUE_CSS)[index].value
|
||||
g._element.send_keys(Keys.CONTROL + Keys.END)
|
||||
for count in range(len(current_value)):
|
||||
g._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
# Must delete "" before typing the JSON value
|
||||
g._element.send_keys(Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
|
||||
type_in_codemirror(get_index_of(key), new_value)
|
||||
press_the_notification_button(step, "Save")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true
|
||||
@@ -16,7 +16,7 @@ logger = getLogger(__name__)
|
||||
########### STEP HELPERS ##############
|
||||
|
||||
@step('I (?:visit|access|open) the Studio homepage$')
|
||||
def i_visit_the_studio_homepage(step):
|
||||
def i_visit_the_studio_homepage(_step):
|
||||
# To make this go to port 8001, put
|
||||
# LETTUCE_SERVER_PORT = 8001
|
||||
# in your settings.py file.
|
||||
@@ -26,17 +26,17 @@ def i_visit_the_studio_homepage(step):
|
||||
|
||||
|
||||
@step('I am logged into Studio$')
|
||||
def i_am_logged_into_studio(step):
|
||||
def i_am_logged_into_studio(_step):
|
||||
log_into_studio()
|
||||
|
||||
|
||||
@step('I confirm the alert$')
|
||||
def i_confirm_with_ok(step):
|
||||
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):
|
||||
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':
|
||||
@@ -47,7 +47,7 @@ def i_press_the_category_delete_icon(step, category):
|
||||
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(step):
|
||||
def i_have_opened_a_new_course(_step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ def create_studio_user(
|
||||
registration.register(studio_user)
|
||||
registration.activate()
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
org='MITx',
|
||||
@@ -107,7 +106,7 @@ def log_into_studio(
|
||||
|
||||
|
||||
def create_a_course():
|
||||
c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
# Add the user to the instructor group of the course
|
||||
# so they will have the permissions to see it in studio
|
||||
@@ -147,6 +146,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
|
||||
world.css_fill(date_css, desired_date)
|
||||
# hit TAB to get to the time field
|
||||
e = world.css_find(date_css).first
|
||||
# pylint: disable=W0212
|
||||
e._element.send_keys(Keys.TAB)
|
||||
world.css_fill(time_css, desired_time)
|
||||
e = world.css_find(time_css).first
|
||||
@@ -169,3 +169,24 @@ def open_new_unit(step):
|
||||
step.given('I have added a new subsection')
|
||||
step.given('I expand the first section')
|
||||
world.css_click('a.new-unit-item')
|
||||
|
||||
|
||||
@step('when I view the video it (.*) show the captions')
|
||||
def shows_captions(step, show_captions):
|
||||
# Prevent cookies from overriding course settings
|
||||
world.browser.cookies.delete('hide_captions')
|
||||
if show_captions == 'does not':
|
||||
assert world.css_find('.video')[0].has_class('closed')
|
||||
else:
|
||||
assert world.is_css_not_present('.video.closed')
|
||||
|
||||
|
||||
def type_in_codemirror(index, text):
|
||||
world.css_click(".CodeMirror", index=index)
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
if world.is_mac():
|
||||
g._element.send_keys(Keys.COMMAND + 'a')
|
||||
else:
|
||||
g._element.send_keys(Keys.CONTROL + 'a')
|
||||
g._element.send_keys(Keys.DELETE)
|
||||
g._element.send_keys(text)
|
||||
|
||||
53
cms/djangoapps/contentstore/features/grading.feature
Normal file
53
cms/djangoapps/contentstore/features/grading.feature
Normal file
@@ -0,0 +1,53 @@
|
||||
Feature: Course Grading
|
||||
As a course author, I want to be able to configure how my course is graded
|
||||
|
||||
Scenario: Users can add grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "1" new grade
|
||||
Then I see I now have "3" grades
|
||||
|
||||
Scenario: Users can only have up to 5 grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "6" new grades
|
||||
Then I see I now have "5" grades
|
||||
|
||||
#Cannot reliably make the delete button appear so using javascript instead
|
||||
Scenario: Users can delete grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I add "1" new grade
|
||||
And I delete a grade
|
||||
Then I see I now have "2" grades
|
||||
|
||||
Scenario: Users can move grading ranges
|
||||
Given I have opened a new course in Studio
|
||||
And I am viewing the grading settings
|
||||
When I move a grading section
|
||||
Then I see that the grade range has changed
|
||||
|
||||
Scenario: Users can modify Assignment types
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I change assignment type "Homework" to "New Type"
|
||||
And I go back to the main course page
|
||||
Then I do see the assignment name "New Type"
|
||||
And I do not see the assignment name "Homework"
|
||||
|
||||
Scenario: Users can delete Assignment types
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I delete the assignment type "Homework"
|
||||
And I go back to the main course page
|
||||
Then I do not see the assignment name "Homework"
|
||||
|
||||
Scenario: Users can add Assignment types
|
||||
Given I have opened a new course in Studio
|
||||
And I have populated the course
|
||||
And I am viewing the grading settings
|
||||
When I add a new assignment type "New Type"
|
||||
And I go back to the main course page
|
||||
Then I do see the assignment name "New Type"
|
||||
108
cms/djangoapps/contentstore/features/grading.py
Normal file
108
cms/djangoapps/contentstore/features/grading.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
|
||||
@step(u'I am viewing the grading settings')
|
||||
def view_grading_settings(step):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-grading a'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step(u'I add "([^"]*)" new grade')
|
||||
def add_grade(step, many):
|
||||
grade_css = '.new-grade-button'
|
||||
for i in range(int(many)):
|
||||
world.css_click(grade_css)
|
||||
|
||||
|
||||
@step(u'I delete a grade')
|
||||
def delete_grade(step):
|
||||
#grade_css = 'li.grade-specific-bar > a.remove-button'
|
||||
#range_css = '.grade-specific-bar'
|
||||
#world.css_find(range_css)[1].mouseover()
|
||||
#world.css_click(grade_css)
|
||||
world.browser.execute_script('document.getElementsByClassName("remove-button")[0].click()')
|
||||
|
||||
|
||||
@step(u'I see I now have "([^"]*)" grades$')
|
||||
def view_grade_slider(step, how_many):
|
||||
grade_slider_css = '.grade-specific-bar'
|
||||
all_grades = world.css_find(grade_slider_css)
|
||||
assert len(all_grades) == int(how_many)
|
||||
|
||||
|
||||
@step(u'I move a grading section')
|
||||
def move_grade_slider(step):
|
||||
moveable_css = '.ui-resizable-e'
|
||||
f = world.css_find(moveable_css).first
|
||||
f.action_chains.drag_and_drop_by_offset(f._element, 100, 0).perform()
|
||||
|
||||
|
||||
@step(u'I see that the grade range has changed')
|
||||
def confirm_change(step):
|
||||
range_css = '.range'
|
||||
all_ranges = world.css_find(range_css)
|
||||
for i in range(len(all_ranges)):
|
||||
assert all_ranges[i].html != '0-50'
|
||||
|
||||
|
||||
@step(u'I change assignment type "([^"]*)" to "([^"]*)"$')
|
||||
def change_assignment_name(step, old_name, new_name):
|
||||
name_id = '#course-grading-assignment-name'
|
||||
index = get_type_index(old_name)
|
||||
f = world.css_find(name_id)[index]
|
||||
assert index != -1
|
||||
for count in range(len(old_name)):
|
||||
f._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
f._element.send_keys(new_name)
|
||||
|
||||
|
||||
@step(u'I go back to the main course page')
|
||||
def main_course_page(step):
|
||||
main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]'
|
||||
world.css_click(main_page_link_css)
|
||||
|
||||
|
||||
@step(u'I do( not)? see the assignment name "([^"]*)"$')
|
||||
def see_assignment_name(step, do_not, name):
|
||||
assignment_menu_css = 'ul.menu > li > a'
|
||||
assignment_menu = world.css_find(assignment_menu_css)
|
||||
allnames = [item.html for item in assignment_menu]
|
||||
if do_not:
|
||||
assert not name in allnames
|
||||
else:
|
||||
assert name in allnames
|
||||
|
||||
|
||||
@step(u'I delete the assignment type "([^"]*)"$')
|
||||
def delete_assignment_type(step, to_delete):
|
||||
delete_css = '.remove-grading-data'
|
||||
world.css_click(delete_css, index=get_type_index(to_delete))
|
||||
|
||||
|
||||
@step(u'I add a new assignment type "([^"]*)"$')
|
||||
def add_assignment_type(step, new_name):
|
||||
add_button_css = '.add-grading-data'
|
||||
world.css_click(add_button_css)
|
||||
name_id = '#course-grading-assignment-name'
|
||||
f = world.css_find(name_id)[4]
|
||||
f._element.send_keys(new_name)
|
||||
|
||||
|
||||
@step(u'I have populated the course')
|
||||
def populate_course(step):
|
||||
step.given('I have added a new section')
|
||||
step.given('I have added a new subsection')
|
||||
|
||||
|
||||
def get_type_index(name):
|
||||
name_id = '#course-grading-assignment-name'
|
||||
f = world.css_find(name_id)
|
||||
for i in range(len(f)):
|
||||
if f[i].value == name:
|
||||
return i
|
||||
return -1
|
||||
@@ -3,65 +3,71 @@ Feature: Problem Editor
|
||||
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I see five alphabetized settings and their expected values
|
||||
And Edit High Level Source is not visible
|
||||
|
||||
Scenario: User can modify String values
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
Scenario: User can specify special characters in String values
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I can specify special characters in the display name
|
||||
And my special characters and persisted on save
|
||||
|
||||
Scenario: User can revert display name to unset
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I can revert the display name to unset
|
||||
And my display name is unset on save
|
||||
|
||||
Scenario: User can select values in a Select
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I can select Per Student for Randomization
|
||||
And my change to randomization is persisted
|
||||
And I can revert to the default value for randomization
|
||||
|
||||
Scenario: User can modify float input values
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I can set the weight to "3.5"
|
||||
And my change to weight is persisted
|
||||
And I can revert to the default value of unset for weight
|
||||
|
||||
Scenario: User cannot type letters in float number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then if I set the weight to "abc", it remains unset
|
||||
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
|
||||
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
|
||||
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then I can set the weight to "3.5"
|
||||
And I can modify the display name
|
||||
Then If I press Cancel my changes are not persisted
|
||||
|
||||
Scenario: Edit High Level source is available for LaTeX problem
|
||||
Given I have created a LaTeX Problem
|
||||
And I edit and select Settings
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
|
||||
Given I have created a LaTeX Problem
|
||||
When I edit and compile the High Level Source
|
||||
Then my change to the High Level Source is persisted
|
||||
And when I view the High Level Source I see my changes
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_equal
|
||||
from common import type_in_codemirror
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
MAXIMUM_ATTEMPTS = "Maximum Attempts"
|
||||
@@ -135,12 +136,12 @@ def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_att
|
||||
|
||||
@step('Edit High Level Source is not visible')
|
||||
def edit_high_level_source_not_visible(step):
|
||||
verify_high_level_source(step, False)
|
||||
verify_high_level_source_links(step, False)
|
||||
|
||||
|
||||
@step('Edit High Level Source is visible')
|
||||
def edit_high_level_source_visible(step):
|
||||
verify_high_level_source(step, True)
|
||||
def edit_high_level_source_links_visible(step):
|
||||
verify_high_level_source_links(step, True)
|
||||
|
||||
|
||||
@step('If I press Cancel my changes are not persisted')
|
||||
@@ -153,13 +154,33 @@ def cancel_does_not_save_changes(step):
|
||||
@step('I have created a LaTeX Problem')
|
||||
def create_latex_problem(step):
|
||||
world.click_new_component_button(step, '.large-problem-icon')
|
||||
# Go to advanced tab (waiting for the tab to be visible)
|
||||
world.css_find('#ui-id-2')
|
||||
# Go to advanced tab.
|
||||
world.css_click('#ui-id-2')
|
||||
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
|
||||
|
||||
|
||||
def verify_high_level_source(step, visible):
|
||||
@step('I edit and compile the High Level Source')
|
||||
def edit_latex_source(step):
|
||||
open_high_level_source()
|
||||
type_in_codemirror(1, "hi")
|
||||
world.css_click('.hls-compile')
|
||||
|
||||
|
||||
@step('my change to the High Level Source is persisted')
|
||||
def high_level_source_persisted(step):
|
||||
def verify_text(driver):
|
||||
return world.css_find('.problem').text == 'hi'
|
||||
|
||||
world.wait_for(verify_text)
|
||||
|
||||
|
||||
@step('I view the High Level Source I see my changes')
|
||||
def high_level_source_in_editor(step):
|
||||
open_high_level_source()
|
||||
assert_equal('hi', world.css_find('.source-edit-box').value)
|
||||
|
||||
|
||||
def verify_high_level_source_links(step, visible):
|
||||
assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
|
||||
world.cancel_component(step)
|
||||
assert_equal(visible, world.is_css_present('.upload-button'))
|
||||
@@ -187,3 +208,8 @@ def verify_unset_display_name():
|
||||
|
||||
def set_weight(weight):
|
||||
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
|
||||
|
||||
|
||||
def open_high_level_source():
|
||||
world.css_click('a.edit-button')
|
||||
world.css_click('.launch-latex-compiler > a')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
@@ -8,7 +8,7 @@ from nose.tools import assert_equal
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('I click the new section link$')
|
||||
@step('I click the New Section link$')
|
||||
def i_click_new_section_link(_step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
world.css_click(link_css)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
|
||||
@@ -4,10 +4,20 @@ Feature: Video Component Editor
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Video component
|
||||
And I edit and select Settings
|
||||
Then I see only the Video display name setting
|
||||
Then I see the correct settings and default values
|
||||
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Video component
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
Scenario: Captions are hidden when "show captions" is false
|
||||
Given I have created a Video component
|
||||
And I have set "show captions" to False
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
Scenario: Captions are shown when "show captions" is true
|
||||
Given I have created a Video component
|
||||
And I have set "show captions" to True
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
from lettuce import world, step
|
||||
|
||||
|
||||
@step('I see only the video display name setting$')
|
||||
def i_see_only_the_video_display_name(step):
|
||||
world.verify_all_setting_entries([['Display Name', "default", True]])
|
||||
@step('I see the correct settings and default values$')
|
||||
def i_see_the_correct_settings_and_values(step):
|
||||
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
|
||||
['Display Name', 'default', True],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
['Speed: .75x', '', False],
|
||||
['Speed: 1.25x', '', False],
|
||||
['Speed: 1.5x', '', False]])
|
||||
|
||||
|
||||
@step('I have set "show captions" to (.*)')
|
||||
def set_show_captions(step, setting):
|
||||
world.css_click('a.edit-button')
|
||||
world.browser.select('Show Captions', setting)
|
||||
world.css_click('a.save-button')
|
||||
|
||||
@@ -9,7 +9,16 @@ Feature: Video Component
|
||||
Given I have clicked the new unit button
|
||||
Then creating a video takes a single click
|
||||
|
||||
Scenario: Captions are shown correctly
|
||||
Scenario: Captions are hidden correctly
|
||||
Given I have created a Video component
|
||||
And I have hidden captions
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
Scenario: Captions are shown correctly
|
||||
Given I have created a Video component
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
Scenario: Captions are toggled correctly
|
||||
Given I have created a Video component
|
||||
And I have toggled captions
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -6,23 +6,28 @@ from lettuce import world, step
|
||||
|
||||
|
||||
@step('when I view the video it does not have autoplay enabled')
|
||||
def does_not_autoplay(step):
|
||||
def does_not_autoplay(_step):
|
||||
assert world.css_find('.video')[0]['data-autoplay'] == 'False'
|
||||
assert world.css_find('.video_control')[0].has_class('play')
|
||||
|
||||
|
||||
@step('creating a video takes a single click')
|
||||
def video_takes_a_single_click(step):
|
||||
def video_takes_a_single_click(_step):
|
||||
assert(not world.is_css_present('.xmodule_VideoModule'))
|
||||
world.css_click("a[data-location='i4x://edx/templates/video/default']")
|
||||
assert(world.is_css_present('.xmodule_VideoModule'))
|
||||
|
||||
|
||||
@step('I have hidden captions')
|
||||
def set_show_captions_false(step):
|
||||
world.css_click('a.hide-subtitles')
|
||||
|
||||
|
||||
@step('when I view the video it does not show the captions')
|
||||
def does_not_show_captions(step):
|
||||
assert world.css_find('.video')[0].has_class('closed')
|
||||
@step('I have (hidden|toggled) captions')
|
||||
def hide_or_show_captions(step, shown):
|
||||
button_css = 'a.hide-subtitles'
|
||||
if shown == 'hidden':
|
||||
world.css_click(button_css)
|
||||
if shown == 'toggled':
|
||||
world.css_click(button_css)
|
||||
# When we click the first time, a tooltip shows up. We want to
|
||||
# click the button rather than the tooltip, so move the mouse
|
||||
# away to make it disappear.
|
||||
button = world.css_find(button_css)
|
||||
button.mouse_out()
|
||||
world.css_click(button_css)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.contentstore.utils import empty_asset_trashcan
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from .prompt import query_yes_no
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("empty_asset_trashcan requires one or no arguments: |<location>|")
|
||||
|
||||
locs = []
|
||||
|
||||
if len(args) == 1:
|
||||
locs.append(CourseDescriptor.id_to_location(args[0]))
|
||||
else:
|
||||
courses = modulestore('direct').get_courses()
|
||||
for course in courses:
|
||||
locs.append(course.location)
|
||||
|
||||
if query_yes_no("Emptying trashcan. Confirm?", default="no"):
|
||||
empty_asset_trashcan(locs)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Restore a deleted asset from the trashcan back to it's original course'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
|
||||
|
||||
restore_asset_from_trashcan(args[0])
|
||||
|
||||
@@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
|
||||
def set_module_info(store, location, post_data):
|
||||
module = None
|
||||
try:
|
||||
if location.revision is None:
|
||||
module = store.get_item(location)
|
||||
else:
|
||||
module = store.get_item(location)
|
||||
module = store.get_item(location)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase):
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
return modulestore.get_item(self.course.location).checklists
|
||||
|
||||
|
||||
def compare_checklists(self, persisted, request):
|
||||
"""
|
||||
Handles url expansion as possible difference and descends into guts
|
||||
|
||||
@@ -28,6 +28,8 @@ from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan, empty_asset_trashcan
|
||||
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
@@ -35,6 +37,7 @@ from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
@@ -129,7 +132,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
location = descriptor.location._replace(name='.' + descriptor.location.name)
|
||||
location = descriptor.location.replace(name='.' + descriptor.location.name)
|
||||
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()}))
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
@@ -221,7 +224,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
new_graceperiod = timedelta(**{'hours': 1})
|
||||
new_graceperiod = timedelta(hours=1)
|
||||
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
html_module.lms.graceperiod = new_graceperiod
|
||||
@@ -366,7 +369,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
'''
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None]))
|
||||
self.assertEqual(effort.data, '6 hours')
|
||||
|
||||
@@ -382,6 +384,159 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
course = module_store.get_item(source_location)
|
||||
self.assertFalse(course.hide_progress_tab)
|
||||
|
||||
def test_asset_import(self):
|
||||
'''
|
||||
This test validates that an image asset is imported and a thumbnail was generated for a .gif
|
||||
'''
|
||||
content_store = contentstore()
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
# make sure we have some assets in our contentstore
|
||||
all_assets = content_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our contentstore
|
||||
all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
#
|
||||
# self.assertGreater(len(all_thumbnails), 0)
|
||||
|
||||
content = None
|
||||
try:
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
# self.assertIsNotNone(content.thumbnail_location)
|
||||
#
|
||||
# thumbnail = None
|
||||
# try:
|
||||
# thumbnail = content_store.find(content.thumbnail_location)
|
||||
# except:
|
||||
# pass
|
||||
#
|
||||
# self.assertIsNotNone(thumbnail)
|
||||
|
||||
def test_asset_delete_and_restore(self):
|
||||
'''
|
||||
This test will exercise the soft delete/restore functionality of the assets
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
# look up original (and thumbnail) in content store, should be there after import
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
thumbnail_location = content.thumbnail_location
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
# self.assertIsNotNone(thumbnail_location)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
|
||||
# now try to find it in store, but they should not be there any longer
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
self.assertIsNone(content)
|
||||
|
||||
if thumbnail_location:
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNone(thumbnail)
|
||||
|
||||
# now try to find it and the thumbnail in trashcan - should be in there
|
||||
content = trash_store.find(asset_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
if thumbnail_location:
|
||||
thumbnail = trash_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
# let's restore the asset
|
||||
restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
|
||||
# now try to find it in courseware store, and they should be back after restore
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
if thumbnail_location:
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
def test_empty_trashcan(self):
|
||||
'''
|
||||
This test will exercise the empting of the asset trashcan
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# make sure there's something in the trashcan
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our trashcan
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
# self.assertGreater(len(all_thumbnails), 0)
|
||||
|
||||
# empty the trashcan
|
||||
empty_asset_trashcan([course_location])
|
||||
|
||||
# make sure trashcan is empty
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
self.assertEqual(len(all_assets), 0)
|
||||
|
||||
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
self.assertEqual(len(all_thumbnails), 0)
|
||||
|
||||
def test_clone_course(self):
|
||||
|
||||
course_data = {
|
||||
@@ -461,12 +616,12 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None]))
|
||||
self.assertEqual(len(items), 0)
|
||||
|
||||
def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''):
|
||||
def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''):
|
||||
filesystem = OSFS(root_dir / 'test_export')
|
||||
self.assertTrue(filesystem.exists(dirname))
|
||||
|
||||
query_loc = Location('i4x', location.org, location.course, category_name, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
items = store.get_items(query_loc)
|
||||
|
||||
for item in items:
|
||||
filesystem = OSFS(root_dir / ('test_export/' + dirname))
|
||||
@@ -612,7 +767,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_prefetch_children(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'])
|
||||
|
||||
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
wrapper = MongoCollectionFindWrapper(module_store.collection.find)
|
||||
@@ -708,7 +862,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
def test_create_course_duplicate_course(self):
|
||||
"""Test new course creation - error path"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.client.post(reverse('create_new_course'), self.course_data)
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
@@ -716,7 +870,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
def test_create_course_duplicate_number(self):
|
||||
"""Test new course creation - error path"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.course_data['display_name'] = 'Robot Super Course Two'
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
@@ -934,11 +1088,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
json.dumps({'id': del_loc.url()}), "application/json")
|
||||
self.assertEqual(200, resp.status_code)
|
||||
|
||||
|
||||
def test_import_metadata_with_attempts_empty_string(self):
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['simple'])
|
||||
|
||||
did_load_item = False
|
||||
try:
|
||||
module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]))
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""
|
||||
Tests for Studio Course Settings.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import copy
|
||||
import mock
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.timezone import UTC
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
|
||||
@@ -21,6 +26,9 @@ from xmodule.fields import Date
|
||||
|
||||
|
||||
class CourseTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Base class for test classes below.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
These tests need a user in the DB so that the django Test Client
|
||||
@@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
|
||||
|
||||
class CourseDetailsTestCase(CourseTestCase):
|
||||
"""
|
||||
Tests the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
|
||||
@@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
Test the encoder out of its original constrained purpose to see if it functions for general use
|
||||
"""
|
||||
details = {'location': Location(['tag', 'org', 'course', 'category', 'name']),
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())}
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())}
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
|
||||
@@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
jsondetails.effort, "After set effort"
|
||||
)
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
|
||||
def test_marketing_site_fetch(self):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course_location.org,
|
||||
'name': self.course_location.name,
|
||||
'course': self.course_location.course
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
response = self.client.get(settings_details_url)
|
||||
self.assertContains(response, "Course Summary Page")
|
||||
self.assertContains(response, "course summary page will not be viewable")
|
||||
|
||||
self.assertContains(response, "Course Start Date")
|
||||
self.assertContains(response, "Course End Date")
|
||||
self.assertNotContains(response, "Enrollment Start Date")
|
||||
self.assertNotContains(response, "Enrollment End Date")
|
||||
self.assertContains(response, "not the dates shown on your course summary page")
|
||||
|
||||
self.assertNotContains(response, "Introducing Your Course")
|
||||
self.assertNotContains(response, "Requirements")
|
||||
|
||||
def test_regular_site_fetch(self):
|
||||
settings_details_url = reverse(
|
||||
'settings_details',
|
||||
kwargs={
|
||||
'org': self.course_location.org,
|
||||
'name': self.course_location.name,
|
||||
'course': self.course_location.course
|
||||
}
|
||||
)
|
||||
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
|
||||
response = self.client.get(settings_details_url)
|
||||
self.assertContains(response, "Course Summary Page")
|
||||
self.assertNotContains(response, "course summary page will not be viewable")
|
||||
|
||||
self.assertContains(response, "Course Start Date")
|
||||
self.assertContains(response, "Course End Date")
|
||||
self.assertContains(response, "Enrollment Start Date")
|
||||
self.assertContains(response, "Enrollment End Date")
|
||||
self.assertNotContains(response, "not the dates shown on your course summary page")
|
||||
|
||||
self.assertContains(response, "Introducing Your Course")
|
||||
self.assertContains(response, "Requirements")
|
||||
|
||||
|
||||
class CourseDetailsViewTest(CourseTestCase):
|
||||
"""
|
||||
Tests for modifying content on the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def alter_field(self, url, details, field, val):
|
||||
setattr(details, field, val)
|
||||
# Need to partially serialize payload b/c the mock doesn't handle it correctly
|
||||
@@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
|
||||
|
||||
class CourseGradingTest(CourseTestCase):
|
||||
"""
|
||||
Tests for the course settings grading page.
|
||||
"""
|
||||
def test_initial_grader(self):
|
||||
descriptor = get_modulestore(self.course_location).get_item(self.course_location)
|
||||
test_grader = CourseGradingModel(descriptor)
|
||||
@@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase):
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
Tests for CourseMetadata.
|
||||
"""
|
||||
def setUp(self):
|
||||
CourseTestCase.setUp(self)
|
||||
# add in the full class too
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from contentstore.utils import get_modulestore, get_url_reverse
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse
|
||||
|
||||
from .utils import parse_json, user, registration
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class ContentStoreTestCase(ModuleStoreTestCase):
|
||||
@@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase):
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
|
||||
# Logged in should work.
|
||||
|
||||
|
||||
class ForumTestCase(CourseTestCase):
|
||||
def setUp(self):
|
||||
""" Creates the test course. """
|
||||
super(ForumTestCase, self).setUp()
|
||||
self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course')
|
||||
|
||||
def test_blackouts(self):
|
||||
now = datetime.datetime.now(UTC)
|
||||
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
|
||||
[(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)),
|
||||
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
|
||||
self.assertTrue(self.course.forum_posts_allowed)
|
||||
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
|
||||
[(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)),
|
||||
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
|
||||
self.assertFalse(self.course.forum_posts_allowed)
|
||||
|
||||
@@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course):
|
||||
@param course: A course object from the modulestore.
|
||||
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
|
||||
"""
|
||||
#Copy course tabs
|
||||
# Copy course tabs
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
# Check to see if open ended panel is defined in the course
|
||||
|
||||
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
|
||||
if tab_panel not in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
# Add panel to the tabs if it is not defined
|
||||
course_tabs.append(tab_panel)
|
||||
changed = True
|
||||
return changed, course_tabs
|
||||
@@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course):
|
||||
@param course: A course object from the modulestore.
|
||||
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
|
||||
"""
|
||||
#Copy course tabs
|
||||
# Copy course tabs
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
# Check to see if open ended panel is defined in the course
|
||||
|
||||
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
|
||||
if tab_panel in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
# Add panel to the tabs if it is not defined
|
||||
course_tabs = [ct for ct in course_tabs if ct != tab_panel]
|
||||
changed = True
|
||||
return changed, course_tabs
|
||||
|
||||
@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore import InvalidLocationError
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from ..utils import get_url_reverse
|
||||
from .access import get_location_and_verify_access
|
||||
@@ -78,10 +80,17 @@ def asset_index(request, org, course, name):
|
||||
'active_tab': 'assets',
|
||||
'context_course': course_module,
|
||||
'assets': asset_display,
|
||||
'upload_asset_callback_url': upload_asset_callback_url
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'remove_asset_callback_url': reverse('remove_asset', kwargs={
|
||||
'org': org,
|
||||
'course': course,
|
||||
'name': name
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def upload_asset(request, org, course, coursename):
|
||||
'''
|
||||
cdodge: this method allows for POST uploading of files into the course asset library, which will
|
||||
@@ -145,6 +154,57 @@ def upload_asset(request, org, course, coursename):
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def remove_asset(request, org, course, name):
|
||||
'''
|
||||
This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
|
||||
the main GridFS collection and into a Trashcan
|
||||
'''
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
location = request.POST['location']
|
||||
|
||||
# make sure the location is valid
|
||||
try:
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
except InvalidLocationError:
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
|
||||
# also make sure the item to delete actually exists
|
||||
try:
|
||||
content = contentstore().find(loc)
|
||||
except NotFoundError:
|
||||
response = HttpResponse()
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
# ok, save the content into the trashcan
|
||||
contentstore('trashcan').save(content)
|
||||
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
try:
|
||||
thumbnail_content = contentstore().find(content.thumbnail_location)
|
||||
contentstore('trashcan').save(thumbnail_content)
|
||||
# hard delete thumbnail from origin
|
||||
contentstore().delete(thumbnail_content.get_id())
|
||||
# remove from any caching
|
||||
del_cached_content(thumbnail_content.location)
|
||||
except:
|
||||
pass # OK if this is left dangling
|
||||
|
||||
# delete the original
|
||||
contentstore().delete(content.get_id())
|
||||
# remove from cache
|
||||
del_cached_content(content.location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
|
||||
@@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, \
|
||||
InvalidLocationError
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
|
||||
@@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
# TODO: should explicitly enumerate exports with __all__
|
||||
|
||||
__all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
'course_info_updates', 'get_course_settings',
|
||||
'course_config_graders_page',
|
||||
@@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name):
|
||||
kwargs={"org": org,
|
||||
"course": course,
|
||||
"name": name,
|
||||
"section": "details"})
|
||||
"section": "details"}),
|
||||
'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def clone_item(request):
|
||||
@expect_json
|
||||
def delete_item(request):
|
||||
item_location = request.POST['id']
|
||||
item_loc = Location(item_location)
|
||||
item_location = Location(item_location)
|
||||
|
||||
# check permissions for this user within this course
|
||||
if not has_access(request.user, item_location):
|
||||
@@ -124,11 +124,11 @@ def delete_item(request):
|
||||
|
||||
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
|
||||
if delete_all_versions:
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
|
||||
parent_locs = modulestore('direct').get_parent_locations(item_location, None)
|
||||
|
||||
for parent_loc in parent_locs:
|
||||
parent = modulestore('direct').get_item(parent_loc)
|
||||
item_url = item_loc.url()
|
||||
item_url = item_location.url()
|
||||
if item_url in parent.children:
|
||||
children = parent.children
|
||||
children.remove(item_url)
|
||||
|
||||
@@ -41,25 +41,25 @@ class CourseDetails(object):
|
||||
course.enrollment_start = descriptor.enrollment_start
|
||||
course.enrollment_end = descriptor.enrollment_end
|
||||
|
||||
temploc = course_location._replace(category='about', name='syllabus')
|
||||
temploc = course_location.replace(category='about', name='syllabus')
|
||||
try:
|
||||
course.syllabus = get_modulestore(temploc).get_item(temploc).data
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
temploc = temploc._replace(name='overview')
|
||||
temploc = temploc.replace(name='overview')
|
||||
try:
|
||||
course.overview = get_modulestore(temploc).get_item(temploc).data
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
temploc = temploc._replace(name='effort')
|
||||
temploc = temploc.replace(name='effort')
|
||||
try:
|
||||
course.effort = get_modulestore(temploc).get_item(temploc).data
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
temploc = temploc._replace(name='video')
|
||||
temploc = temploc.replace(name='video')
|
||||
try:
|
||||
raw_video = get_modulestore(temploc).get_item(temploc).data
|
||||
course.intro_video = CourseDetails.parse_video_tag(raw_video)
|
||||
@@ -126,16 +126,16 @@ class CourseDetails(object):
|
||||
|
||||
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
|
||||
# to make faster, could compare against db or could have client send over a list of which fields changed.
|
||||
temploc = Location(course_location)._replace(category='about', name='syllabus')
|
||||
temploc = Location(course_location).replace(category='about', name='syllabus')
|
||||
update_item(temploc, jsondict['syllabus'])
|
||||
|
||||
temploc = temploc._replace(name='overview')
|
||||
temploc = temploc.replace(name='overview')
|
||||
update_item(temploc, jsondict['overview'])
|
||||
|
||||
temploc = temploc._replace(name='effort')
|
||||
temploc = temploc.replace(name='effort')
|
||||
update_item(temploc, jsondict['effort'])
|
||||
|
||||
temploc = temploc._replace(name='video')
|
||||
temploc = temploc.replace(name='video')
|
||||
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
|
||||
update_item(temploc, recomposed_video_tag)
|
||||
|
||||
@@ -153,9 +153,9 @@ class CourseDetails(object):
|
||||
if not raw_video:
|
||||
return None
|
||||
|
||||
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
|
||||
keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
|
||||
if keystring_matcher is None:
|
||||
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video)
|
||||
keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
|
||||
|
||||
if keystring_matcher:
|
||||
return keystring_matcher.group(0)
|
||||
@@ -174,10 +174,10 @@ class CourseDetails(object):
|
||||
return result
|
||||
|
||||
|
||||
# TODO move to a more general util? Is there a better way to do the isinstance model check?
|
||||
# TODO move to a more general util?
|
||||
class CourseSettingsEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel):
|
||||
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
|
||||
return obj.__dict__
|
||||
elif isinstance(obj, Location):
|
||||
return obj.dict()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from xmodule.modulestore import Location
|
||||
from contentstore.utils import get_modulestore
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xblock.core import Scope
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
@@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
|
||||
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
|
||||
MITX_FEATURES[feature] = value
|
||||
|
||||
# load segment.io key, provide a dummy if it does not exist
|
||||
SEGMENT_IO_KEY = ENV_TOKENS.get('SEGMENT_IO_KEY', '***REMOVED***')
|
||||
|
||||
LOGGING = get_logger_config(LOG_DIR,
|
||||
logging_env=ENV_TOKENS['LOGGING_ENV'],
|
||||
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
|
||||
@@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR,
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
|
||||
AUTH_TOKENS = json.load(auth_file)
|
||||
|
||||
# If Segment.io key specified, load it and turn on Segment.io if the feature flag is set
|
||||
# Note that this is the Studio key. There is a separate key for the LMS.
|
||||
SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
|
||||
if SEGMENT_IO_KEY:
|
||||
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
|
||||
|
||||
|
||||
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
|
||||
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
|
||||
DATABASES = AUTH_TOKENS['DATABASES']
|
||||
|
||||
@@ -21,7 +21,7 @@ Longer TODO:
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=W0401, W0614
|
||||
# pylint: disable=W0401, W0611, W0614
|
||||
|
||||
import sys
|
||||
import lms.envs.common
|
||||
@@ -32,13 +32,23 @@ from path import path
|
||||
|
||||
MITX_FEATURES = {
|
||||
'USE_DJANGO_PIPELINE': True,
|
||||
|
||||
'GITHUB_PUSH': False,
|
||||
|
||||
'ENABLE_DISCUSSION_SERVICE': False,
|
||||
|
||||
'AUTH_USE_MIT_CERTIFICATES': False,
|
||||
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
|
||||
'STAFF_EMAIL': '', # email address for staff (eg to request course creation)
|
||||
|
||||
# do not display video when running automated acceptance tests
|
||||
'STUB_VIDEO_FOR_TESTING': False,
|
||||
|
||||
# email address for staff (eg to request course creation)
|
||||
'STAFF_EMAIL': '',
|
||||
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
'SEGMENT_IO': True,
|
||||
|
||||
# Segment.io - must explicitly turn it on for production
|
||||
'SEGMENT_IO': False,
|
||||
|
||||
# Enable URL that shows information about the status of various services
|
||||
'ENABLE_SERVICE_STATUS': False,
|
||||
@@ -225,10 +235,10 @@ PIPELINE_JS = {
|
||||
'source_filenames': sorted(
|
||||
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
|
||||
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
|
||||
) + ['js/hesitate.js', 'js/base.js',
|
||||
'js/models/feedback.js', 'js/views/feedback.js',
|
||||
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
|
||||
'js/models/section.js', 'js/views/section.js',
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js'],
|
||||
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
|
||||
'js/views/assets.js'],
|
||||
'output_filename': 'js/cms-application.js',
|
||||
'test_order': 0
|
||||
},
|
||||
|
||||
@@ -43,10 +43,15 @@ CONTENTSTORE = {
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'xcontent',
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
@@ -163,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
|
||||
# Enable URL that shows information about the status of variuous services
|
||||
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
|
||||
# segment-io key for dev
|
||||
SEGMENT_IO_KEY = 'mty8edrrsg'
|
||||
############################# SEGMENT-IO ##################################
|
||||
|
||||
# If there's an environment variable set, grab it and turn on Segment.io
|
||||
# Note that this is the Studio key. There is a separate key for the LMS.
|
||||
import os
|
||||
SEGMENT_IO_KEY = os.environ.get('SEGMENT_IO_KEY')
|
||||
if SEGMENT_IO_KEY:
|
||||
MITX_FEATURES['SEGMENT_IO'] = True
|
||||
|
||||
|
||||
#####################################################################
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
# FORCE_SCRIPT_NAME = '/cms'
|
||||
|
||||
from .common import *
|
||||
from logsettings import get_logger_config
|
||||
from .dev import *
|
||||
import socket
|
||||
|
||||
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@ CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'OPTIONS': {
|
||||
'host': 'localhost',
|
||||
'db': 'xcontent',
|
||||
'db': 'test_xmodule',
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"js/vendor/jquery.cookie.js",
|
||||
"js/vendor/json2.js",
|
||||
"js/vendor/underscore-min.js",
|
||||
"js/vendor/underscore.string.min.js",
|
||||
"js/vendor/backbone-min.js",
|
||||
"js/vendor/jquery.leanModal.min.js",
|
||||
"js/vendor/sinon-1.7.1.js",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
describe "CMS.Models.SystemFeedback", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.SystemFeedback()
|
||||
|
||||
it "should have an empty message by default", ->
|
||||
expect(@model.get("message")).toEqual("")
|
||||
|
||||
it "should have an empty title by default", ->
|
||||
expect(@model.get("title")).toEqual("")
|
||||
|
||||
it "should not have an intent set by default", ->
|
||||
expect(@model.get("intent")).toBeNull()
|
||||
|
||||
|
||||
describe "CMS.Models.WarningMessage", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.WarningMessage()
|
||||
|
||||
it "should have the correct intent", ->
|
||||
expect(@model.get("intent")).toEqual("warning")
|
||||
|
||||
describe "CMS.Models.ErrorMessage", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ErrorMessage()
|
||||
|
||||
it "should have the correct intent", ->
|
||||
expect(@model.get("intent")).toEqual("error")
|
||||
|
||||
describe "CMS.Models.ConfirmationMessage", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ConfirmationMessage()
|
||||
|
||||
it "should have the correct intent", ->
|
||||
expect(@model.get("intent")).toEqual("confirmation")
|
||||
@@ -18,79 +18,105 @@ beforeEach ->
|
||||
else
|
||||
return trimmedText.indexOf(text) != -1;
|
||||
|
||||
describe "CMS.Views.Alert as base class", ->
|
||||
describe "CMS.Views.SystemFeedback", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ConfirmationMessage({
|
||||
@options =
|
||||
title: "Portal"
|
||||
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
|
||||
})
|
||||
# it will be interesting to see when this.render is called, so lets spy on it
|
||||
spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough()
|
||||
@renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough()
|
||||
@showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough()
|
||||
@hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough()
|
||||
|
||||
it "renders on initalize", ->
|
||||
view = new CMS.Views.Alert({model: @model})
|
||||
expect(view.render).toHaveBeenCalled()
|
||||
it "requires a type and an intent", ->
|
||||
neither = =>
|
||||
new CMS.Views.SystemFeedback(@options)
|
||||
noType = =>
|
||||
options = $.extend({}, @options)
|
||||
options.intent = "confirmation"
|
||||
new CMS.Views.SystemFeedback(options)
|
||||
noIntent = =>
|
||||
options = $.extend({}, @options)
|
||||
options.type = "alert"
|
||||
new CMS.Views.SystemFeedback(options)
|
||||
both = =>
|
||||
options = $.extend({}, @options)
|
||||
options.type = "alert"
|
||||
options.intent = "confirmation"
|
||||
new CMS.Views.SystemFeedback(options)
|
||||
|
||||
expect(neither).toThrow()
|
||||
expect(noType).toThrow()
|
||||
expect(noIntent).toThrow()
|
||||
expect(both).not.toThrow()
|
||||
|
||||
# for simplicity, we'll use CMS.Views.Alert.Confirmation from here on,
|
||||
# which extends and proxies to CMS.Views.SystemFeedback
|
||||
|
||||
it "does not show on initalize", ->
|
||||
view = new CMS.Views.Alert.Confirmation(@options)
|
||||
expect(@renderSpy).not.toHaveBeenCalled()
|
||||
expect(@showSpy).not.toHaveBeenCalled()
|
||||
|
||||
it "renders the template", ->
|
||||
view = new CMS.Views.Alert({model: @model})
|
||||
view = new CMS.Views.Alert.Confirmation(@options)
|
||||
view.show()
|
||||
|
||||
expect(view.$(".action-close")).toBeDefined()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
expect(view.$el).toContainText(@model.get("title"))
|
||||
expect(view.$el).toContainText(@model.get("message"))
|
||||
expect(view.$el).toContainText(@options.title)
|
||||
expect(view.$el).toContainText(@options.message)
|
||||
|
||||
it "close button sends a .hide() message", ->
|
||||
spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough()
|
||||
|
||||
view = new CMS.Views.Alert({model: @model})
|
||||
view = new CMS.Views.Alert.Confirmation(@options).show()
|
||||
view.$(".action-close").click()
|
||||
|
||||
expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled()
|
||||
expect(@hideSpy).toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
describe "CMS.Views.Prompt", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.ConfirmationMessage({
|
||||
title: "Portal"
|
||||
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
|
||||
})
|
||||
|
||||
# for some reason, expect($("body")) blows up the test runner, so this test
|
||||
# just exercises the Prompt rather than asserting on anything. Best I can
|
||||
# do for now. :(
|
||||
it "changes class on body", ->
|
||||
# expect($("body")).not.toHaveClass("prompt-is-shown")
|
||||
view = new CMS.Views.Prompt({model: @model})
|
||||
view = new CMS.Views.Prompt.Confirmation({
|
||||
title: "Portal"
|
||||
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
|
||||
})
|
||||
# expect($("body")).toHaveClass("prompt-is-shown")
|
||||
view.hide()
|
||||
# expect($("body")).not.toHaveClass("prompt-is-shown")
|
||||
|
||||
describe "CMS.Views.Alert click events", ->
|
||||
describe "CMS.Views.SystemFeedback click events", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.WarningMessage(
|
||||
@primaryClickSpy = jasmine.createSpy('primaryClick')
|
||||
@secondaryClickSpy = jasmine.createSpy('secondaryClick')
|
||||
@view = new CMS.Views.Notification.Warning(
|
||||
title: "Unsaved",
|
||||
message: "Your content is currently Unsaved.",
|
||||
actions:
|
||||
primary:
|
||||
text: "Save",
|
||||
class: "save-button",
|
||||
click: jasmine.createSpy('primaryClick')
|
||||
click: @primaryClickSpy
|
||||
secondary: [{
|
||||
text: "Revert",
|
||||
class: "cancel-button",
|
||||
click: jasmine.createSpy('secondaryClick')
|
||||
click: @secondaryClickSpy
|
||||
}]
|
||||
|
||||
)
|
||||
|
||||
@view = new CMS.Views.Alert({model: @model})
|
||||
@view.show()
|
||||
|
||||
it "should trigger the primary event on a primary click", ->
|
||||
@view.primaryClick()
|
||||
expect(@model.get('actions').primary.click).toHaveBeenCalled()
|
||||
@view.$(".action-primary").click()
|
||||
expect(@primaryClickSpy).toHaveBeenCalled()
|
||||
expect(@secondaryClickSpy).not.toHaveBeenCalled()
|
||||
|
||||
it "should trigger the secondary event on a secondary click", ->
|
||||
@view.secondaryClick()
|
||||
expect(@model.get('actions').secondary[0].click).toHaveBeenCalled()
|
||||
@view.$(".action-secondary").click()
|
||||
expect(@secondaryClickSpy).toHaveBeenCalled()
|
||||
expect(@primaryClickSpy).not.toHaveBeenCalled()
|
||||
|
||||
it "should apply class to primary action", ->
|
||||
expect(@view.$(".action-primary")).toHaveClass("save-button")
|
||||
@@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", ->
|
||||
|
||||
describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
beforeEach ->
|
||||
@model = new CMS.Models.SystemFeedback(
|
||||
intent: "saving"
|
||||
title: "Saving"
|
||||
)
|
||||
spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough()
|
||||
spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough()
|
||||
@showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show')
|
||||
@showSpy.andCallThrough()
|
||||
@hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide')
|
||||
@hideSpy.andCallThrough()
|
||||
@clock = sinon.useFakeTimers()
|
||||
|
||||
afterEach ->
|
||||
@clock.restore()
|
||||
|
||||
it "a minShown view should not hide too quickly", ->
|
||||
view = new CMS.Views.Notification({model: @model, minShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# call hide() on it, but the minShown should prevent it from hiding right away
|
||||
@@ -125,8 +149,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a maxShown view should hide by itself", ->
|
||||
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
view = new CMS.Views.Notification.Saving({maxShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait for the maxShown timeout to expire, and check again
|
||||
@@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a minShown view can stay visible longer", ->
|
||||
view = new CMS.Views.Notification({model: @model, minShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait for the minShown timeout to expire, and check again
|
||||
@clock.tick(1001)
|
||||
expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled()
|
||||
expect(@hideSpy).not.toHaveBeenCalled()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# can now hide immediately
|
||||
@@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a maxShown view can hide early", ->
|
||||
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
|
||||
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
|
||||
view = new CMS.Views.Notification.Saving({maxShown: 1000})
|
||||
view.show()
|
||||
expect(view.$('.wrapper')).toBeShown()
|
||||
|
||||
# wait 50 milliseconds, and hide it early
|
||||
@@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
|
||||
expect(view.$('.wrapper')).toBeHiding()
|
||||
|
||||
it "a view can have both maxShown and minShown", ->
|
||||
view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000})
|
||||
view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000})
|
||||
view.show()
|
||||
|
||||
# can't hide early
|
||||
@clock.tick(50)
|
||||
|
||||
@@ -18,11 +18,15 @@ $ ->
|
||||
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
|
||||
if ajaxSettings.notifyOnError is false
|
||||
return
|
||||
msg = new CMS.Models.ErrorMessage(
|
||||
if jqXHR.responseText
|
||||
message = _.str.truncate(jqXHR.responseText, 300)
|
||||
else
|
||||
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
|
||||
msg = new CMS.Views.Notification.Error(
|
||||
"title": gettext("Studio's having trouble saving your work")
|
||||
"message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
|
||||
"message": message
|
||||
)
|
||||
new CMS.Views.Notification({model: msg})
|
||||
msg.show()
|
||||
|
||||
window.onTouchBasedDevice = ->
|
||||
navigator.userAgent.match /iPhone|iPod|iPad/i
|
||||
|
||||
@@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
[@metadataEditor.getDisplayName()])
|
||||
@$el.find('.component-name').html(title)
|
||||
|
||||
customMetadata: ->
|
||||
# Hack to support metadata fields that aren't part of the metadata editor (ie, LaTeX high level source).
|
||||
# Walk through the set of elements which have the 'data-metadata_name' attribute and
|
||||
# build up an object to pass back to the server on the subsequent POST.
|
||||
# Note that these values will always be sent back on POST, even if they did not actually change.
|
||||
_metadata = {}
|
||||
_metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', @$component_editor())
|
||||
return _metadata
|
||||
|
||||
changedMetadata: ->
|
||||
return @metadataEditor.getModifiedMetadataValues()
|
||||
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
|
||||
|
||||
cloneTemplate: (parent, template) ->
|
||||
$.post("/clone_item", {
|
||||
|
||||
@@ -32,8 +32,6 @@ $(document).ready(function() {
|
||||
|
||||
$modal.bind('click', hideModal);
|
||||
$modalCover.bind('click', hideModal);
|
||||
$('.uploads .upload-button').bind('click', showUploadModal);
|
||||
$('.upload-modal .close-button').bind('click', hideModal);
|
||||
|
||||
$body.on('click', '.embeddable-xml-input', function() {
|
||||
$(this).select();
|
||||
@@ -145,8 +143,6 @@ $(document).ready(function() {
|
||||
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
|
||||
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate);
|
||||
|
||||
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
|
||||
|
||||
$body.on('click', '.section-published-date .edit-button', editSectionPublishDate);
|
||||
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
|
||||
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
|
||||
@@ -398,73 +394,6 @@ function _deleteItem($el) {
|
||||
});
|
||||
}
|
||||
|
||||
function showUploadModal(e) {
|
||||
e.preventDefault();
|
||||
$modal = $('.upload-modal').show();
|
||||
$('.file-input').bind('change', startUpload);
|
||||
$modalCover.show();
|
||||
}
|
||||
|
||||
function showFileSelectionMenu(e) {
|
||||
e.preventDefault();
|
||||
$('.file-input').click();
|
||||
}
|
||||
|
||||
function startUpload(e) {
|
||||
var files = $('.file-input').get(0).files;
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
$('.upload-modal h1').html(gettext('Uploading…'));
|
||||
$('.upload-modal .file-name').html(files[0].name);
|
||||
$('.upload-modal .file-chooser').ajaxSubmit({
|
||||
beforeSend: resetUploadBar,
|
||||
uploadProgress: showUploadFeedback,
|
||||
complete: displayFinishedUpload
|
||||
});
|
||||
$('.upload-modal .choose-file-button').hide();
|
||||
$('.upload-modal .progress-bar').removeClass('loaded').show();
|
||||
}
|
||||
|
||||
function resetUploadBar() {
|
||||
var percentVal = '0%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function showUploadFeedback(event, position, total, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function displayFinishedUpload(xhr) {
|
||||
if (xhr.status = 200) {
|
||||
markAsLoaded();
|
||||
}
|
||||
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
|
||||
$('.upload-modal .progress-fill').width('100%');
|
||||
|
||||
// see if this id already exists, if so, then user must have updated an existing piece of content
|
||||
$("tr[data-id='" + resp.url + "']").remove();
|
||||
|
||||
var template = $('#new-asset-element').html();
|
||||
var html = Mustache.to_html(template, resp);
|
||||
$('table > tbody').prepend(html);
|
||||
|
||||
analytics.track('Uploaded a File', {
|
||||
'course': course_location_analytics,
|
||||
'asset_url': resp.url
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function markAsLoaded() {
|
||||
$('.upload-modal .copy-button').css('display', 'inline-block');
|
||||
$('.upload-modal .progress-bar').addClass('loaded');
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
CMS.Models.SystemFeedback = Backbone.Model.extend({
|
||||
defaults: {
|
||||
"intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc
|
||||
"title": "",
|
||||
"message": ""
|
||||
/* could also have an "actions" hash: here is an example demonstrating
|
||||
the expected structure
|
||||
"actions": {
|
||||
"primary": {
|
||||
"text": "Save",
|
||||
"class": "action-save",
|
||||
"click": function() {
|
||||
// do something when Save is clicked
|
||||
// `this` refers to the model
|
||||
}
|
||||
},
|
||||
"secondary": [
|
||||
{
|
||||
"text": "Cancel",
|
||||
"class": "action-cancel",
|
||||
"click": function() {}
|
||||
}, {
|
||||
"text": "Discard Changes",
|
||||
"class": "action-discard",
|
||||
"click": function() {}
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
}
|
||||
});
|
||||
|
||||
CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "warning"
|
||||
})
|
||||
});
|
||||
|
||||
CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "error"
|
||||
})
|
||||
});
|
||||
|
||||
CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "confirmation"
|
||||
})
|
||||
});
|
||||
@@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({
|
||||
},
|
||||
showNotification: function() {
|
||||
if(!this.msg) {
|
||||
this.msg = new CMS.Models.SystemFeedback({
|
||||
intent: "saving",
|
||||
title: gettext("Saving…")
|
||||
});
|
||||
}
|
||||
if(!this.msgView) {
|
||||
this.msgView = new CMS.Views.Notification({
|
||||
model: this.msg,
|
||||
this.msg = new CMS.Views.Notification.Saving({
|
||||
title: gettext("Saving…"),
|
||||
closeIcon: false,
|
||||
minShown: 1250
|
||||
});
|
||||
}
|
||||
this.msgView.show();
|
||||
this.msg.show();
|
||||
},
|
||||
hideNotification: function() {
|
||||
if(!this.msgView) { return; }
|
||||
this.msgView.hide();
|
||||
if(!this.msg) { return; }
|
||||
this.msg.hide();
|
||||
}
|
||||
});
|
||||
|
||||
115
cms/static/js/views/assets.js
Normal file
115
cms/static/js/views/assets.js
Normal file
@@ -0,0 +1,115 @@
|
||||
$(document).ready(function() {
|
||||
$('.uploads .upload-button').bind('click', showUploadModal);
|
||||
$('.upload-modal .close-button').bind('click', hideModal);
|
||||
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
|
||||
$('.remove-asset-button').bind('click', removeAsset);
|
||||
});
|
||||
|
||||
function removeAsset(e){
|
||||
e.preventDefault();
|
||||
|
||||
var that = this;
|
||||
var msg = new CMS.Views.Prompt.Confirmation({
|
||||
title: gettext("Delete File Confirmation"),
|
||||
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext("OK"),
|
||||
click: function(view) {
|
||||
// call the back-end to actually remove the asset
|
||||
var url = $('.asset-library').data('remove-asset-callback-url');
|
||||
var row = $(that).closest('tr');
|
||||
$.post(url,
|
||||
{ 'location': row.data('id') },
|
||||
function() {
|
||||
// show the post-commit confirmation
|
||||
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
|
||||
row.remove();
|
||||
analytics.track('Deleted Asset', {
|
||||
'course': course_location_analytics,
|
||||
'id': row.data('id')
|
||||
});
|
||||
}
|
||||
);
|
||||
view.hide();
|
||||
}
|
||||
},
|
||||
secondary: [{
|
||||
text: gettext("Cancel"),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
return msg.show();
|
||||
}
|
||||
|
||||
function showUploadModal(e) {
|
||||
e.preventDefault();
|
||||
$modal = $('.upload-modal').show();
|
||||
$('.file-input').bind('change', startUpload);
|
||||
$modalCover.show();
|
||||
}
|
||||
|
||||
function showFileSelectionMenu(e) {
|
||||
e.preventDefault();
|
||||
$('.file-input').click();
|
||||
}
|
||||
|
||||
function startUpload(e) {
|
||||
var files = $('.file-input').get(0).files;
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
$('.upload-modal h1').html(gettext('Uploading…'));
|
||||
$('.upload-modal .file-name').html(files[0].name);
|
||||
$('.upload-modal .file-chooser').ajaxSubmit({
|
||||
beforeSend: resetUploadBar,
|
||||
uploadProgress: showUploadFeedback,
|
||||
complete: displayFinishedUpload
|
||||
});
|
||||
$('.upload-modal .choose-file-button').hide();
|
||||
$('.upload-modal .progress-bar').removeClass('loaded').show();
|
||||
}
|
||||
|
||||
function resetUploadBar() {
|
||||
var percentVal = '0%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function showUploadFeedback(event, position, total, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
}
|
||||
|
||||
function displayFinishedUpload(xhr) {
|
||||
if (xhr.status == 200) {
|
||||
markAsLoaded();
|
||||
}
|
||||
|
||||
var resp = JSON.parse(xhr.responseText);
|
||||
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
|
||||
$('.upload-modal .progress-fill').width('100%');
|
||||
|
||||
// see if this id already exists, if so, then user must have updated an existing piece of content
|
||||
$("tr[data-id='" + resp.url + "']").remove();
|
||||
|
||||
var template = $('#new-asset-element').html();
|
||||
var html = Mustache.to_html(template, resp);
|
||||
$('table > tbody').prepend(html);
|
||||
|
||||
// re-bind the listeners to delete it
|
||||
$('.remove-asset-button').bind('click', removeAsset);
|
||||
|
||||
analytics.track('Uploaded a File', {
|
||||
'course': course_location_analytics,
|
||||
'asset_url': resp.url
|
||||
});
|
||||
}
|
||||
@@ -1,39 +1,64 @@
|
||||
CMS.Views.Alert = Backbone.View.extend({
|
||||
CMS.Views.SystemFeedback = Backbone.View.extend({
|
||||
options: {
|
||||
type: "alert",
|
||||
title: "",
|
||||
message: "",
|
||||
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
|
||||
type: null, // "alert", "notification", or "prompt": set by subclass
|
||||
shown: true, // is this view currently being shown?
|
||||
icon: true, // should we render an icon related to the message intent?
|
||||
closeIcon: true, // should we render a close button in the top right corner?
|
||||
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
|
||||
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
|
||||
|
||||
/* could also have an "actions" hash: here is an example demonstrating
|
||||
the expected structure
|
||||
actions: {
|
||||
primary: {
|
||||
"text": "Save",
|
||||
"class": "action-save",
|
||||
"click": function(view) {
|
||||
// do something when Save is clicked
|
||||
}
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
"text": "Cancel",
|
||||
"class": "action-cancel",
|
||||
"click": function(view) {}
|
||||
}, {
|
||||
"text": "Discard Changes",
|
||||
"class": "action-discard",
|
||||
"click": function(view) {}
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
},
|
||||
initialize: function() {
|
||||
if(!this.options.type) {
|
||||
throw "SystemFeedback: type required (given " +
|
||||
JSON.stringify(this.options) + ")";
|
||||
}
|
||||
if(!this.options.intent) {
|
||||
throw "SystemFeedback: intent required (given " +
|
||||
JSON.stringify(this.options) + ")";
|
||||
}
|
||||
var tpl = $("#system-feedback-tpl").text();
|
||||
if(!tpl) {
|
||||
console.error("Couldn't load system-feedback template");
|
||||
}
|
||||
this.template = _.template(tpl);
|
||||
this.setElement($("#page-"+this.options.type));
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
return this.show();
|
||||
},
|
||||
render: function() {
|
||||
var attrs = $.extend({}, this.options, this.model.attributes);
|
||||
this.$el.html(this.template(attrs));
|
||||
return this;
|
||||
},
|
||||
events: {
|
||||
"click .action-close": "hide",
|
||||
"click .action-primary": "primaryClick",
|
||||
"click .action-secondary": "secondaryClick"
|
||||
},
|
||||
// public API: show() and hide()
|
||||
show: function() {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.options.shown = true;
|
||||
this.shownAt = new Date();
|
||||
this.render();
|
||||
if($.isNumeric(this.options.maxShown)) {
|
||||
this.hideTimeout = setTimeout($.proxy(this.hide, this),
|
||||
this.hideTimeout = setTimeout(_.bind(this.hide, this),
|
||||
this.options.maxShown);
|
||||
}
|
||||
return this;
|
||||
@@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({
|
||||
this.options.minShown > new Date() - this.shownAt)
|
||||
{
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = setTimeout($.proxy(this.hide, this),
|
||||
this.hideTimeout = setTimeout(_.bind(this.hide, this),
|
||||
this.options.minShown - (new Date() - this.shownAt));
|
||||
} else {
|
||||
this.options.shown = false;
|
||||
@@ -52,40 +77,64 @@ CMS.Views.Alert = Backbone.View.extend({
|
||||
}
|
||||
return this;
|
||||
},
|
||||
primaryClick: function() {
|
||||
var actions = this.model.get("actions");
|
||||
// the rest of the API should be considered semi-private
|
||||
events: {
|
||||
"click .action-close": "hide",
|
||||
"click .action-primary": "primaryClick",
|
||||
"click .action-secondary": "secondaryClick"
|
||||
},
|
||||
render: function() {
|
||||
// there can be only one active view of a given type at a time: only
|
||||
// one alert, only one notification, only one prompt. Therefore, we'll
|
||||
// use a singleton approach.
|
||||
var parent = CMS.Views[_.str.capitalize(this.options.type)];
|
||||
if(parent && parent.active && parent.active !== this) {
|
||||
parent.active.stopListening();
|
||||
parent.active.undelegateEvents();
|
||||
}
|
||||
this.$el.html(this.template(this.options));
|
||||
parent.active = this;
|
||||
return this;
|
||||
},
|
||||
primaryClick: function(event) {
|
||||
var actions = this.options.actions;
|
||||
if(!actions) { return; }
|
||||
var primary = actions.primary;
|
||||
if(!primary) { return; }
|
||||
if(primary.click) {
|
||||
primary.click.call(this.model, this);
|
||||
primary.click.call(event.target, this, event);
|
||||
}
|
||||
},
|
||||
secondaryClick: function(e) {
|
||||
var actions = this.model.get("actions");
|
||||
secondaryClick: function(event) {
|
||||
var actions = this.options.actions;
|
||||
if(!actions) { return; }
|
||||
var secondaryList = actions.secondary;
|
||||
if(!secondaryList) { return; }
|
||||
// which secondary action was clicked?
|
||||
var i = 0; // default to the first secondary action (easier for testing)
|
||||
if(e && e.target) {
|
||||
i = _.indexOf(this.$(".action-secondary"), e.target);
|
||||
if(event && event.target) {
|
||||
i = _.indexOf(this.$(".action-secondary"), event.target);
|
||||
}
|
||||
var secondary = this.model.get("actions").secondary[i];
|
||||
var secondary = secondaryList[i];
|
||||
if(secondary.click) {
|
||||
secondary.click.call(this.model, this);
|
||||
secondary.click.call(event.target, this, event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
CMS.Views.Notification = CMS.Views.Alert.extend({
|
||||
options: $.extend({}, CMS.Views.Alert.prototype.options, {
|
||||
CMS.Views.Alert = CMS.Views.SystemFeedback.extend({
|
||||
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
|
||||
type: "alert"
|
||||
})
|
||||
});
|
||||
CMS.Views.Notification = CMS.Views.SystemFeedback.extend({
|
||||
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
|
||||
type: "notification",
|
||||
closeIcon: false
|
||||
})
|
||||
});
|
||||
CMS.Views.Prompt = CMS.Views.Alert.extend({
|
||||
options: $.extend({}, CMS.Views.Alert.prototype.options, {
|
||||
CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({
|
||||
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
|
||||
type: "prompt",
|
||||
closeIcon: false,
|
||||
icon: false
|
||||
@@ -98,6 +147,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({
|
||||
$body.removeClass('prompt-is-shown');
|
||||
}
|
||||
// super() in Javascript has awkward syntax :(
|
||||
return CMS.Views.Alert.prototype.render.apply(this, arguments);
|
||||
return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation,
|
||||
// CMS.Views.Prompt.StepRequired, etc
|
||||
var capitalCamel, types, intents;
|
||||
capitalCamel = _.compose(_.str.capitalize, _.str.camelize);
|
||||
types = ["alert", "notification", "prompt"];
|
||||
intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"];
|
||||
_.each(types, function(type) {
|
||||
_.each(intents, function(intent) {
|
||||
// "class" is a reserved word in Javascript, so use "klass" instead
|
||||
var klass, subklass;
|
||||
klass = CMS.Views[capitalCamel(type)];
|
||||
subklass = klass.extend({
|
||||
options: $.extend({}, klass.prototype.options, {
|
||||
type: type,
|
||||
intent: intent
|
||||
})
|
||||
});
|
||||
klass[capitalCamel(intent)] = subklass;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({
|
||||
showInvalidMessage: function(model, error, options) {
|
||||
model.set("name", model.previous("name"));
|
||||
var that = this;
|
||||
var msg = new CMS.Models.ErrorMessage({
|
||||
var prompt = new CMS.Views.Prompt.Error({
|
||||
title: gettext("Your change could not be saved"),
|
||||
message: error,
|
||||
actions: {
|
||||
@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({
|
||||
}
|
||||
}
|
||||
});
|
||||
new CMS.Views.Prompt({model: msg});
|
||||
prompt.show();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +1,40 @@
|
||||
// studio - elements - system help
|
||||
// ====================
|
||||
|
||||
// notices - in-context: to be used as notices to users within the context of a form/action
|
||||
.notice-incontext {
|
||||
@extend .ui-well;
|
||||
@include border-radius(($baseline/10));
|
||||
|
||||
.title {
|
||||
@extend .t-title7;
|
||||
margin-bottom: ($baseline/4);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend .t-copy-sub1;
|
||||
@include transition(opacity 0.25s ease-in-out 0);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
.copy {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// particular warnings around a workflow for something
|
||||
.notice-workflow {
|
||||
background: $yellow-l5;
|
||||
|
||||
.copy {
|
||||
color: $gray-d1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ body.course.uploads {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.delete-col {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.embeddable-xml-input {
|
||||
@include box-shadow(none);
|
||||
width: 100%;
|
||||
|
||||
@@ -21,7 +21,7 @@ body.course.settings {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
.message-status {
|
||||
display: none;
|
||||
@include border-top-radius(2px);
|
||||
@include box-sizing(border-box);
|
||||
@@ -52,6 +52,12 @@ body.course.settings {
|
||||
}
|
||||
}
|
||||
|
||||
// notices - used currently for edx mktg
|
||||
.notice-workflow {
|
||||
margin-top: ($baseline);
|
||||
}
|
||||
|
||||
|
||||
// in form - elements
|
||||
.group-settings {
|
||||
margin: 0 0 ($baseline*2) 0;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%block name="bodyclass">is-signedin course uploads</%block>
|
||||
<%block name="title">Files & Uploads</%block>
|
||||
|
||||
@@ -30,6 +31,9 @@
|
||||
<td class="embed-col">
|
||||
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly>
|
||||
</td>
|
||||
<td class="delete-col">
|
||||
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</script>
|
||||
|
||||
@@ -56,7 +60,7 @@
|
||||
<div class="page-actions">
|
||||
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
|
||||
</div>
|
||||
<article class="asset-library">
|
||||
<article class="asset-library" data-remove-asset-callback-url='${remove_asset_callback_url}'>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -64,6 +68,7 @@
|
||||
<th class="name-col">Name</th>
|
||||
<th class="date-col">Date Added</th>
|
||||
<th class="embed-col">URL</th>
|
||||
<th class="delete-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="asset_table_body">
|
||||
@@ -86,6 +91,9 @@
|
||||
<td class="embed-col">
|
||||
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly>
|
||||
</td>
|
||||
<td class="delete-col">
|
||||
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
|
||||
</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</tbody>
|
||||
@@ -129,3 +137,21 @@
|
||||
|
||||
|
||||
</%block>
|
||||
|
||||
<%block name="view_alerts">
|
||||
<!-- alert: save confirmed with close -->
|
||||
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
|
||||
<div class="alert confirmation">
|
||||
<i class="icon-ok"></i>
|
||||
|
||||
<div class="copy">
|
||||
<h2 class="title title-3">${_('Your file has been deleted.')}</h2>
|
||||
</div>
|
||||
|
||||
<a href="" rel="view" class="action action-alert-close">
|
||||
<i class="icon-remove-sign"></i>
|
||||
<span class="label">${_('close alert')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<script type="text/javascript" src="/jsi18n/"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/json2.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/underscore.string.min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script>
|
||||
@@ -54,7 +55,6 @@
|
||||
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
|
||||
<script type="text/javascript" src="//www.youtube.com/player_api"></script>
|
||||
|
||||
<script src="${static.url('js/models/feedback.js')}"></script>
|
||||
<script src="${static.url('js/views/feedback.js')}"></script>
|
||||
|
||||
<!-- view -->
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Thank you for signing up for edX edge! To activate your account,
|
||||
Thank you for signing up for edX Studio! To activate your account,
|
||||
please copy and paste this address into your web browser's
|
||||
address bar:
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<%inherit file="base.html" />
|
||||
<%block name="title">Schedule & Details Settings</%block>
|
||||
<%block name="bodyclass">is-signedin course schedule settings</%block>
|
||||
@@ -50,8 +52,8 @@ from contentstore import utils
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">Settings</small>
|
||||
<span class="sr">> </span>Schedule & Details
|
||||
<small class="subtitle">${_("Settings")}</small>
|
||||
<span class="sr">> </span>${_("Schedule & Details")}
|
||||
</h1>
|
||||
</header>
|
||||
</div>
|
||||
@@ -62,59 +64,68 @@ from contentstore import utils
|
||||
<form id="settings_details" class="settings-details" method="post" action="">
|
||||
<section class="group-settings basic">
|
||||
<header>
|
||||
<h2 class="title-2">Basic Information</h2>
|
||||
<span class="tip">The nuts and bolts of your course</span>
|
||||
<h2 class="title-2">${_("Basic Information")}</h2>
|
||||
<span class="tip">${_("The nuts and bolts of your course")}</span>
|
||||
</header>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text is-not-editable" id="field-course-organization">
|
||||
<label for="course-organization">Organization</label>
|
||||
<input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-organization" value="[Course Organization]" readonly />
|
||||
<label for="course-organization">${_("Organization")}</label>
|
||||
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-organization" value="[Course Organization]" readonly />
|
||||
</li>
|
||||
|
||||
<li class="field text is-not-editable" id="field-course-number">
|
||||
<label for="course-number">Course Number</label>
|
||||
<input title="This field is disabled: this information cannot be changed." type="text" class="short" id="course-number" value="[Course No.]" readonly>
|
||||
<label for="course-number">${_("Course Number")}</label>
|
||||
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="short" id="course-number" value="[Course No.]" readonly>
|
||||
</li>
|
||||
|
||||
<li class="field text is-not-editable" id="field-course-name">
|
||||
<label for="course-name">Course Name</label>
|
||||
<input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-name" value="[Course Name]" readonly />
|
||||
<label for="course-name">${_("Course Name")}</label>
|
||||
<input title="${_('This field is disabled: this information cannot be changed.')}" type="text" class="long" id="course-name" value="[Course Name]" readonly />
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="note note-promotion note-promotion-courseURL has-actions">
|
||||
<h3 class="title">Course Summary Page <span class="tip">(for student enrollment and access)</span></h3>
|
||||
<h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3>
|
||||
<div class="copy">
|
||||
<p><a class="link-courseURL" rel="external" href="https:${utils.get_lms_link_for_about_page(course_location)}" />https:${utils.get_lms_link_for_about_page(course_location)}</a></p>
|
||||
</div>
|
||||
|
||||
<ul class="list-actions">
|
||||
<li class="action-item">
|
||||
<a title="Send a note to students via email" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20"${context_course.display_name_with_default}",%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="icon-envelope-alt icon-inline"></i> Invite your students</a>
|
||||
<a title="${_('Send a note to students via email')}" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20"${context_course.display_name_with_default}",%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="icon-envelope-alt icon-inline"></i>${_("Invite your students")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
% if not about_page_editable:
|
||||
<div class="notice notice-incontext notice-workflow">
|
||||
<h3 class="title">${_("Promoting Your Course with edX")}</h3>
|
||||
<div class="copy">
|
||||
<p>${_('Your course summary page will not be viewable until your course has been announced. To provide content for the page and preview it, follow the instructions provided by your <abbr title="Program Manager">PM</abbr> or Conrad Warre <a rel="email" class="action action-email" href="mailto:conrad@edx.org">(conrad@edx.org)</a>.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
|
||||
<hr class="divide" />
|
||||
|
||||
<section class="group-settings schedule">
|
||||
<header>
|
||||
<h2 class="title-2">Course Schedule</h2>
|
||||
<span class="tip">Important steps and segments of your course</span>
|
||||
<h2 class="title-2">${_('Course Schedule')}</h2>
|
||||
<span class="tip">${_('Dates that control when your course can be viewed.')}</span>
|
||||
</header>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field-group field-group-course-start" id="course-start">
|
||||
<div class="field date" id="field-course-start-date">
|
||||
<label for="course-start-date">Course Start Date</label>
|
||||
<label for="course-start-date">${_("Course Start Date")}</label>
|
||||
<input type="text" class="start-date date start datepicker" id="course-start-date" placeholder="MM/DD/YYYY" autocomplete="off" />
|
||||
<span class="tip tip-stacked">First day the course begins</span>
|
||||
<span class="tip tip-stacked">${_("First day the course begins")}</span>
|
||||
</div>
|
||||
|
||||
<div class="field time" id="field-course-start-time">
|
||||
<label for="course-start-time">Course Start Time</label>
|
||||
<label for="course-start-time">${_("Course Start Time")}</label>
|
||||
<input type="text" class="time start timepicker" id="course-start-time" value="" placeholder="HH:MM" autocomplete="off" />
|
||||
<span class="tip tip-stacked" id="timezone"></span>
|
||||
</div>
|
||||
@@ -122,29 +133,30 @@ from contentstore import utils
|
||||
|
||||
<li class="field-group field-group-course-end" id="course-end">
|
||||
<div class="field date" id="field-course-end-date">
|
||||
<label for="course-end-date">Course End Date</label>
|
||||
<label for="course-end-date">${_("Course End Date")}</label>
|
||||
<input type="text" class="end-date date end" id="course-end-date" placeholder="MM/DD/YYYY" autocomplete="off" />
|
||||
<span class="tip tip-stacked">Last day your course is active</span>
|
||||
<span class="tip tip-stacked">${_("Last day your course is active")}</span>
|
||||
</div>
|
||||
|
||||
<div class="field time" id="field-course-end-time">
|
||||
<label for="course-end-time">Course End Time</label>
|
||||
<label for="course-end-time">${_("Course End Time")}</label>
|
||||
<input type="text" class="time end" id="course-end-time" value="" placeholder="HH:MM" autocomplete="off" />
|
||||
<span class="tip tip-stacked" id="timezone"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
% if about_page_editable:
|
||||
<ol class="list-input">
|
||||
<li class="field-group field-group-enrollment-start" id="enrollment-start">
|
||||
<div class="field date" id="field-enrollment-start-date">
|
||||
<label for="course-enrollment-start-date">Enrollment Start Date</label>
|
||||
<label for="course-enrollment-start-date">${_("Enrollment Start Date")}</label>
|
||||
<input type="text" class="start-date date start" id="course-enrollment-start-date" placeholder="MM/DD/YYYY" autocomplete="off" />
|
||||
<span class="tip tip-stacked">First day students can enroll</span>
|
||||
<span class="tip tip-stacked">${_("First day students can enroll")}</span>
|
||||
</div>
|
||||
|
||||
<div class="field time" id="field-enrollment-start-time">
|
||||
<label for="course-enrollment-start-time">Enrollment Start Time</label>
|
||||
<label for="course-enrollment-start-time">${_("Enrollment Start Time")}</label>
|
||||
<input type="text" class="time start" id="course-enrollment-start-time" value="" placeholder="HH:MM" autocomplete="off" />
|
||||
<span class="tip tip-stacked" id="timezone"></span>
|
||||
</div>
|
||||
@@ -152,91 +164,106 @@ from contentstore import utils
|
||||
|
||||
<li class="field-group field-group-enrollment-end" id="enrollment-end">
|
||||
<div class="field date" id="field-enrollment-end-date">
|
||||
<label for="course-enrollment-end-date">Enrollment End Date</label>
|
||||
<label for="course-enrollment-end-date">${_("Enrollment End Date")}</label>
|
||||
<input type="text" class="end-date date end" id="course-enrollment-end-date" placeholder="MM/DD/YYYY" autocomplete="off" />
|
||||
<span class="tip tip-stacked">Last day students can enroll</span>
|
||||
<span class="tip tip-stacked">${_("Last day students can enroll")}</span>
|
||||
</div>
|
||||
|
||||
<div class="field time" id="field-enrollment-end-time">
|
||||
<label for="course-enrollment-end-time">Enrollment End Time</label>
|
||||
<label for="course-enrollment-end-time">${_("Enrollment End Time")}</label>
|
||||
<input type="text" class="time end" id="course-enrollment-end-time" value="" placeholder="HH:MM" autocomplete="off" />
|
||||
<span class="tip tip-stacked" id="timezone"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
% endif
|
||||
|
||||
% if not about_page_editable:
|
||||
<div class="notice notice-incontext notice-workflow">
|
||||
<h3 class="title">${_("These Dates Are Not Used When Promoting Your Course")}</h3>
|
||||
<div class="copy">
|
||||
<p>${_('These dates impact <strong>when your courseware can be viewed</strong>, but they are <strong>not the dates shown on your course summary page</strong>. To provide the course start and registration dates as shown on your course summary page, follow the instructions provided by your <abbr title="Program Manager">PM</abbr> or Conrad Warre <a rel="email" class="action action-email" href="mailto:conrad@edx.org">(conrad@edx.org)</a>.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
<hr class="divide" />
|
||||
% if about_page_editable:
|
||||
<section class="group-settings marketing">
|
||||
<header>
|
||||
<h2 class="title-2">${_("Introducing Your Course")}</h2>
|
||||
<span class="tip">${_("Information for prospective students")}</span>
|
||||
</header>
|
||||
|
||||
<section class="group-settings marketing">
|
||||
<header>
|
||||
<h2 class="title-2">Introducing Your Course</h2>
|
||||
<span class="tip">Information for prospective students</span>
|
||||
</header>
|
||||
<ol class="list-input">
|
||||
<li class="field text" id="field-course-overview">
|
||||
<label for="course-overview">${_("Course Overview")}</label>
|
||||
<textarea class="tinymce text-editor" id="course-overview"></textarea>
|
||||
<%def name='overview_text()'><%
|
||||
a_link_start = '<a class="link-courseURL" rel="external" href="'
|
||||
a_link_end = '">' + _("your course summary page") + '</a>'
|
||||
a_link = a_link_start + utils.get_lms_link_for_about_page(course_location) + a_link_end
|
||||
text = _("Introductions, prerequisites, FAQs that are used on %s (formatted in HTML)") % a_link
|
||||
%>${text}</%def>
|
||||
<span class="tip tip-stacked">${overview_text()}</span>
|
||||
</li>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text" id="field-course-overview">
|
||||
<label for="course-overview">Course Overview</label>
|
||||
<textarea class="tinymce text-editor" id="course-overview"></textarea>
|
||||
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a class="link-courseURL" rel="external" href="${utils.get_lms_link_for_about_page(course_location)}">your course summary page</a> (formatted in HTML)</span>
|
||||
</li>
|
||||
<li class="field video" id="field-course-introduction-video">
|
||||
<label for="course-overview">${_("Course Introduction Video")}</label>
|
||||
<div class="input input-existing">
|
||||
<div class="current current-course-introduction-video">
|
||||
<iframe width="618" height="350" src="" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="#" class="remove-item remove-course-introduction-video remove-video-data"><span class="delete-icon"></span>${_("Delete Current Video")}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li class="field video" id="field-course-introduction-video">
|
||||
<label for="course-overview">Course Introduction Video</label>
|
||||
<div class="input input-existing">
|
||||
<div class="current current-course-introduction-video">
|
||||
<iframe width="618" height="350" src="" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="#" class="remove-item remove-course-introduction-video remove-video-data"><span class="delete-icon"></span> Delete Current Video</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<input type="text" class="long new-course-introduction-video add-video-data" id="course-introduction-video" value="" placeholder="your YouTube video's ID" autocomplete="off" />
|
||||
<span class="tip tip-stacked">${_("Enter your YouTube video's ID (along with any restriction parameters)")}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<div class="input">
|
||||
<input type="text" class="long new-course-introduction-video add-video-data" id="course-introduction-video" value="" placeholder="your YouTube video's ID" autocomplete="off" />
|
||||
<span class="tip tip-stacked">Enter your YouTube video's ID (along with any restriction parameters)</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<hr class="divide" />
|
||||
|
||||
<hr class="divide" />
|
||||
<section class="group-settings requirements">
|
||||
<header>
|
||||
<h2 class="title-2">${_("Requirements")}</h2>
|
||||
<span class="tip">${_("Expectations of the students taking this course")}</span>
|
||||
</header>
|
||||
|
||||
<section class="group-settings requirements">
|
||||
<header>
|
||||
<h2 class="title-2">Requirements</h2>
|
||||
<span class="tip">Expectations of the students taking this course</span>
|
||||
</header>
|
||||
|
||||
<ol class="list-input">
|
||||
<li class="field text" id="field-course-effort">
|
||||
<label for="course-effort">Hours of Effort per Week</label>
|
||||
<input type="text" class="short time" id="course-effort" placeholder="HH:MM" />
|
||||
<span class="tip tip-inline">Time spent on all course work</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<ol class="list-input">
|
||||
<li class="field text" id="field-course-effort">
|
||||
<label for="course-effort">${_("Hours of Effort per Week")}</label>
|
||||
<input type="text" class="short time" id="course-effort" placeholder="HH:MM" />
|
||||
<span class="tip tip-inline">${_("Time spent on all course work")}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
% endif
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">How will these settings be used?</h3>
|
||||
<p>Your course's schedule settings determine when students can enroll in and begin a course as well as when the course.</p>
|
||||
<h3 class="title-3">${_("How will these settings be used?")}</h3>
|
||||
<p>${_("Your course's schedule settings determine when students can enroll in and begin a course.")}</p>
|
||||
|
||||
<p>Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.</p>
|
||||
<p>${_("Additionally, details provided on this page are also used in edX's catalog of courses, which new and returning students use to choose new courses to study.")}</p>
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<h3 class="title-3">Other Course Settings</h3>
|
||||
<h3 class="title-3">${_("Other Course Settings")}</h3>
|
||||
<nav class="nav-related">
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Grading</a></li>
|
||||
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">Course Team</a></li>
|
||||
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">Advanced Settings</a></li>
|
||||
<li class="nav-item"><a href="${reverse('contentstore.views.course_config_graders_page', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}">${_("Course Team")}</a></li>
|
||||
<li class="nav-item"><a href="${reverse('course_advanced_settings', kwargs={'org' : ctx_loc.org, 'course' : ctx_loc.course, 'name': ctx_loc.name})}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls import patterns, include, url
|
||||
|
||||
# Import this file so it can do its work, even though we don't use the name.
|
||||
# pylint: disable=W0611
|
||||
from . import one_time_startup
|
||||
|
||||
# Uncomment the next two lines to enable the admin:
|
||||
@@ -35,6 +38,8 @@ urlpatterns = ('', # nopep8
|
||||
'contentstore.views.preview_dispatch', name='preview_dispatch'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
|
||||
'contentstore.views.upload_asset', name='upload_asset'),
|
||||
|
||||
|
||||
url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'),
|
||||
url(r'^add_user/(?P<location>.*?)$',
|
||||
'contentstore.views.add_user', name='add_user'),
|
||||
@@ -71,8 +76,11 @@ urlpatterns = ('', # nopep8
|
||||
'contentstore.views.edit_static', name='edit_static'),
|
||||
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
|
||||
'contentstore.views.edit_tabs', name='edit_tabs'),
|
||||
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
|
||||
'contentstore.views.asset_index', name='asset_index'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$',
|
||||
'contentstore.views.assets.remove_asset', name='remove_asset'),
|
||||
|
||||
# this is a generic method to return the data/metadata associated with a xmodule
|
||||
url(r'^module_info/(?P<module_location>.*)$',
|
||||
|
||||
@@ -12,7 +12,6 @@ from django.core.cache import cache
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
|
||||
from . import app_settings
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
|
||||
def get_instance(model, instance_or_pk, timeout=None, using=None):
|
||||
|
||||
@@ -3,7 +3,6 @@ This file contains the logic for cohort groups, as exposed internally to the
|
||||
forums, and to the cohort admin views.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import Http404
|
||||
import logging
|
||||
import random
|
||||
@@ -27,7 +26,7 @@ def local_random():
|
||||
"""
|
||||
# ironic, isn't it?
|
||||
global _local_random
|
||||
|
||||
|
||||
if _local_random is None:
|
||||
_local_random = random.Random()
|
||||
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponse, HttpResponseForbidden, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.http import HttpResponse
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from courseware.courses import get_course_with_access
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from .models import CourseUserGroup
|
||||
from . import cohorts
|
||||
|
||||
import track.views
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
90
common/djangoapps/external_auth/migrations/0001_initial.py
Normal file
90
common/djangoapps/external_auth/migrations/0001_initial.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- 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 'ExternalAuthMap'
|
||||
db.create_table('external_auth_externalauthmap', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('external_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('external_domain', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('external_credentials', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
('external_email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('external_name', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, blank=True)),
|
||||
('user', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['auth.User'], unique=True, null=True)),
|
||||
('internal_password', self.gf('django.db.models.fields.CharField')(max_length=31, blank=True)),
|
||||
('dtcreated', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('dtsignup', self.gf('django.db.models.fields.DateTimeField')(null=True)),
|
||||
))
|
||||
db.send_create_signal('external_auth', ['ExternalAuthMap'])
|
||||
|
||||
# Adding unique constraint on 'ExternalAuthMap', fields ['external_id', 'external_domain']
|
||||
db.create_unique('external_auth_externalauthmap', ['external_id', 'external_domain'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'ExternalAuthMap', fields ['external_id', 'external_domain']
|
||||
db.delete_unique('external_auth_externalauthmap', ['external_id', 'external_domain'])
|
||||
|
||||
# Deleting model 'ExternalAuthMap'
|
||||
db.delete_table('external_auth_externalauthmap')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'external_auth.externalauthmap': {
|
||||
'Meta': {'unique_together': "(('external_id', 'external_domain'),)", 'object_name': 'ExternalAuthMap'},
|
||||
'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'dtsignup': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
|
||||
'external_credentials': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'external_domain': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'external_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'external_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'external_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'internal_password': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True', 'null': 'True'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['external_auth']
|
||||
405
common/djangoapps/external_auth/tests/test_shib.py
Normal file
405
common/djangoapps/external_auth/tests/test_shib.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Tests for Shibboleth Authentication
|
||||
@jbau
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.test.client import RequestFactory, Client as DjangoTestClient
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import AnonymousUser, User
|
||||
from django.contrib.sessions.backends.base import SessionBase
|
||||
from django.utils.importlib import import_module
|
||||
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
|
||||
from external_auth.models import ExternalAuthMap
|
||||
from external_auth.views import shib_login, course_specific_login, course_specific_register
|
||||
|
||||
from student.views import create_account, change_enrollment
|
||||
from student.models import UserProfile, Registration, CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
#Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider'
|
||||
#attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present
|
||||
#b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing
|
||||
|
||||
#For the sake of python convention we'll make all of these variable names ALL_CAPS
|
||||
IDP = 'https://idp.stanford.edu/'
|
||||
REMOTE_USER = 'test_user@stanford.edu'
|
||||
MAILS = [None, '', 'test_user@stanford.edu']
|
||||
GIVENNAMES = [None, '', 'Jason', 'jas\xc3\xb6n; John; bob'] # At Stanford, the givenNames can be a list delimited by ';'
|
||||
SNS = [None, '', 'Bau', '\xe5\x8c\x85; smith'] # At Stanford, the sns can be a list delimited by ';'
|
||||
|
||||
|
||||
def gen_all_identities():
|
||||
"""
|
||||
A generator for all combinations of test inputs.
|
||||
Each generated item is a dict that represents what a shib IDP
|
||||
could potentially pass to django via request.META, i.e.
|
||||
setting (or not) request.META['givenName'], etc.
|
||||
"""
|
||||
def _build_identity_dict(mail, given_name, surname):
|
||||
""" Helper function to return a dict of test identity """
|
||||
meta_dict = {'Shib-Identity-Provider': IDP,
|
||||
'REMOTE_USER': REMOTE_USER}
|
||||
if mail is not None:
|
||||
meta_dict['mail'] = mail
|
||||
if given_name is not None:
|
||||
meta_dict['givenName'] = given_name
|
||||
if surname is not None:
|
||||
meta_dict['sn'] = surname
|
||||
return meta_dict
|
||||
|
||||
for mail in MAILS:
|
||||
for given_name in GIVENNAMES:
|
||||
for surname in SNS:
|
||||
yield _build_identity_dict(mail, given_name, surname)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, SESSION_ENGINE='django.contrib.sessions.backends.cache')
|
||||
class ShibSPTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the Shibboleth SP, which communicates via request.META
|
||||
(Apache environment variables set by mod_shib)
|
||||
"""
|
||||
request_factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
self.store = modulestore()
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_exception_shib_login(self):
|
||||
"""
|
||||
Tests that we get the error page when there is no REMOTE_USER
|
||||
or Shib-Identity-Provider in request.META
|
||||
"""
|
||||
no_remote_user_request = self.request_factory.get('/shib-login')
|
||||
no_remote_user_request.META.update({'Shib-Identity-Provider': IDP})
|
||||
no_remote_user_response = shib_login(no_remote_user_request)
|
||||
self.assertEqual(no_remote_user_response.status_code, 403)
|
||||
self.assertIn("identity server did not return your ID information", no_remote_user_response.content)
|
||||
|
||||
no_idp_request = self.request_factory.get('/shib-login')
|
||||
no_idp_request.META.update({'REMOTE_USER': REMOTE_USER})
|
||||
no_idp_response = shib_login(no_idp_request)
|
||||
self.assertEqual(no_idp_response.status_code, 403)
|
||||
self.assertIn("identity server did not return your ID information", no_idp_response.content)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_shib_login(self):
|
||||
"""
|
||||
Tests that:
|
||||
* shib credentials that match an existing ExternalAuthMap with a linked user logs the user in
|
||||
* shib credentials that match an existing ExternalAuthMap without a linked user and also match the email
|
||||
of an existing user without an existing ExternalAuthMap links the two and log the user in
|
||||
* shib credentials that match an existing ExternalAuthMap without a linked user and also match the email
|
||||
of an existing user that already has an ExternalAuthMap causes an error (403)
|
||||
* shib credentials that do not match an existing ExternalAuthMap causes the registration form to appear
|
||||
"""
|
||||
|
||||
user_w_map = UserFactory.create(email='withmap@stanford.edu')
|
||||
extauth = ExternalAuthMap(external_id='withmap@stanford.edu',
|
||||
external_email='',
|
||||
external_domain='shib:https://idp.stanford.edu/',
|
||||
external_credentials="",
|
||||
user=user_w_map)
|
||||
user_wo_map = UserFactory.create(email='womap@stanford.edu')
|
||||
user_w_map.save()
|
||||
user_wo_map.save()
|
||||
extauth.save()
|
||||
|
||||
idps = ['https://idp.stanford.edu/', 'https://someother.idp.com/']
|
||||
remote_users = ['withmap@stanford.edu', 'womap@stanford.edu', 'testuser2@someother_idp.com']
|
||||
|
||||
for idp in idps:
|
||||
for remote_user in remote_users:
|
||||
request = self.request_factory.get('/shib-login')
|
||||
request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session
|
||||
request.META.update({'Shib-Identity-Provider': idp,
|
||||
'REMOTE_USER': remote_user,
|
||||
'mail': remote_user})
|
||||
request.user = AnonymousUser()
|
||||
response = shib_login(request)
|
||||
if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu':
|
||||
self.assertIsInstance(response, HttpResponseRedirect)
|
||||
self.assertEqual(request.user, user_w_map)
|
||||
self.assertEqual(response['Location'], '/')
|
||||
elif idp == "https://idp.stanford.edu/" and remote_user == 'womap@stanford.edu':
|
||||
self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map))
|
||||
self.assertIsInstance(response, HttpResponseRedirect)
|
||||
self.assertEqual(request.user, user_wo_map)
|
||||
self.assertEqual(response['Location'], '/')
|
||||
elif idp == "https://someother.idp.com/" and remote_user in \
|
||||
['withmap@stanford.edu', 'womap@stanford.edu']:
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn("You have already created an account using an external login", response.content)
|
||||
else:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "<title>Register for")
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_registration_form(self):
|
||||
"""
|
||||
Tests the registration form showing up with the proper parameters.
|
||||
|
||||
Uses django test client for its session support
|
||||
"""
|
||||
for identity in gen_all_identities():
|
||||
client = DjangoTestClient()
|
||||
# identity k/v pairs will show up in request.META
|
||||
response = client.get(path='/shib-login/', data={}, follow=False, **identity)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
mail_input_HTML = '<input class="" id="email" type="email" name="email"'
|
||||
if not identity.get('mail'):
|
||||
self.assertContains(response, mail_input_HTML)
|
||||
else:
|
||||
self.assertNotContains(response, mail_input_HTML)
|
||||
sn_empty = not identity.get('sn')
|
||||
given_name_empty = not identity.get('givenName')
|
||||
fullname_input_HTML = '<input id="name" type="text" name="name"'
|
||||
if sn_empty and given_name_empty:
|
||||
self.assertContains(response, fullname_input_HTML)
|
||||
else:
|
||||
self.assertNotContains(response, fullname_input_HTML)
|
||||
|
||||
#clean up b/c we don't want existing ExternalAuthMap for the next run
|
||||
client.session['ExternalAuthMap'].delete()
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_registration_formSubmit(self):
|
||||
"""
|
||||
Tests user creation after the registration form that pops is submitted. If there is no shib
|
||||
ExternalAuthMap in the session, then the created user should take the username and email from the
|
||||
request.
|
||||
|
||||
Uses django test client for its session support
|
||||
"""
|
||||
for identity in gen_all_identities():
|
||||
#First we pop the registration form
|
||||
client = DjangoTestClient()
|
||||
response1 = client.get(path='/shib-login/', data={}, follow=False, **identity)
|
||||
#Then we have the user answer the registration form
|
||||
postvars = {'email': 'post_email@stanford.edu',
|
||||
'username': 'post_username',
|
||||
'password': 'post_password',
|
||||
'name': 'post_name',
|
||||
'terms_of_service': 'true',
|
||||
'honor_code': 'true'}
|
||||
#use RequestFactory instead of TestClient here because we want access to request.user
|
||||
request2 = self.request_factory.post('/create_account', data=postvars)
|
||||
request2.session = client.session
|
||||
request2.user = AnonymousUser()
|
||||
response2 = create_account(request2)
|
||||
|
||||
user = request2.user
|
||||
mail = identity.get('mail')
|
||||
#check that the created user has the right email, either taken from shib or user input
|
||||
if mail:
|
||||
self.assertEqual(user.email, mail)
|
||||
self.assertEqual(list(User.objects.filter(email=postvars['email'])), [])
|
||||
self.assertIsNotNone(User.objects.get(email=mail)) # get enforces only 1 such user
|
||||
else:
|
||||
self.assertEqual(user.email, postvars['email'])
|
||||
self.assertEqual(list(User.objects.filter(email=mail)), [])
|
||||
self.assertIsNotNone(User.objects.get(email=postvars['email'])) # get enforces only 1 such user
|
||||
|
||||
#check that the created user profile has the right name, either taken from shib or user input
|
||||
profile = UserProfile.objects.get(user=user)
|
||||
sn_empty = not identity.get('sn')
|
||||
given_name_empty = not identity.get('givenName')
|
||||
if sn_empty and given_name_empty:
|
||||
self.assertEqual(profile.name, postvars['name'])
|
||||
else:
|
||||
self.assertEqual(profile.name, request2.session['ExternalAuthMap'].external_name)
|
||||
#clean up for next loop
|
||||
request2.session['ExternalAuthMap'].delete()
|
||||
UserProfile.objects.filter(user=user).delete()
|
||||
Registration.objects.filter(user=user).delete()
|
||||
user.delete()
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_course_specificLoginAndReg(self):
|
||||
"""
|
||||
Tests that the correct course specific login and registration urls work for shib
|
||||
"""
|
||||
course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course')
|
||||
|
||||
# Test for cases where course is found
|
||||
for domain in ["", "shib:https://idp.stanford.edu/"]:
|
||||
#set domains
|
||||
course.enrollment_domain = domain
|
||||
metadata = own_metadata(course)
|
||||
metadata['enrollment_domain'] = domain
|
||||
self.store.update_metadata(course.location.url(), metadata)
|
||||
|
||||
#setting location to test that GET params get passed through
|
||||
login_request = self.request_factory.get('/course_specific_login/MITx/999/Robot_Super_Course' +
|
||||
'?course_id=MITx/999/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
reg_request = self.request_factory.get('/course_specific_register/MITx/999/Robot_Super_Course' +
|
||||
'?course_id=MITx/999/course/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
login_response = course_specific_login(login_request, 'MITx/999/Robot_Super_Course')
|
||||
reg_response = course_specific_register(login_request, 'MITx/999/Robot_Super_Course')
|
||||
|
||||
if "shib" in domain:
|
||||
self.assertIsInstance(login_response, HttpResponseRedirect)
|
||||
self.assertEqual(login_response['Location'],
|
||||
reverse('shib-login') +
|
||||
'?course_id=MITx/999/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
self.assertIsInstance(login_response, HttpResponseRedirect)
|
||||
self.assertEqual(reg_response['Location'],
|
||||
reverse('shib-login') +
|
||||
'?course_id=MITx/999/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
else:
|
||||
self.assertIsInstance(login_response, HttpResponseRedirect)
|
||||
self.assertEqual(login_response['Location'],
|
||||
reverse('signin_user') +
|
||||
'?course_id=MITx/999/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
self.assertIsInstance(login_response, HttpResponseRedirect)
|
||||
self.assertEqual(reg_response['Location'],
|
||||
reverse('register_user') +
|
||||
'?course_id=MITx/999/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
# Now test for non-existent course
|
||||
#setting location to test that GET params get passed through
|
||||
login_request = self.request_factory.get('/course_specific_login/DNE/DNE/DNE' +
|
||||
'?course_id=DNE/DNE/DNE' +
|
||||
'&enrollment_action=enroll')
|
||||
reg_request = self.request_factory.get('/course_specific_register/DNE/DNE/DNE' +
|
||||
'?course_id=DNE/DNE/DNE/Robot_Super_Course' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
login_response = course_specific_login(login_request, 'DNE/DNE/DNE')
|
||||
reg_response = course_specific_register(login_request, 'DNE/DNE/DNE')
|
||||
|
||||
self.assertIsInstance(login_response, HttpResponseRedirect)
|
||||
self.assertEqual(login_response['Location'],
|
||||
reverse('signin_user') +
|
||||
'?course_id=DNE/DNE/DNE' +
|
||||
'&enrollment_action=enroll')
|
||||
self.assertIsInstance(login_response, HttpResponseRedirect)
|
||||
self.assertEqual(reg_response['Location'],
|
||||
reverse('register_user') +
|
||||
'?course_id=DNE/DNE/DNE' +
|
||||
'&enrollment_action=enroll')
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_enrollment_limit_by_domain(self):
|
||||
"""
|
||||
Tests that the enrollmentDomain setting is properly limiting enrollment to those who have
|
||||
the proper external auth
|
||||
"""
|
||||
|
||||
#create 2 course, one with limited enrollment one without
|
||||
shib_course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only')
|
||||
shib_course.enrollment_domain = 'shib:https://idp.stanford.edu/'
|
||||
metadata = own_metadata(shib_course)
|
||||
metadata['enrollment_domain'] = shib_course.enrollment_domain
|
||||
self.store.update_metadata(shib_course.location.url(), metadata)
|
||||
|
||||
open_enroll_course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course')
|
||||
open_enroll_course.enrollment_domain = ''
|
||||
metadata = own_metadata(open_enroll_course)
|
||||
metadata['enrollment_domain'] = open_enroll_course.enrollment_domain
|
||||
self.store.update_metadata(open_enroll_course.location.url(), metadata)
|
||||
|
||||
# create 3 kinds of students, external_auth matching shib_course, external_auth not matching, no external auth
|
||||
shib_student = UserFactory.create()
|
||||
shib_student.save()
|
||||
extauth = ExternalAuthMap(external_id='testuser@stanford.edu',
|
||||
external_email='',
|
||||
external_domain='shib:https://idp.stanford.edu/',
|
||||
external_credentials="",
|
||||
user=shib_student)
|
||||
extauth.save()
|
||||
|
||||
other_ext_student = UserFactory.create()
|
||||
other_ext_student.username = "teststudent2"
|
||||
other_ext_student.email = "teststudent2@other.edu"
|
||||
other_ext_student.save()
|
||||
extauth = ExternalAuthMap(external_id='testuser1@other.edu',
|
||||
external_email='',
|
||||
external_domain='shib:https://other.edu/',
|
||||
external_credentials="",
|
||||
user=other_ext_student)
|
||||
extauth.save()
|
||||
|
||||
int_student = UserFactory.create()
|
||||
int_student.username = "teststudent3"
|
||||
int_student.email = "teststudent3@gmail.com"
|
||||
int_student.save()
|
||||
|
||||
#Tests the two case for courses, limited and not
|
||||
for course in [shib_course, open_enroll_course]:
|
||||
for student in [shib_student, other_ext_student, int_student]:
|
||||
request = self.request_factory.post('/change_enrollment')
|
||||
request.POST.update({'enrollment_action': 'enroll',
|
||||
'course_id': course.id})
|
||||
request.user = student
|
||||
response = change_enrollment(request)
|
||||
#if course is not limited or student has correct shib extauth then enrollment should be allowed
|
||||
if course is open_enroll_course or student is shib_student:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
|
||||
#clean up
|
||||
CourseEnrollment.objects.filter(user=student, course_id=course.id).delete()
|
||||
else:
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
|
||||
|
||||
@unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True)
|
||||
def test_shib_login_enrollment(self):
|
||||
"""
|
||||
A functionality test that a student with an existing shib login can auto-enroll in a class with GET params
|
||||
"""
|
||||
if not settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
|
||||
return
|
||||
|
||||
student = UserFactory.create()
|
||||
extauth = ExternalAuthMap(external_id='testuser@stanford.edu',
|
||||
external_email='',
|
||||
external_domain='shib:https://idp.stanford.edu/',
|
||||
external_credentials="",
|
||||
internal_password="password",
|
||||
user=student)
|
||||
student.set_password("password")
|
||||
student.save()
|
||||
extauth.save()
|
||||
|
||||
course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only')
|
||||
course.enrollment_domain = 'shib:https://idp.stanford.edu/'
|
||||
metadata = own_metadata(course)
|
||||
metadata['enrollment_domain'] = course.enrollment_domain
|
||||
self.store.update_metadata(course.location.url(), metadata)
|
||||
|
||||
#use django test client for sessions and url processing
|
||||
#no enrollment before trying
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0)
|
||||
self.client.logout()
|
||||
request_kwargs = {'path': '/shib-login/',
|
||||
'data': {'enrollment_action': 'enroll', 'course_id': course.id},
|
||||
'follow': False,
|
||||
'REMOTE_USER': 'testuser@stanford.edu',
|
||||
'Shib-Identity-Provider': 'https://idp.stanford.edu/'}
|
||||
response = self.client.get(**request_kwargs)
|
||||
#successful login is a redirect to "/"
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response['location'], 'http://testserver/')
|
||||
#now there is enrollment
|
||||
self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1)
|
||||
@@ -6,17 +6,24 @@ import re
|
||||
import string
|
||||
import fnmatch
|
||||
|
||||
from textwrap import dedent
|
||||
from external_auth.models import ExternalAuthMap
|
||||
from external_auth.djangostore import DjangoOpenIDStore
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from student.models import UserProfile, TestCenterUser, TestCenterRegistration
|
||||
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest
|
||||
from django.utils.http import urlquote
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
try:
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -40,6 +47,7 @@ from courseware.model_data import ModelDataCache
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
log = logging.getLogger("mitx.external_auth")
|
||||
|
||||
@@ -137,13 +145,48 @@ def external_login_or_signup(request,
|
||||
|
||||
eamap.save()
|
||||
|
||||
log.info("External_Auth login_or_signup for %s : %s : %s : %s" % (external_domain, external_id, email, fullname))
|
||||
internal_user = eamap.user
|
||||
if internal_user is None:
|
||||
log.debug('No user for %s yet, doing signup' % eamap.external_email)
|
||||
return signup(request, eamap)
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
|
||||
# if we are using shib, try to link accounts using email
|
||||
try:
|
||||
link_user = User.objects.get(email=eamap.external_email)
|
||||
if not ExternalAuthMap.objects.filter(user=link_user).exists():
|
||||
# if there's no pre-existing linked eamap, we link the user
|
||||
eamap.user = link_user
|
||||
eamap.save()
|
||||
internal_user = link_user
|
||||
log.info('SHIB: Linking existing account for %s' % eamap.external_email)
|
||||
# now pass through to log in
|
||||
else:
|
||||
# otherwise, there must have been an error, b/c we've already linked a user with these external
|
||||
# creds
|
||||
failure_msg = _(dedent("""
|
||||
You have already created an account using an external login like WebAuth or Shibboleth.
|
||||
Please contact %s for support """
|
||||
% getattr(settings, 'TECH_SUPPORT_EMAIL', 'techsupport@class.stanford.edu')))
|
||||
return default_render_failure(request, failure_msg)
|
||||
except User.DoesNotExist:
|
||||
log.info('SHIB: No user for %s yet, doing signup' % eamap.external_email)
|
||||
return signup(request, eamap)
|
||||
else:
|
||||
log.info('No user for %s yet, doing signup' % eamap.external_email)
|
||||
return signup(request, eamap)
|
||||
|
||||
uname = internal_user.username
|
||||
user = authenticate(username=uname, password=eamap.internal_password)
|
||||
# We trust shib's authentication, so no need to authenticate using the password again
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB'):
|
||||
user = internal_user
|
||||
# Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe
|
||||
if settings.AUTHENTICATION_BACKENDS:
|
||||
auth_backend = settings.AUTHENTICATION_BACKENDS[0]
|
||||
else:
|
||||
auth_backend = 'django.contrib.auth.backends.ModelBackend'
|
||||
user.backend = auth_backend
|
||||
log.info('SHIB: Logging in linked user %s' % user.email)
|
||||
else:
|
||||
uname = internal_user.username
|
||||
user = authenticate(username=uname, password=eamap.internal_password)
|
||||
if user is None:
|
||||
log.warning("External Auth Login failed for %s / %s" %
|
||||
(uname, eamap.internal_password))
|
||||
@@ -154,10 +197,17 @@ def external_login_or_signup(request,
|
||||
# TODO: improve error page
|
||||
msg = 'Account not yet activated: please look for link in your email'
|
||||
return default_render_failure(request, msg)
|
||||
|
||||
login(request, user)
|
||||
request.session.set_expiry(0)
|
||||
student_views.try_change_enrollment(request)
|
||||
|
||||
# Now to try enrollment
|
||||
# Need to special case Shibboleth here because it logs in via a GET.
|
||||
# testing request.method for extra paranoia
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in external_domain and request.method == 'GET':
|
||||
enroll_request = make_shib_enrollment_request(request)
|
||||
student_views.try_change_enrollment(enroll_request)
|
||||
else:
|
||||
student_views.try_change_enrollment(request)
|
||||
log.info("Login success - {0} ({1})".format(user.username, user.email))
|
||||
if retfun is None:
|
||||
return redirect('/')
|
||||
@@ -188,14 +238,32 @@ def signup(request, eamap=None):
|
||||
|
||||
context = {'has_extauth_info': True,
|
||||
'show_signup_immediately': True,
|
||||
'extauth_id': eamap.external_id,
|
||||
'extauth_email': eamap.external_email,
|
||||
'extauth_username': username,
|
||||
'extauth_name': eamap.external_name,
|
||||
'ask_for_tos': True,
|
||||
}
|
||||
|
||||
log.debug('Doing signup for %s' % eamap.external_email)
|
||||
# Some openEdX instances can't have terms of service for shib users, like
|
||||
# according to Stanford's Office of General Counsel
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and \
|
||||
('shib' in eamap.external_domain):
|
||||
context['ask_for_tos'] = False
|
||||
|
||||
return student_views.index(request, extra_context=context)
|
||||
# detect if full name is blank and ask for it from user
|
||||
context['ask_for_fullname'] = eamap.external_name.strip() == ''
|
||||
|
||||
# validate provided mail and if it's not valid ask the user
|
||||
try:
|
||||
validate_email(eamap.external_email)
|
||||
context['ask_for_email'] = False
|
||||
except ValidationError:
|
||||
context['ask_for_email'] = True
|
||||
|
||||
log.info('EXTAUTH: Doing signup for %s' % eamap.external_id)
|
||||
|
||||
return student_views.register_user(request, extra_context=context)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -304,6 +372,127 @@ def ssl_login(request):
|
||||
retfun=retfun)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shibboleth (Stanford and others. Uses *Apache* environment variables)
|
||||
# -----------------------------------------------------------------------------
|
||||
def shib_login(request):
|
||||
"""
|
||||
Uses Apache's REMOTE_USER environment variable as the external id.
|
||||
This in turn typically uses EduPersonPrincipalName
|
||||
http://www.incommonfederation.org/attributesummary.html#eduPersonPrincipal
|
||||
but the configuration is in the shibboleth software.
|
||||
"""
|
||||
shib_error_msg = _(dedent(
|
||||
"""
|
||||
Your university identity server did not return your ID information to us.
|
||||
Please try logging in again. (You may need to restart your browser.)
|
||||
"""))
|
||||
|
||||
if not request.META.get('REMOTE_USER'):
|
||||
log.error("SHIB: no REMOTE_USER found in request.META")
|
||||
return default_render_failure(request, shib_error_msg)
|
||||
elif not request.META.get('Shib-Identity-Provider'):
|
||||
log.error("SHIB: no Shib-Identity-Provider in request.META")
|
||||
return default_render_failure(request, shib_error_msg)
|
||||
else:
|
||||
#if we get here, the user has authenticated properly
|
||||
shib = {attr: request.META.get(attr, '')
|
||||
for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider']}
|
||||
|
||||
#Clean up first name, last name, and email address
|
||||
#TODO: Make this less hardcoded re: format, but split will work
|
||||
#even if ";" is not present since we are accessing 1st element
|
||||
shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8')
|
||||
shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8')
|
||||
|
||||
log.info("SHIB creds returned: %r" % shib)
|
||||
|
||||
return external_login_or_signup(request,
|
||||
external_id=shib['REMOTE_USER'],
|
||||
external_domain="shib:" + shib['Shib-Identity-Provider'],
|
||||
credentials=shib,
|
||||
email=shib['mail'],
|
||||
fullname=u'%s %s' % (shib['givenName'], shib['sn']),
|
||||
)
|
||||
|
||||
|
||||
def make_shib_enrollment_request(request):
|
||||
"""
|
||||
Need this hack function because shibboleth logins don't happen over POST
|
||||
but change_enrollment expects its request to be a POST, with
|
||||
enrollment_action and course_id POST parameters.
|
||||
"""
|
||||
enroll_request = HttpRequest()
|
||||
enroll_request.user = request.user
|
||||
enroll_request.session = request.session
|
||||
enroll_request.method = "POST"
|
||||
|
||||
# copy() also makes GET and POST mutable
|
||||
# See https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.QueryDict.update
|
||||
enroll_request.GET = request.GET.copy()
|
||||
enroll_request.POST = request.POST.copy()
|
||||
|
||||
# also have to copy these GET parameters over to POST
|
||||
if "enrollment_action" not in enroll_request.POST and "enrollment_action" in enroll_request.GET:
|
||||
enroll_request.POST.setdefault('enrollment_action', enroll_request.GET.get('enrollment_action'))
|
||||
if "course_id" not in enroll_request.POST and "course_id" in enroll_request.GET:
|
||||
enroll_request.POST.setdefault('course_id', enroll_request.GET.get('course_id'))
|
||||
|
||||
return enroll_request
|
||||
|
||||
|
||||
def course_specific_login(request, course_id):
|
||||
"""
|
||||
Dispatcher function for selecting the specific login method
|
||||
required by the course
|
||||
"""
|
||||
query_string = request.META.get("QUERY_STRING", '')
|
||||
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
#couldn't find the course, will just return vanilla signin page
|
||||
return redirect_with_querystring('signin_user', query_string)
|
||||
|
||||
#now the dispatching conditionals. Only shib for now
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain:
|
||||
return redirect_with_querystring('shib-login', query_string)
|
||||
|
||||
#Default fallthrough to normal signin page
|
||||
return redirect_with_querystring('signin_user', query_string)
|
||||
|
||||
|
||||
def course_specific_register(request, course_id):
|
||||
"""
|
||||
Dispatcher function for selecting the specific registration method
|
||||
required by the course
|
||||
"""
|
||||
query_string = request.META.get("QUERY_STRING", '')
|
||||
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
#couldn't find the course, will just return vanilla registration page
|
||||
return redirect_with_querystring('register_user', query_string)
|
||||
|
||||
#now the dispatching conditionals. Only shib for now
|
||||
if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain:
|
||||
#shib-login takes care of both registration and login flows
|
||||
return redirect_with_querystring('shib-login', query_string)
|
||||
|
||||
#Default fallthrough to normal registration page
|
||||
return redirect_with_querystring('register_user', query_string)
|
||||
|
||||
|
||||
def redirect_with_querystring(view_name, query_string):
|
||||
"""
|
||||
Helper function to add query string to redirect views
|
||||
"""
|
||||
if query_string:
|
||||
return redirect("%s?%s" % (reverse(view_name), query_string))
|
||||
return redirect(view_name)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OpenID Provider
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.template.loaders.filesystem import Loader as FilesystemLoader
|
||||
from django.template.loaders.app_directories import Loader as AppDirectoriesLoader
|
||||
|
||||
from mitxmako.template import Template
|
||||
import mitxmako.middleware
|
||||
|
||||
import tempdir
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from django.conf import settings
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -11,12 +11,7 @@
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import os.path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from student.models import UserProfile
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
## See export for more info
|
||||
|
||||
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
import os.path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from student.models import UserProfile
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import os.path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import mitxmako.middleware as middleware
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
## A script to create some dummy users
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import UserProfile, CourseEnrollment
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from student.views import _do_create_account, get_random_post_override
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import os.path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import mitxmako.middleware as middleware
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import os.path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import mitxmako.middleware as middleware
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import os.path
|
||||
import time
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import mitxmako.middleware as middleware
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from optparse import make_option
|
||||
from json import dump
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from student.models import TestCenterRegistration
|
||||
|
||||
|
||||
@@ -3,11 +3,8 @@ import csv
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
from time import strptime, strftime
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from os.path import isdir
|
||||
from optparse import make_option
|
||||
from dogapi import dog_http_api, dog_stats_api
|
||||
from dogapi import dog_http_api
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
|
||||
@@ -26,7 +26,7 @@ class Command(BaseCommand):
|
||||
raise CommandError('Usage is set_staff {0}'.format(self.args))
|
||||
|
||||
for user in args:
|
||||
if re.match('[^@]+@[^@]+\.[^@]+', user):
|
||||
if re.match(r'[^@]+@[^@]+\.[^@]+', user):
|
||||
try:
|
||||
v = User.objects.get(email=user)
|
||||
except:
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.test import TestCase
|
||||
from django.core.management import call_command
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from student.models import User, TestCenterRegistration, TestCenterUser, get_testcenter_registration
|
||||
from student.models import User, TestCenterUser, get_testcenter_registration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import os.path
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
import mitxmako.middleware as middleware
|
||||
|
||||
@@ -3,8 +3,8 @@ import feedparser
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
import urllib
|
||||
import uuid
|
||||
import time
|
||||
@@ -20,9 +20,9 @@ from django.core.mail import send_mail
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import validate_email, validate_slug, ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseRedirect, Http404
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.utils.http import cookie_date
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
@@ -39,14 +39,13 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
from courseware.courses import get_courses, sort_by_announcement
|
||||
from courseware.access import has_access
|
||||
from courseware.views import get_module_for_descriptor, jump_to
|
||||
from courseware.model_data import ModelDataCache
|
||||
|
||||
from external_auth.models import ExternalAuthMap
|
||||
|
||||
from statsd import statsd
|
||||
from pytz import UTC
|
||||
@@ -99,9 +98,8 @@ def course_from_id(course_id):
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
return modulestore().get_instance(course_id, course_loc)
|
||||
|
||||
import re
|
||||
day_pattern = re.compile('\s\d+,\s')
|
||||
multimonth_pattern = re.compile('\s?\-\s?\S+\s')
|
||||
day_pattern = re.compile(r'\s\d+,\s')
|
||||
multimonth_pattern = re.compile(r'\s?\-\s?\S+\s')
|
||||
|
||||
|
||||
def get_date_for_press(publish_date):
|
||||
@@ -230,7 +228,7 @@ def signin_user(request):
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def register_user(request):
|
||||
def register_user(request, extra_context={}):
|
||||
"""
|
||||
This view will display the non-modal registration form
|
||||
"""
|
||||
@@ -241,6 +239,8 @@ def register_user(request):
|
||||
'course_id': request.GET.get('course_id'),
|
||||
'enrollment_action': request.GET.get('enrollment_action')
|
||||
}
|
||||
context.update(extra_context)
|
||||
|
||||
return render_to_response('register.html', context)
|
||||
|
||||
|
||||
@@ -282,9 +282,17 @@ def dashboard(request):
|
||||
|
||||
# Get the 3 most recent news
|
||||
top_news = _get_news(top=3) if not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) else None
|
||||
|
||||
# get info w.r.t ExternalAuthMap
|
||||
external_auth_map = None
|
||||
try:
|
||||
external_auth_map = ExternalAuthMap.objects.get(user=user)
|
||||
except ExternalAuthMap.DoesNotExist:
|
||||
pass
|
||||
|
||||
context = {'courses': courses,
|
||||
'message': message,
|
||||
'external_auth_map': external_auth_map,
|
||||
'staff_access': staff_access,
|
||||
'errored_courses': errored_courses,
|
||||
'show_courseware_links_for': show_courseware_links_for,
|
||||
@@ -571,15 +579,23 @@ def create_account(request, post_override=None):
|
||||
|
||||
# if doing signup for an external authorization, then get email, password, name from the eamap
|
||||
# don't use the ones from the form, since the user could have hacked those
|
||||
# unless originally we didn't get a valid email or name from the external auth
|
||||
DoExternalAuth = 'ExternalAuthMap' in request.session
|
||||
if DoExternalAuth:
|
||||
eamap = request.session['ExternalAuthMap']
|
||||
email = eamap.external_email
|
||||
name = eamap.external_name
|
||||
try:
|
||||
validate_email(eamap.external_email)
|
||||
email = eamap.external_email
|
||||
except ValidationError:
|
||||
email = post_vars.get('email', '')
|
||||
if eamap.external_name.strip() == '':
|
||||
name = post_vars.get('name', '')
|
||||
else:
|
||||
name = eamap.external_name
|
||||
password = eamap.internal_password
|
||||
post_vars = dict(post_vars.items())
|
||||
post_vars.update(dict(email=email, name=name, password=password))
|
||||
log.debug('extauth test: post_vars = %s' % post_vars)
|
||||
log.info('In create_account with external_auth: post_vars = %s' % post_vars)
|
||||
|
||||
# Confirm we have a properly formed request
|
||||
for a in ['username', 'email', 'password', 'name']:
|
||||
@@ -593,17 +609,28 @@ def create_account(request, post_override=None):
|
||||
js['field'] = 'honor_code'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
if post_vars.get('terms_of_service', 'false') != u'true':
|
||||
js['value'] = "You must accept the terms of service.".format(field=a)
|
||||
js['field'] = 'terms_of_service'
|
||||
return HttpResponse(json.dumps(js))
|
||||
# Can't have terms of service for certain SHIB users, like at Stanford
|
||||
tos_not_required = settings.MITX_FEATURES.get("AUTH_USE_SHIB") \
|
||||
and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') \
|
||||
and DoExternalAuth and ("shib" in eamap.external_domain)
|
||||
|
||||
if not tos_not_required:
|
||||
if post_vars.get('terms_of_service', 'false') != u'true':
|
||||
js['value'] = "You must accept the terms of service.".format(field=a)
|
||||
js['field'] = 'terms_of_service'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
# Confirm appropriate fields are there.
|
||||
# TODO: Check e-mail format is correct.
|
||||
# TODO: Confirm e-mail is not from a generic domain (mailinator, etc.)? Not sure if
|
||||
# this is a good idea
|
||||
# TODO: Check password is sane
|
||||
for a in ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code']:
|
||||
|
||||
required_post_vars = ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code']
|
||||
if tos_not_required:
|
||||
required_post_vars = ['username', 'email', 'name', 'password', 'honor_code']
|
||||
|
||||
for a in required_post_vars:
|
||||
if len(post_vars[a]) < 2:
|
||||
error_str = {'username': 'Username must be minimum of two characters long.',
|
||||
'email': 'A properly formatted e-mail is required.',
|
||||
@@ -665,19 +692,20 @@ def create_account(request, post_override=None):
|
||||
login(request, login_user)
|
||||
request.session.set_expiry(0)
|
||||
|
||||
try_change_enrollment(request)
|
||||
|
||||
if DoExternalAuth:
|
||||
eamap.user = login_user
|
||||
eamap.dtsignup = datetime.datetime.now(UTC)
|
||||
eamap.save()
|
||||
log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
|
||||
log.info("User registered with external_auth %s" % post_vars['username'])
|
||||
log.info('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
|
||||
|
||||
if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
|
||||
log.debug('bypassing activation email')
|
||||
log.info('bypassing activation email')
|
||||
login_user.is_active = True
|
||||
login_user.save()
|
||||
|
||||
try_change_enrollment(request)
|
||||
|
||||
statsd.increment("common.student.account_created")
|
||||
|
||||
js = {'success': True}
|
||||
|
||||
@@ -4,7 +4,6 @@ Browser set up for acceptance tests.
|
||||
|
||||
#pylint: disable=E1101
|
||||
#pylint: disable=W0613
|
||||
#pylint: disable=W0611
|
||||
|
||||
from lettuce import before, after, world
|
||||
from splinter.browser import Browser
|
||||
@@ -15,8 +14,9 @@ from selenium.common.exceptions import WebDriverException
|
||||
|
||||
# Let the LMS and CMS do their one-time setup
|
||||
# For example, setting up mongo caches
|
||||
from lms import one_time_startup
|
||||
from cms import one_time_startup
|
||||
# These names aren't used, but do important work on import.
|
||||
from lms import one_time_startup # pylint: disable=W0611
|
||||
from cms import one_time_startup # pylint: disable=W0611
|
||||
|
||||
# There is an import issue when using django-staticfiles with lettuce
|
||||
# Lettuce assumes that we are using django.contrib.staticfiles,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#pylint: disable=C0111
|
||||
#pylint: disable=W0621
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from lettuce import world
|
||||
from .factories import *
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
@@ -15,7 +15,6 @@ from xmodule.templates import update_templates
|
||||
from bs4 import BeautifulSoup
|
||||
import os.path
|
||||
from urllib import quote_plus
|
||||
from lettuce.django import django_url
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -15,13 +15,13 @@ from lettuce import world, step
|
||||
from .course_helpers import *
|
||||
from .ui_helpers import *
|
||||
from lettuce.django import django_url
|
||||
from nose.tools import assert_equals, assert_in
|
||||
from nose.tools import assert_equals
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@step(u'I wait (?:for )?"(\d+)" seconds?$')
|
||||
@step(r'I wait (?:for )?"(\d+)" seconds?$')
|
||||
def wait(step, seconds):
|
||||
world.wait(seconds)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from lettuce import world
|
||||
import time
|
||||
import platform
|
||||
from urllib import quote_plus
|
||||
from selenium.common.exceptions import WebDriverException, StaleElementReferenceException
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
@@ -48,7 +49,7 @@ def css_has_text(css_selector, text):
|
||||
|
||||
@world.absorb
|
||||
def css_find(css, wait_time=5):
|
||||
def is_visible(driver):
|
||||
def is_visible(_driver):
|
||||
return EC.visibility_of_element_located((By.CSS_SELECTOR, css,))
|
||||
|
||||
world.browser.is_element_present_by_css(css, wait_time=wait_time)
|
||||
@@ -57,32 +58,79 @@ def css_find(css, wait_time=5):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click(css_selector):
|
||||
def css_click(css_selector, index=0, max_attempts=5, success_condition=lambda: True):
|
||||
"""
|
||||
Perform a click on a CSS selector, retrying if it initially fails
|
||||
Perform a click on a CSS selector, retrying if it initially fails.
|
||||
|
||||
This function handles errors that may be thrown if the component cannot be clicked on.
|
||||
However, there are cases where an error may not be thrown, and yet the operation did not
|
||||
actually succeed. For those cases, a success_condition lambda can be supplied to verify that the click worked.
|
||||
|
||||
This function will return True if the click worked (taking into account both errors and the optional
|
||||
success_condition).
|
||||
"""
|
||||
assert is_css_present(css_selector)
|
||||
try:
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
|
||||
except WebDriverException:
|
||||
# Occassionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
world.wait(1)
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
attempt = 0
|
||||
result = False
|
||||
while attempt < max_attempts:
|
||||
try:
|
||||
world.css_find(css_selector)[index].click()
|
||||
if success_condition():
|
||||
result = True
|
||||
break
|
||||
except WebDriverException:
|
||||
# Occasionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
world.wait(1)
|
||||
attempt += 1
|
||||
except:
|
||||
attempt += 1
|
||||
return result
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click_at(css, x=10, y=10):
|
||||
def css_check(css_selector, index=0, max_attempts=5, success_condition=lambda: True):
|
||||
"""
|
||||
Checks a check box based on a CSS selector, retrying if it initially fails.
|
||||
|
||||
This function handles errors that may be thrown if the component cannot be clicked on.
|
||||
However, there are cases where an error may not be thrown, and yet the operation did not
|
||||
actually succeed. For those cases, a success_condition lambda can be supplied to verify that the check worked.
|
||||
|
||||
This function will return True if the check worked (taking into account both errors and the optional
|
||||
success_condition).
|
||||
"""
|
||||
assert is_css_present(css_selector)
|
||||
attempt = 0
|
||||
result = False
|
||||
while attempt < max_attempts:
|
||||
try:
|
||||
world.css_find(css_selector)[index].check()
|
||||
if success_condition():
|
||||
result = True
|
||||
break
|
||||
except WebDriverException:
|
||||
# Occasionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
world.wait(1)
|
||||
attempt += 1
|
||||
except:
|
||||
attempt += 1
|
||||
return result
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click_at(css, x_cord=10, y_cord=10):
|
||||
'''
|
||||
A method to click at x,y coordinates of the element
|
||||
rather than in the center of the element
|
||||
'''
|
||||
e = css_find(css).first
|
||||
e.action_chains.move_to_element_with_offset(e._element, x, y)
|
||||
e.action_chains.click()
|
||||
e.action_chains.perform()
|
||||
element = css_find(css).first
|
||||
element.action_chains.move_to_element_with_offset(element._element, x_cord, y_cord)
|
||||
element.action_chains.click()
|
||||
element.action_chains.perform()
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -127,7 +175,7 @@ def css_visible(css_selector):
|
||||
|
||||
@world.absorb
|
||||
def dialogs_closed():
|
||||
def are_dialogs_closed(driver):
|
||||
def are_dialogs_closed(_driver):
|
||||
'''
|
||||
Return True when no modal dialogs are visible
|
||||
'''
|
||||
@@ -138,12 +186,12 @@ def dialogs_closed():
|
||||
|
||||
@world.absorb
|
||||
def save_the_html(path='/tmp'):
|
||||
u = world.browser.url
|
||||
url = world.browser.url
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
filename = '%s.html' % quote_plus(u)
|
||||
f = open('%s/%s' % (path, filename), 'w')
|
||||
f.write(html)
|
||||
f.close()
|
||||
filename = '%s.html' % quote_plus(url)
|
||||
file = open('%s/%s' % (path, filename), 'w')
|
||||
file.write(html)
|
||||
file.close()
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -158,3 +206,8 @@ def click_tools():
|
||||
tools_css = 'li.nav-course-tools'
|
||||
if world.browser.is_element_present_by_css(tools_css):
|
||||
world.css_click(tools_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def is_mac():
|
||||
return platform.mac_ver()[0] is not ''
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import views
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pytz
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.conf import settings
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
@@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', '
|
||||
|
||||
|
||||
def log_event(event):
|
||||
"""Write tracking event to log file, and optionally to TrackingLog model."""
|
||||
event_str = json.dumps(event)
|
||||
log.info(event_str[:settings.TRACK_MAX_EVENT])
|
||||
if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
|
||||
@@ -34,6 +33,11 @@ def log_event(event):
|
||||
|
||||
|
||||
def user_track(request):
|
||||
"""
|
||||
Log when GET call to "event" URL is made by a user.
|
||||
|
||||
GET call should provide "event_type", "event", and "page" arguments.
|
||||
"""
|
||||
try: # TODO: Do the same for many of the optional META parameters
|
||||
username = request.user.username
|
||||
except:
|
||||
@@ -50,7 +54,6 @@ def user_track(request):
|
||||
except:
|
||||
agent = ''
|
||||
|
||||
# TODO: Move a bunch of this into log_event
|
||||
event = {
|
||||
"username": username,
|
||||
"session": scookie,
|
||||
@@ -68,6 +71,7 @@ def user_track(request):
|
||||
|
||||
|
||||
def server_track(request, event_type, event, page=None):
|
||||
"""Log events related to server requests."""
|
||||
try:
|
||||
username = request.user.username
|
||||
except:
|
||||
@@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None):
|
||||
log_event(event)
|
||||
|
||||
|
||||
def task_track(request_info, task_info, event_type, event, page=None):
|
||||
"""
|
||||
Logs tracking information for events occuring within celery tasks.
|
||||
|
||||
The `event_type` is a string naming the particular event being logged,
|
||||
while `event` is a dict containing whatever additional contextual information
|
||||
is desired.
|
||||
|
||||
The `request_info` is a dict containing information about the original
|
||||
task request. Relevant keys are `username`, `ip`, `agent`, and `host`.
|
||||
While the dict is required, the values in it are not, so that {} can be
|
||||
passed in.
|
||||
|
||||
In addition, a `task_info` dict provides more information about the current
|
||||
task, to be stored with the `event` dict. This may also be an empty dict.
|
||||
|
||||
The `page` parameter is optional, and allows the name of the page to
|
||||
be provided.
|
||||
"""
|
||||
|
||||
# supplement event information with additional information
|
||||
# about the task in which it is running.
|
||||
full_event = dict(event, **task_info)
|
||||
|
||||
# All fields must be specified, in case the tracking information is
|
||||
# also saved to the TrackingLog model. Get values from the task-level
|
||||
# information, or just add placeholder values.
|
||||
event = {
|
||||
"username": request_info.get('username', 'unknown'),
|
||||
"ip": request_info.get('ip', 'unknown'),
|
||||
"event_source": "task",
|
||||
"event_type": event_type,
|
||||
"event": full_event,
|
||||
"agent": request_info.get('agent', 'unknown'),
|
||||
"page": page,
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"host": request_info.get('host', 'unknown')
|
||||
}
|
||||
|
||||
log_event(event)
|
||||
|
||||
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def view_tracking_log(request, args=''):
|
||||
"""View to output contents of TrackingLog model. For staff use only."""
|
||||
if not request.user.is_staff:
|
||||
return redirect('/')
|
||||
nlen = 100
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests for memcache in util app
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.cache import get_cache
|
||||
from django.conf import settings
|
||||
from util.memcache import safe_key
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for the Zendesk"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import Http404
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import pprint
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.mail import send_mail
|
||||
from django.core.validators import ValidationError, validate_email
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError
|
||||
from django.shortcuts import redirect
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.http import Http404, HttpResponse, HttpResponseNotAllowed
|
||||
from dogapi import dog_stats_api
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from urllib import urlencode
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
import zendesk
|
||||
|
||||
import calc
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import static_replace
|
||||
|
||||
@@ -15,25 +15,22 @@ This is used by capa_module.
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import math
|
||||
import numpy
|
||||
import os.path
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
from xml.sax.saxutils import unescape
|
||||
from copy import deepcopy
|
||||
|
||||
from .correctmap import CorrectMap
|
||||
import inputtypes
|
||||
import customrender
|
||||
from .util import contextualize_text, convert_files_to_filenames
|
||||
import xqueue_interface
|
||||
from capa.correctmap import CorrectMap
|
||||
import capa.inputtypes as inputtypes
|
||||
import capa.customrender as customrender
|
||||
from capa.util import contextualize_text, convert_files_to_filenames
|
||||
import capa.xqueue_interface as xqueue_interface
|
||||
|
||||
# to be replaced with auto-registering
|
||||
import responsetypes
|
||||
import safe_exec
|
||||
import capa.responsetypes as responsetypes
|
||||
from capa.safe_exec import safe_exec
|
||||
|
||||
# dict of tagname, Response Class -- this should come from auto-registering
|
||||
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
|
||||
@@ -46,8 +43,8 @@ response_properties = ["codeparam", "responseparam", "answer", "openendedparam"]
|
||||
|
||||
# special problem tags which should be turned into innocuous HTML
|
||||
html_transforms = {'problem': {'tag': 'div'},
|
||||
"text": {'tag': 'span'},
|
||||
"math": {'tag': 'span'},
|
||||
'text': {'tag': 'span'},
|
||||
'math': {'tag': 'span'},
|
||||
}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
@@ -106,8 +103,8 @@ class LoncapaProblem(object):
|
||||
self.input_state = state.get('input_state', {})
|
||||
|
||||
# Convert startouttext and endouttext to proper <text></text>
|
||||
problem_text = re.sub("startouttext\s*/", "text", problem_text)
|
||||
problem_text = re.sub("endouttext\s*/", "/text", problem_text)
|
||||
problem_text = re.sub(r"startouttext\s*/", "text", problem_text)
|
||||
problem_text = re.sub(r"endouttext\s*/", "/text", problem_text)
|
||||
self.problem_text = problem_text
|
||||
|
||||
# parse problem XML file into an element tree
|
||||
@@ -134,7 +131,6 @@ class LoncapaProblem(object):
|
||||
|
||||
self.extracted_tree = self._extract_html(self.tree)
|
||||
|
||||
|
||||
def do_reset(self):
|
||||
'''
|
||||
Reset internal state to unfinished, with no answers
|
||||
@@ -175,7 +171,7 @@ class LoncapaProblem(object):
|
||||
Return the maximum score for this problem.
|
||||
'''
|
||||
maxscore = 0
|
||||
for response, responder in self.responders.iteritems():
|
||||
for responder in self.responders.values():
|
||||
maxscore += responder.get_max_score()
|
||||
return maxscore
|
||||
|
||||
@@ -220,7 +216,7 @@ class LoncapaProblem(object):
|
||||
def ungraded_response(self, xqueue_msg, queuekey):
|
||||
'''
|
||||
Handle any responses from the xqueue that do not contain grades
|
||||
Will try to pass the queue message to all inputtypes that can handle ungraded responses
|
||||
Will try to pass the queue message to all inputtypes that can handle ungraded responses
|
||||
|
||||
Does not return any value
|
||||
'''
|
||||
@@ -230,7 +226,6 @@ class LoncapaProblem(object):
|
||||
if hasattr(the_input, 'ungraded_response'):
|
||||
the_input.ungraded_response(xqueue_msg, queuekey)
|
||||
|
||||
|
||||
def is_queued(self):
|
||||
'''
|
||||
Returns True if any part of the problem has been submitted to an external queue
|
||||
@@ -238,7 +233,6 @@ class LoncapaProblem(object):
|
||||
'''
|
||||
return any(self.correct_map.is_queued(answer_id) for answer_id in self.correct_map)
|
||||
|
||||
|
||||
def get_recentmost_queuetime(self):
|
||||
'''
|
||||
Returns a DateTime object that represents the timestamp of the most recent
|
||||
@@ -256,11 +250,11 @@ class LoncapaProblem(object):
|
||||
|
||||
return max(queuetimes)
|
||||
|
||||
|
||||
def grade_answers(self, answers):
|
||||
'''
|
||||
Grade student responses. Called by capa_module.check_problem.
|
||||
answers is a dict of all the entries from request.POST, but with the first part
|
||||
|
||||
`answers` is a dict of all the entries from request.POST, but with the first part
|
||||
of each key removed (the string before the first "_").
|
||||
|
||||
Thus, for example, input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123
|
||||
@@ -270,24 +264,72 @@ class LoncapaProblem(object):
|
||||
|
||||
# if answers include File objects, convert them to filenames.
|
||||
self.student_answers = convert_files_to_filenames(answers)
|
||||
return self._grade_answers(answers)
|
||||
|
||||
def supports_rescoring(self):
|
||||
"""
|
||||
Checks that the current problem definition permits rescoring.
|
||||
|
||||
More precisely, it checks that there are no response types in
|
||||
the current problem that are not fully supported (yet) for rescoring.
|
||||
|
||||
This includes responsetypes for which the student's answer
|
||||
is not properly stored in state, i.e. file submissions. At present,
|
||||
we have no way to know if an existing response was actually a real
|
||||
answer or merely the filename of a file submitted as an answer.
|
||||
|
||||
It turns out that because rescoring is a background task, limiting
|
||||
it to responsetypes that don't support file submissions also means
|
||||
that the responsetypes are synchronous. This is convenient as it
|
||||
permits rescoring to be complete when the rescoring call returns.
|
||||
"""
|
||||
return all('filesubmission' not in responder.allowed_inputfields for responder in self.responders.values())
|
||||
|
||||
def rescore_existing_answers(self):
|
||||
"""
|
||||
Rescore student responses. Called by capa_module.rescore_problem.
|
||||
"""
|
||||
return self._grade_answers(None)
|
||||
|
||||
def _grade_answers(self, student_answers):
|
||||
"""
|
||||
Internal grading call used for checking new 'student_answers' and also
|
||||
rescoring existing student_answers.
|
||||
|
||||
For new student_answers being graded, `student_answers` is a dict of all the
|
||||
entries from request.POST, but with the first part of each key removed
|
||||
(the string before the first "_"). Thus, for example,
|
||||
input_ID123 -> ID123, and input_fromjs_ID123 -> fromjs_ID123.
|
||||
|
||||
For rescoring, `student_answers` is None.
|
||||
|
||||
Calls the Response for each question in this problem, to do the actual grading.
|
||||
"""
|
||||
# old CorrectMap
|
||||
oldcmap = self.correct_map
|
||||
|
||||
# start new with empty CorrectMap
|
||||
newcmap = CorrectMap()
|
||||
# log.debug('Responders: %s' % self.responders)
|
||||
|
||||
# Call each responsetype instance to do actual grading
|
||||
for responder in self.responders.values():
|
||||
# File objects are passed only if responsetype explicitly allows for file
|
||||
# submissions
|
||||
if 'filesubmission' in responder.allowed_inputfields:
|
||||
results = responder.evaluate_answers(answers, oldcmap)
|
||||
# File objects are passed only if responsetype explicitly allows
|
||||
# for file submissions. But we have no way of knowing if
|
||||
# student_answers contains a proper answer or the filename of
|
||||
# an earlier submission, so for now skip these entirely.
|
||||
# TODO: figure out where to get file submissions when rescoring.
|
||||
if 'filesubmission' in responder.allowed_inputfields and student_answers is None:
|
||||
raise Exception("Cannot rescore problems with possible file submissions")
|
||||
|
||||
# use 'student_answers' only if it is provided, and if it might contain a file
|
||||
# submission that would not exist in the persisted "student_answers".
|
||||
if 'filesubmission' in responder.allowed_inputfields and student_answers is not None:
|
||||
results = responder.evaluate_answers(student_answers, oldcmap)
|
||||
else:
|
||||
results = responder.evaluate_answers(convert_files_to_filenames(answers), oldcmap)
|
||||
results = responder.evaluate_answers(self.student_answers, oldcmap)
|
||||
newcmap.update(results)
|
||||
|
||||
self.correct_map = newcmap
|
||||
# log.debug('%s: in grade_answers, answers=%s, cmap=%s' % (self,answers,newcmap))
|
||||
return newcmap
|
||||
|
||||
def get_question_answers(self):
|
||||
@@ -331,7 +373,6 @@ class LoncapaProblem(object):
|
||||
html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
|
||||
return html
|
||||
|
||||
|
||||
def handle_input_ajax(self, get):
|
||||
'''
|
||||
InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data
|
||||
@@ -348,8 +389,6 @@ class LoncapaProblem(object):
|
||||
log.warning("Could not find matching input for id: %s" % input_id)
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
# ======= Private Methods Below ========
|
||||
|
||||
def _process_includes(self):
|
||||
@@ -359,16 +398,16 @@ class LoncapaProblem(object):
|
||||
'''
|
||||
includes = self.tree.findall('.//include')
|
||||
for inc in includes:
|
||||
file = inc.get('file')
|
||||
if file is not None:
|
||||
filename = inc.get('file')
|
||||
if filename is not None:
|
||||
try:
|
||||
# open using ModuleSystem OSFS filestore
|
||||
ifp = self.system.filestore.open(file)
|
||||
ifp = self.system.filestore.open(filename)
|
||||
except Exception as err:
|
||||
log.warning('Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)))
|
||||
log.warning('Cannot find file %s in %s' % (
|
||||
file, self.system.filestore))
|
||||
filename, self.system.filestore))
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): need real error handling, display to users
|
||||
if not self.system.get('DEBUG'):
|
||||
@@ -381,7 +420,7 @@ class LoncapaProblem(object):
|
||||
except Exception as err:
|
||||
log.warning('Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)))
|
||||
log.warning('Cannot parse XML in %s' % (file))
|
||||
log.warning('Cannot parse XML in %s' % (filename))
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): same as above
|
||||
if not self.system.get('DEBUG'):
|
||||
@@ -389,11 +428,11 @@ class LoncapaProblem(object):
|
||||
else:
|
||||
continue
|
||||
|
||||
# insert new XML into tree in place of inlcude
|
||||
# insert new XML into tree in place of include
|
||||
parent = inc.getparent()
|
||||
parent.insert(parent.index(inc), incxml)
|
||||
parent.remove(inc)
|
||||
log.debug('Included %s into %s' % (file, self.problem_id))
|
||||
log.debug('Included %s into %s' % (filename, self.problem_id))
|
||||
|
||||
def _extract_system_path(self, script):
|
||||
"""
|
||||
@@ -463,7 +502,7 @@ class LoncapaProblem(object):
|
||||
|
||||
if all_code:
|
||||
try:
|
||||
safe_exec.safe_exec(
|
||||
safe_exec(
|
||||
all_code,
|
||||
context,
|
||||
random_seed=self.seed,
|
||||
@@ -519,18 +558,18 @@ class LoncapaProblem(object):
|
||||
value = ""
|
||||
if self.student_answers and problemid in self.student_answers:
|
||||
value = self.student_answers[problemid]
|
||||
|
||||
|
||||
if input_id not in self.input_state:
|
||||
self.input_state[input_id] = {}
|
||||
|
||||
|
||||
# do the rendering
|
||||
state = {'value': value,
|
||||
'status': status,
|
||||
'id': input_id,
|
||||
'input_state': self.input_state[input_id],
|
||||
'feedback': {'message': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode, }}
|
||||
'status': status,
|
||||
'id': input_id,
|
||||
'input_state': self.input_state[input_id],
|
||||
'feedback': {'message': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode, }}
|
||||
|
||||
input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag)
|
||||
# save the input type so that we can make ajax calls on it if we need to
|
||||
@@ -554,7 +593,7 @@ class LoncapaProblem(object):
|
||||
for item in problemtree:
|
||||
item_xhtml = self._extract_html(item)
|
||||
if item_xhtml is not None:
|
||||
tree.append(item_xhtml)
|
||||
tree.append(item_xhtml)
|
||||
|
||||
if tree.tag in html_transforms:
|
||||
tree.tag = html_transforms[problemtree.tag]['tag']
|
||||
|
||||
@@ -10,10 +10,9 @@ import sys
|
||||
from path import path
|
||||
|
||||
from cStringIO import StringIO
|
||||
from collections import defaultdict
|
||||
|
||||
from .calc import UndefinedVariable
|
||||
from .capa_problem import LoncapaProblem
|
||||
from calc import UndefinedVariable
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from mako.lookup import TemplateLookup
|
||||
|
||||
logging.basicConfig(format="%(levelname)s %(message)s")
|
||||
|
||||
@@ -10,8 +10,6 @@ from .registry import TagRegistry
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex # for splitting quoted strings
|
||||
import json
|
||||
|
||||
from lxml import etree
|
||||
import xml.sax.saxutils as saxutils
|
||||
@@ -28,7 +26,7 @@ class MathRenderer(object):
|
||||
tags = ['math']
|
||||
|
||||
def __init__(self, system, xml):
|
||||
'''
|
||||
r'''
|
||||
Render math using latex-like formatting.
|
||||
|
||||
Examples:
|
||||
@@ -43,7 +41,7 @@ class MathRenderer(object):
|
||||
self.system = system
|
||||
self.xml = xml
|
||||
|
||||
mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
|
||||
mathstr = re.sub(r'\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
|
||||
mtag = 'mathjax'
|
||||
if not r'\displaystyle' in mathstr:
|
||||
mtag += 'inline'
|
||||
|
||||
@@ -856,7 +856,7 @@ class ImageInput(InputTypeBase):
|
||||
"""
|
||||
if value is of the form [x,y] then parse it and send along coordinates of previous answer
|
||||
"""
|
||||
m = re.match('\[([0-9]+),([0-9]+)]',
|
||||
m = re.match(r'\[([0-9]+),([0-9]+)]',
|
||||
self.value.strip().replace(' ', ''))
|
||||
if m:
|
||||
# Note: we subtract 15 to compensate for the size of the dot on the screen.
|
||||
|
||||
@@ -11,7 +11,6 @@ Used by capa_problem.py
|
||||
# standard library imports
|
||||
import abc
|
||||
import cgi
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -1903,8 +1902,7 @@ class ImageResponse(LoncapaResponse):
|
||||
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(' ', ''))
|
||||
m = re.match(r'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
|
||||
if not m:
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] '
|
||||
'error grading %s (input=%s)' % (aid, given))
|
||||
@@ -1919,7 +1917,7 @@ class ImageResponse(LoncapaResponse):
|
||||
# parse expected answer
|
||||
# TODO: Compile regexp on file load
|
||||
m = re.match(
|
||||
'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
r'[\(\[]([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' % (
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
"""
|
||||
Tests to verify that CorrectMap behaves correctly
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from capa.correctmap import CorrectMap
|
||||
import datetime
|
||||
|
||||
|
||||
class CorrectMapTest(unittest.TestCase):
|
||||
"""
|
||||
Tests to verify that CorrectMap behaves correctly
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.cmap = CorrectMap()
|
||||
|
||||
def test_set_input_properties(self):
|
||||
|
||||
# Set the correctmap properties for two inputs
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={'key':'secretstring',
|
||||
'time':'20130228100026'})
|
||||
self.cmap.set(
|
||||
answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={
|
||||
'key': 'secretstring',
|
||||
'time': '20130228100026'
|
||||
}
|
||||
)
|
||||
|
||||
self.cmap.set(answer_id='2_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None,
|
||||
msg=None,
|
||||
hint=None,
|
||||
hintmode=None,
|
||||
queuestate=None)
|
||||
self.cmap.set(
|
||||
answer_id='2_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None,
|
||||
msg=None,
|
||||
hint=None,
|
||||
hintmode=None,
|
||||
queuestate=None
|
||||
)
|
||||
|
||||
# Assert that each input has the expected properties
|
||||
self.assertTrue(self.cmap.is_correct('1_2_1'))
|
||||
@@ -62,7 +75,6 @@ class CorrectMapTest(unittest.TestCase):
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', ''))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None))
|
||||
|
||||
|
||||
def test_get_npoints(self):
|
||||
# Set the correctmap properties for 4 inputs
|
||||
# 1) correct, 5 points
|
||||
@@ -70,25 +82,35 @@ class CorrectMapTest(unittest.TestCase):
|
||||
# 3) incorrect, 5 points
|
||||
# 4) incorrect, None points
|
||||
# 5) correct, 0 points
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5)
|
||||
self.cmap.set(
|
||||
answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5
|
||||
)
|
||||
|
||||
self.cmap.set(answer_id='2_2_1',
|
||||
correctness='correct',
|
||||
npoints=None)
|
||||
self.cmap.set(
|
||||
answer_id='2_2_1',
|
||||
correctness='correct',
|
||||
npoints=None
|
||||
)
|
||||
|
||||
self.cmap.set(answer_id='3_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=5)
|
||||
self.cmap.set(
|
||||
answer_id='3_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=5
|
||||
)
|
||||
|
||||
self.cmap.set(answer_id='4_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None)
|
||||
self.cmap.set(
|
||||
answer_id='4_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None
|
||||
)
|
||||
|
||||
self.cmap.set(answer_id='5_2_1',
|
||||
correctness='correct',
|
||||
npoints=0)
|
||||
self.cmap.set(
|
||||
answer_id='5_2_1',
|
||||
correctness='correct',
|
||||
npoints=0
|
||||
)
|
||||
|
||||
# Assert that we get the expected points
|
||||
# If points assigned --> npoints
|
||||
@@ -100,7 +122,6 @@ class CorrectMapTest(unittest.TestCase):
|
||||
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
|
||||
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
|
||||
|
||||
|
||||
def test_set_overall_message(self):
|
||||
|
||||
# Default is an empty string string
|
||||
@@ -118,14 +139,18 @@ class CorrectMapTest(unittest.TestCase):
|
||||
|
||||
def test_update_from_correctmap(self):
|
||||
# Initialize a CorrectMap with some properties
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={'key':'secretstring',
|
||||
'time':'20130228100026'})
|
||||
self.cmap.set(
|
||||
answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={
|
||||
'key': 'secretstring',
|
||||
'time': '20130228100026'
|
||||
}
|
||||
)
|
||||
|
||||
self.cmap.set_overall_message("Test message")
|
||||
|
||||
@@ -133,14 +158,17 @@ class CorrectMapTest(unittest.TestCase):
|
||||
# as the first cmap
|
||||
other_cmap = CorrectMap()
|
||||
other_cmap.update(self.cmap)
|
||||
|
||||
|
||||
# Assert that it has all the same properties
|
||||
self.assertEqual(other_cmap.get_overall_message(),
|
||||
self.cmap.get_overall_message())
|
||||
|
||||
self.assertEqual(other_cmap.get_dict(),
|
||||
self.cmap.get_dict())
|
||||
self.assertEqual(
|
||||
other_cmap.get_overall_message(),
|
||||
self.cmap.get_overall_message()
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
other_cmap.get_dict(),
|
||||
self.cmap.get_dict()
|
||||
)
|
||||
|
||||
def test_update_from_invalid(self):
|
||||
# Should get an exception if we try to update() a CorrectMap
|
||||
|
||||
@@ -2,7 +2,6 @@ import unittest
|
||||
from lxml import etree
|
||||
import os
|
||||
import textwrap
|
||||
import json
|
||||
|
||||
import mock
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests of responsetypes
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from nose.plugins.skip import SkipTest
|
||||
import os
|
||||
import random
|
||||
import unittest
|
||||
@@ -56,9 +55,18 @@ class ResponseTest(unittest.TestCase):
|
||||
self.assertEqual(result, 'incorrect',
|
||||
msg="%s should be marked incorrect" % str(input_str))
|
||||
|
||||
def _get_random_number_code(self):
|
||||
"""Returns code to be used to generate a random result."""
|
||||
return "str(random.randint(0, 1e9))"
|
||||
|
||||
def _get_random_number_result(self, seed_value):
|
||||
"""Returns a result that should be generated using the random_number_code."""
|
||||
rand = random.Random(seed_value)
|
||||
return str(rand.randint(0, 1e9))
|
||||
|
||||
|
||||
class MultiChoiceResponseTest(ResponseTest):
|
||||
from response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
xml_factory_class = MultipleChoiceResponseXMLFactory
|
||||
|
||||
def test_multiple_choice_grade(self):
|
||||
@@ -80,7 +88,7 @@ class MultiChoiceResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class TrueFalseResponseTest(ResponseTest):
|
||||
from response_xml_factory import TrueFalseResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import TrueFalseResponseXMLFactory
|
||||
xml_factory_class = TrueFalseResponseXMLFactory
|
||||
|
||||
def test_true_false_grade(self):
|
||||
@@ -120,7 +128,7 @@ class TrueFalseResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class ImageResponseTest(ResponseTest):
|
||||
from response_xml_factory import ImageResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import ImageResponseXMLFactory
|
||||
xml_factory_class = ImageResponseXMLFactory
|
||||
|
||||
def test_rectangle_grade(self):
|
||||
@@ -184,7 +192,7 @@ class ImageResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class SymbolicResponseTest(ResponseTest):
|
||||
from response_xml_factory import SymbolicResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import SymbolicResponseXMLFactory
|
||||
xml_factory_class = SymbolicResponseXMLFactory
|
||||
|
||||
def test_grade_single_input(self):
|
||||
@@ -224,8 +232,8 @@ class SymbolicResponseTest(ResponseTest):
|
||||
|
||||
def test_complex_number_grade(self):
|
||||
problem = self.build_problem(math_display=True,
|
||||
expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]",
|
||||
options=["matrix", "imaginary"])
|
||||
expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]",
|
||||
options=["matrix", "imaginary"])
|
||||
|
||||
# For LaTeX-style inputs, symmath_check() will try to contact
|
||||
# a server to convert the input to MathML.
|
||||
@@ -312,16 +320,16 @@ class SymbolicResponseTest(ResponseTest):
|
||||
# Should not allow multiple inputs, since we specify
|
||||
# only one "expect" value
|
||||
with self.assertRaises(Exception):
|
||||
problem = self.build_problem(math_display=True,
|
||||
expect="2*x+3*y",
|
||||
num_inputs=3)
|
||||
self.build_problem(math_display=True,
|
||||
expect="2*x+3*y",
|
||||
num_inputs=3)
|
||||
|
||||
def _assert_symbolic_grade(self, problem,
|
||||
student_input,
|
||||
dynamath_input,
|
||||
expected_correctness):
|
||||
student_input,
|
||||
dynamath_input,
|
||||
expected_correctness):
|
||||
input_dict = {'1_2_1': str(student_input),
|
||||
'1_2_1_dynamath': str(dynamath_input)}
|
||||
'1_2_1_dynamath': str(dynamath_input)}
|
||||
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
@@ -330,7 +338,7 @@ class SymbolicResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class OptionResponseTest(ResponseTest):
|
||||
from response_xml_factory import OptionResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import OptionResponseXMLFactory
|
||||
xml_factory_class = OptionResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
@@ -350,7 +358,7 @@ class FormulaResponseTest(ResponseTest):
|
||||
"""
|
||||
Test the FormulaResponse class
|
||||
"""
|
||||
from response_xml_factory import FormulaResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import FormulaResponseXMLFactory
|
||||
xml_factory_class = FormulaResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
@@ -570,7 +578,7 @@ class FormulaResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class StringResponseTest(ResponseTest):
|
||||
from response_xml_factory import StringResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import StringResponseXMLFactory
|
||||
xml_factory_class = StringResponseXMLFactory
|
||||
|
||||
def test_case_sensitive(self):
|
||||
@@ -647,19 +655,18 @@ class StringResponseTest(ResponseTest):
|
||||
hintfn="gimme_a_random_hint",
|
||||
script=textwrap.dedent("""
|
||||
def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap):
|
||||
answer = str(random.randint(0, 1e9))
|
||||
answer = {code}
|
||||
new_cmap.set_hint_and_mode(answer_ids[0], answer, "always")
|
||||
|
||||
""")
|
||||
""".format(code=self._get_random_number_code()))
|
||||
)
|
||||
correct_map = problem.grade_answers({'1_2_1': '2'})
|
||||
hint = correct_map.get_hint('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(hint, str(r.randint(0, 1e9)))
|
||||
self.assertEqual(hint, self._get_random_number_result(problem.seed))
|
||||
|
||||
|
||||
class CodeResponseTest(ResponseTest):
|
||||
from response_xml_factory import CodeResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import CodeResponseXMLFactory
|
||||
xml_factory_class = CodeResponseXMLFactory
|
||||
|
||||
def setUp(self):
|
||||
@@ -673,6 +680,7 @@ class CodeResponseTest(ResponseTest):
|
||||
|
||||
@staticmethod
|
||||
def make_queuestate(key, time):
|
||||
"""Create queuestate dict"""
|
||||
timestr = datetime.strftime(time, dateformat)
|
||||
return {'key': key, 'time': timestr}
|
||||
|
||||
@@ -710,7 +718,7 @@ class CodeResponseTest(ResponseTest):
|
||||
old_cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, datetime.now())
|
||||
queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now())
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
|
||||
# Message format common to external graders
|
||||
@@ -771,7 +779,7 @@ class CodeResponseTest(ResponseTest):
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
latest_timestamp = datetime.now()
|
||||
queuestate = CodeResponseTest.make_queuestate(1000 + i, latest_timestamp)
|
||||
queuestate = CodeResponseTest.make_queuestate(queuekey, latest_timestamp)
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
@@ -796,7 +804,7 @@ class CodeResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class ChoiceResponseTest(ResponseTest):
|
||||
from response_xml_factory import ChoiceResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import ChoiceResponseXMLFactory
|
||||
xml_factory_class = ChoiceResponseXMLFactory
|
||||
|
||||
def test_radio_group_grade(self):
|
||||
@@ -828,7 +836,7 @@ class ChoiceResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class JavascriptResponseTest(ResponseTest):
|
||||
from response_xml_factory import JavascriptResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import JavascriptResponseXMLFactory
|
||||
xml_factory_class = JavascriptResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
@@ -858,7 +866,7 @@ class JavascriptResponseTest(ResponseTest):
|
||||
system.can_execute_unsafe_code = lambda: False
|
||||
|
||||
with self.assertRaises(LoncapaProblemError):
|
||||
problem = self.build_problem(
|
||||
self.build_problem(
|
||||
system=system,
|
||||
generator_src="test_problem_generator.js",
|
||||
grader_src="test_problem_grader.js",
|
||||
@@ -869,7 +877,7 @@ class JavascriptResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class NumericalResponseTest(ResponseTest):
|
||||
from response_xml_factory import NumericalResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import NumericalResponseXMLFactory
|
||||
xml_factory_class = NumericalResponseXMLFactory
|
||||
|
||||
def test_grade_exact(self):
|
||||
@@ -961,7 +969,7 @@ class NumericalResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class CustomResponseTest(ResponseTest):
|
||||
from response_xml_factory import CustomResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import CustomResponseXMLFactory
|
||||
xml_factory_class = CustomResponseXMLFactory
|
||||
|
||||
def test_inline_code(self):
|
||||
@@ -1000,15 +1008,14 @@ class CustomResponseTest(ResponseTest):
|
||||
|
||||
def test_inline_randomization(self):
|
||||
# Make sure the seed from the problem gets fed into the script execution.
|
||||
inline_script = """messages[0] = str(random.randint(0, 1e9))"""
|
||||
inline_script = "messages[0] = {code}".format(code=self._get_random_number_code())
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
correctmap = problem.grade_answers(input_dict)
|
||||
|
||||
input_msg = correctmap.get_msg('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(input_msg, str(r.randint(0, 1e9)))
|
||||
self.assertEqual(input_msg, self._get_random_number_result(problem.seed))
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
# For function code, we pass in these arguments:
|
||||
@@ -1241,25 +1248,23 @@ class CustomResponseTest(ResponseTest):
|
||||
def test_setup_randomization(self):
|
||||
# Ensure that the problem setup script gets the random seed from the problem.
|
||||
script = textwrap.dedent("""
|
||||
num = random.randint(0, 1e9)
|
||||
""")
|
||||
num = {code}
|
||||
""".format(code=self._get_random_number_code()))
|
||||
problem = self.build_problem(script=script)
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(r.randint(0, 1e9), problem.context['num'])
|
||||
self.assertEqual(problem.context['num'], self._get_random_number_result(problem.seed))
|
||||
|
||||
def test_check_function_randomization(self):
|
||||
# The check function should get random-seeded from the problem.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'ok': True, 'msg': str(random.randint(0, 1e9))}
|
||||
""")
|
||||
return {{'ok': True, 'msg': {code} }}
|
||||
""".format(code=self._get_random_number_code()))
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func", expect="42")
|
||||
input_dict = {'1_2_1': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(msg, str(r.randint(0, 1e9)))
|
||||
self.assertEqual(msg, self._get_random_number_result(problem.seed))
|
||||
|
||||
def test_module_imports_inline(self):
|
||||
'''
|
||||
@@ -1320,7 +1325,7 @@ class CustomResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class SchematicResponseTest(ResponseTest):
|
||||
from response_xml_factory import SchematicResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import SchematicResponseXMLFactory
|
||||
xml_factory_class = SchematicResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
@@ -1349,11 +1354,10 @@ class SchematicResponseTest(ResponseTest):
|
||||
|
||||
def test_check_function_randomization(self):
|
||||
# The check function should get a random seed from the problem.
|
||||
script = "correct = ['correct' if (submission[0]['num'] == random.randint(0, 1e9)) else 'incorrect']"
|
||||
script = "correct = ['correct' if (submission[0]['num'] == {code}) else 'incorrect']".format(code=self._get_random_number_code())
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
r = random.Random(problem.seed)
|
||||
submission_dict = {'num': r.randint(0, 1e9)}
|
||||
submission_dict = {'num': self._get_random_number_result(problem.seed)}
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
@@ -1372,7 +1376,7 @@ class SchematicResponseTest(ResponseTest):
|
||||
|
||||
|
||||
class AnnotationResponseTest(ResponseTest):
|
||||
from response_xml_factory import AnnotationResponseXMLFactory
|
||||
from capa.tests.response_xml_factory import AnnotationResponseXMLFactory
|
||||
xml_factory_class = AnnotationResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
@@ -1393,7 +1397,7 @@ class AnnotationResponseTest(ResponseTest):
|
||||
{'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null'}},
|
||||
]
|
||||
|
||||
for (index, test) in enumerate(tests):
|
||||
for test in tests:
|
||||
expected_correctness = test['correctness']
|
||||
expected_points = test['points']
|
||||
answers = test['answers']
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from calc import evaluator, UndefinedVariable
|
||||
from calc import evaluator
|
||||
from cmath import isinf
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
from __future__ import division
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
import logging
|
||||
import math
|
||||
import operator
|
||||
import re
|
||||
import numpy
|
||||
import numbers
|
||||
import scipy.constants
|
||||
|
||||
from pyparsing import (Literal, Keyword, Word, nums, StringEnd, Optional,
|
||||
Forward, OneOrMore, ParseException)
|
||||
from pyparsing import (Literal, StringEnd, OneOrMore, ParseException)
|
||||
import nltk
|
||||
from nltk.tree import Tree
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
# Provides sympy representation.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import string
|
||||
import re
|
||||
import logging
|
||||
@@ -25,8 +24,7 @@ from sympy.physics.quantum.state import *
|
||||
# from sympy.core.operations import LatticeOp
|
||||
# import sympy.physics.quantum.qubit
|
||||
|
||||
import urllib
|
||||
from xml.sax.saxutils import escape, unescape
|
||||
from xml.sax.saxutils import unescape
|
||||
import sympy
|
||||
import unicodedata
|
||||
from lxml import etree
|
||||
@@ -52,7 +50,7 @@ class dot(sympy.operations.LatticeOp): # my dot product
|
||||
|
||||
|
||||
def _print_dot(self, expr):
|
||||
return '{((%s) \cdot (%s))}' % (expr.args[0], expr.args[1])
|
||||
return r'{((%s) \cdot (%s))}' % (expr.args[0], expr.args[1])
|
||||
|
||||
LatexPrinter._print_dot = _print_dot
|
||||
|
||||
@@ -204,7 +202,7 @@ class formula(object):
|
||||
return xml
|
||||
|
||||
def preprocess_pmathml(self, xml):
|
||||
'''
|
||||
r'''
|
||||
Pre-process presentation MathML from ASCIIMathML to make it more
|
||||
acceptable for SnuggleTeX, and also to accomodate some sympy
|
||||
conventions (eg hat(i) for \hat{i}).
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
#
|
||||
# Takes in math expressions given as Presentation MathML (from ASCIIMathML), converts to Content MathML using SnuggleTeX
|
||||
|
||||
import os
|
||||
import sys
|
||||
import string
|
||||
import re
|
||||
import traceback
|
||||
from .formula import *
|
||||
import logging
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user