Commit 1b94050e by Jean Manuel Nater

Merge branch 'master' into jnater/courseware_tests

Conflicts:
	lms/djangoapps/open_ended_grading/tests.py
parents 9bfddd48 a36aee5b
...@@ -75,4 +75,6 @@ Frances Botsford <frances@edx.org> ...@@ -75,4 +75,6 @@ Frances Botsford <frances@edx.org>
Jonah Stanley <Jonah_Stanley@brown.edu> Jonah Stanley <Jonah_Stanley@brown.edu>
Slater Victoroff <slater.r.victoroff@gmail.com> Slater Victoroff <slater.r.victoroff@gmail.com>
Peter Fogg <peter.p.fogg@gmail.com> Peter Fogg <peter.p.fogg@gmail.com>
Renzo Lucioni <renzolucioni@gmail.com> Bethany LaPenta <lapentab@mit.edu>
\ No newline at end of file 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, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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 Studio, LMS: Make ModelTypes more strict about their expected content (for
instance, Boolean, Integer, String), but also allow them to hold either the 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, 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. 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: Video Alpha bug fix for speed changing to 1.0 in Firefox.
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
captions. 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: 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 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. ...@@ -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 Blades: For Video Alpha the events ready, play, pause, seek, and speed change
are logged on the server (in the logs). 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: Developers can now have private Django settings files.
Common: Safety code added to prevent anything above the vertical level in the Common: Safety code added to prevent anything above the vertical level in the
......
...@@ -4,3 +4,4 @@ gem 'sass', '3.1.15' ...@@ -4,3 +4,4 @@ gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6' gem 'bourbon', '~> 1.3.6'
gem 'colorize', '~> 0.5.8' gem 'colorize', '~> 0.5.8'
gem 'launchy', '~> 2.1.2' gem 'launchy', '~> 2.1.2'
gem 'sys-proctable', '~> 0.9.3'
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): ...@@ -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 Create all permission groups for a new course and subscribe the caller into those roles
''' '''
def create_all_course_groups(creator, location): def create_all_course_groups(creator, location):
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
create_new_course_group(creator, location, STAFF_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME)
...@@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role): ...@@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role):
return 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): 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 # remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all(): for user in instructors.user_set.all():
...@@ -75,13 +71,11 @@ def _delete_course_group(location): ...@@ -75,13 +71,11 @@ def _delete_course_group(location):
user.groups.remove(staff) user.groups.remove(staff)
user.save() 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): 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)) 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)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all(): for user in instructors.user_set.all():
......
...@@ -2,12 +2,8 @@ ...@@ -2,12 +2,8 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
from common import type_in_codemirror
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
"""
from selenium.webdriver.common.keys import Keys
KEY_CSS = '.key input.policy-key' KEY_CSS = '.key input.policy-key'
VALUE_CSS = 'textarea.json' VALUE_CSS = 'textarea.json'
...@@ -32,18 +28,20 @@ def i_am_on_advanced_course_settings(step): ...@@ -32,18 +28,20 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$') @step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name): def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower() 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$') @step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step): def edit_the_value_of_a_policy_key(step):
""" type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
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')
@step(u'I edit the value of a policy key and save$') @step(u'I edit the value of a policy key and save$')
...@@ -132,13 +130,5 @@ def change_display_name_value(step, new_value): ...@@ -132,13 +130,5 @@ def change_display_name_value(step, new_value):
def change_value(step, key, new_value): def change_value(step, key, new_value):
index = get_index_of(key) type_in_codemirror(get_index_of(key), new_value)
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)
press_the_notification_button(step, "Save") press_the_notification_button(step, "Save")
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_true from nose.tools import assert_true
...@@ -16,7 +16,7 @@ logger = getLogger(__name__) ...@@ -16,7 +16,7 @@ logger = getLogger(__name__)
########### STEP HELPERS ############## ########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$') @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 # To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001 # LETTUCE_SERVER_PORT = 8001
# in your settings.py file. # in your settings.py file.
...@@ -26,17 +26,17 @@ def i_visit_the_studio_homepage(step): ...@@ -26,17 +26,17 @@ def i_visit_the_studio_homepage(step):
@step('I am logged into Studio$') @step('I am logged into Studio$')
def i_am_logged_into_studio(step): def i_am_logged_into_studio(_step):
log_into_studio() log_into_studio()
@step('I confirm the alert$') @step('I confirm the alert$')
def i_confirm_with_ok(step): def i_confirm_with_ok(_step):
world.browser.get_alert().accept() world.browser.get_alert().accept()
@step(u'I press the "([^"]*)" delete icon$') @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': if category == 'section':
css = 'a.delete-button.delete-section-button span.delete-icon' css = 'a.delete-button.delete-section-button span.delete-icon'
elif category == 'subsection': elif category == 'subsection':
...@@ -47,7 +47,7 @@ def i_press_the_category_delete_icon(step, category): ...@@ -47,7 +47,7 @@ def i_press_the_category_delete_icon(step, category):
@step('I have opened a new course in Studio$') @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() open_new_course()
...@@ -73,7 +73,6 @@ def create_studio_user( ...@@ -73,7 +73,6 @@ def create_studio_user(
registration.register(studio_user) registration.register(studio_user)
registration.activate() registration.activate()
def fill_in_course_info( def fill_in_course_info(
name='Robot Super Course', name='Robot Super Course',
org='MITx', org='MITx',
...@@ -107,7 +106,7 @@ def log_into_studio( ...@@ -107,7 +106,7 @@ def log_into_studio(
def create_a_course(): 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 # Add the user to the instructor group of the course
# so they will have the permissions to see it in studio # 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): ...@@ -147,6 +146,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
world.css_fill(date_css, desired_date) world.css_fill(date_css, desired_date)
# hit TAB to get to the time field # hit TAB to get to the time field
e = world.css_find(date_css).first e = world.css_find(date_css).first
# pylint: disable=W0212
e._element.send_keys(Keys.TAB) e._element.send_keys(Keys.TAB)
world.css_fill(time_css, desired_time) world.css_fill(time_css, desired_time)
e = world.css_find(time_css).first e = world.css_find(time_css).first
...@@ -169,3 +169,24 @@ def open_new_unit(step): ...@@ -169,3 +169,24 @@ def open_new_unit(step):
step.given('I have added a new subsection') step.given('I have added a new subsection')
step.given('I expand the first section') step.given('I expand the first section')
world.css_click('a.new-unit-item') 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)
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"
#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 ...@@ -3,65 +3,71 @@ Feature: Problem Editor
Scenario: User can view metadata Scenario: User can view metadata
Given I have created a Blank Common Problem 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 Then I see five alphabetized settings and their expected values
And Edit High Level Source is not visible And Edit High Level Source is not visible
Scenario: User can modify String values Scenario: User can modify String values
Given I have created a Blank Common Problem 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 Then I can modify the display name
And my display name change is persisted on save And my display name change is persisted on save
Scenario: User can specify special characters in String values Scenario: User can specify special characters in String values
Given I have created a Blank Common Problem 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 Then I can specify special characters in the display name
And my special characters and persisted on save And my special characters and persisted on save
Scenario: User can revert display name to unset Scenario: User can revert display name to unset
Given I have created a Blank Common Problem 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 Then I can revert the display name to unset
And my display name is unset on save And my display name is unset on save
Scenario: User can select values in a Select Scenario: User can select values in a Select
Given I have created a Blank Common Problem 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 Then I can select Per Student for Randomization
And my change to randomization is persisted And my change to randomization is persisted
And I can revert to the default value for randomization And I can revert to the default value for randomization
Scenario: User can modify float input values Scenario: User can modify float input values
Given I have created a Blank Common Problem 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" Then I can set the weight to "3.5"
And my change to weight is persisted And my change to weight is persisted
And I can revert to the default value of unset for weight And I can revert to the default value of unset for weight
Scenario: User cannot type letters in float number field Scenario: User cannot type letters in float number field
Given I have created a Blank Common Problem 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 Then if I set the weight to "abc", it remains unset
Scenario: User cannot type decimal values integer number field Scenario: User cannot type decimal values integer number field
Given I have created a Blank Common Problem 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" 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 Scenario: User cannot type out of range values in an integer number field
Given I have created a Blank Common Problem 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" 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 Scenario: Settings changes are not saved on Cancel
Given I have created a Blank Common Problem 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" Then I can set the weight to "3.5"
And I can modify the display name And I can modify the display name
Then If I press Cancel my changes are not persisted Then If I press Cancel my changes are not persisted
Scenario: Edit High Level source is available for LaTeX problem Scenario: Edit High Level source is available for LaTeX problem
Given I have created a 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 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 @@ ...@@ -3,6 +3,7 @@
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_equal from nose.tools import assert_equal
from common import type_in_codemirror
DISPLAY_NAME = "Display Name" DISPLAY_NAME = "Display Name"
MAXIMUM_ATTEMPTS = "Maximum Attempts" MAXIMUM_ATTEMPTS = "Maximum Attempts"
...@@ -135,12 +136,12 @@ def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_att ...@@ -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') @step('Edit High Level Source is not visible')
def edit_high_level_source_not_visible(step): 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') @step('Edit High Level Source is visible')
def edit_high_level_source_visible(step): def edit_high_level_source_links_visible(step):
verify_high_level_source(step, True) verify_high_level_source_links(step, True)
@step('If I press Cancel my changes are not persisted') @step('If I press Cancel my changes are not persisted')
...@@ -153,13 +154,33 @@ def cancel_does_not_save_changes(step): ...@@ -153,13 +154,33 @@ def cancel_does_not_save_changes(step):
@step('I have created a LaTeX Problem') @step('I have created a LaTeX Problem')
def create_latex_problem(step): def create_latex_problem(step):
world.click_new_component_button(step, '.large-problem-icon') world.click_new_component_button(step, '.large-problem-icon')
# Go to advanced tab (waiting for the tab to be visible) # Go to advanced tab.
world.css_find('#ui-id-2')
world.css_click('#ui-id-2') world.css_click('#ui-id-2')
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') 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')) assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
world.cancel_component(step) world.cancel_component(step)
assert_equal(visible, world.is_css_present('.upload-button')) assert_equal(visible, world.is_css_present('.upload-button'))
...@@ -187,3 +208,8 @@ def verify_unset_display_name(): ...@@ -187,3 +208,8 @@ def verify_unset_display_name():
def set_weight(weight): def set_weight(weight):
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(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')
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
...@@ -8,7 +8,7 @@ from nose.tools import assert_equal ...@@ -8,7 +8,7 @@ from nose.tools import assert_equal
############### ACTIONS #################### ############### ACTIONS ####################
@step('I click the new section link$') @step('I click the New Section link$')
def i_click_new_section_link(_step): def i_click_new_section_link(_step):
link_css = 'a.new-courseware-section-button' link_css = 'a.new-courseware-section-button'
world.css_click(link_css) world.css_click(link_css)
......
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from common import *
......
...@@ -4,10 +4,20 @@ Feature: Video Component Editor ...@@ -4,10 +4,20 @@ Feature: Video Component Editor
Scenario: User can view metadata Scenario: User can view metadata
Given I have created a Video component Given I have created a Video component
And I edit and select Settings 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 Scenario: User can modify display name
Given I have created a Video component Given I have created a Video component
And I edit and select Settings And I edit and select Settings
Then I can modify the display name Then I can modify the display name
And my display name change is persisted on save 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 @@ ...@@ -4,6 +4,20 @@
from lettuce import world, step from lettuce import world, step
@step('I see only the video display name setting$') @step('I see the correct settings and default values$')
def i_see_only_the_video_display_name(step): def i_see_the_correct_settings_and_values(step):
world.verify_all_setting_entries([['Display Name', "default", True]]) 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 ...@@ -9,7 +9,16 @@ Feature: Video Component
Given I have clicked the new unit button Given I have clicked the new unit button
Then creating a video takes a single click 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 Given I have created a Video component
And I have hidden captions And I have hidden captions
Then when I view the video it does not show the 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 ...@@ -6,23 +6,28 @@ from lettuce import world, step
@step('when I view the video it does not have autoplay enabled') @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')[0]['data-autoplay'] == 'False'
assert world.css_find('.video_control')[0].has_class('play') assert world.css_find('.video_control')[0].has_class('play')
@step('creating a video takes a single click') @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')) assert(not world.is_css_present('.xmodule_VideoModule'))
world.css_click("a[data-location='i4x://edx/templates/video/default']") world.css_click("a[data-location='i4x://edx/templates/video/default']")
assert(world.is_css_present('.xmodule_VideoModule')) assert(world.is_css_present('.xmodule_VideoModule'))
@step('I have hidden captions') @step('I have (hidden|toggled) captions')
def set_show_captions_false(step): def hide_or_show_captions(step, shown):
world.css_click('a.hide-subtitles') button_css = 'a.hide-subtitles'
if shown == 'hidden':
world.css_click(button_css)
@step('when I view the video it does not show the captions') if shown == 'toggled':
def does_not_show_captions(step): world.css_click(button_css)
assert world.css_find('.video')[0].has_class('closed') # 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)
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)
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= ...@@ -39,10 +39,7 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
def set_module_info(store, location, post_data): def set_module_info(store, location, post_data):
module = None module = None
try: try:
if location.revision is None: module = store.get_item(location)
module = store.get_item(location)
else:
module = store.get_item(location)
except: except:
pass pass
......
...@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase): ...@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase):
modulestore = get_modulestore(self.course.location) modulestore = get_modulestore(self.course.location)
return modulestore.get_item(self.course.location).checklists return modulestore.get_item(self.course.location).checklists
def compare_checklists(self, persisted, request): def compare_checklists(self, persisted, request):
""" """
Handles url expansion as possible difference and descends into guts Handles url expansion as possible difference and descends into guts
......
"""
Tests for Studio Course Settings.
"""
import datetime import datetime
import json import json
import copy import copy
import mock
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test.client import Client from django.test.client import Client
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.timezone import UTC from django.utils.timezone import UTC
from django.test.utils import override_settings
from xmodule.modulestore import Location from xmodule.modulestore import Location
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
...@@ -21,6 +26,9 @@ from xmodule.fields import Date ...@@ -21,6 +26,9 @@ from xmodule.fields import Date
class CourseTestCase(ModuleStoreTestCase): class CourseTestCase(ModuleStoreTestCase):
"""
Base class for test classes below.
"""
def setUp(self): def setUp(self):
""" """
These tests need a user in the DB so that the django Test Client These tests need a user in the DB so that the django Test Client
...@@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase):
class CourseDetailsTestCase(CourseTestCase): class CourseDetailsTestCase(CourseTestCase):
"""
Tests the first course settings page (course dates, overview, etc.).
"""
def test_virgin_fetch(self): def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course_location) details = CourseDetails.fetch(self.course_location)
self.assertEqual(details.course_location, self.course_location, "Location not copied into") self.assertEqual(details.course_location, self.course_location, "Location not copied into")
...@@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase):
Test the encoder out of its original constrained purpose to see if it functions for general use 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']), details = {'location': Location(['tag', 'org', 'course', 'category', 'name']),
'number': 1, 'number': 1,
'string': 'string', 'string': 'string',
'datetime': datetime.datetime.now(UTC())} 'datetime': datetime.datetime.now(UTC())}
jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails) jsondetails = json.loads(jsondetails)
...@@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase): ...@@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails.effort, "After set effort" 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): 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): def alter_field(self, url, details, field, val):
setattr(details, field, val) setattr(details, field, val)
# Need to partially serialize payload b/c the mock doesn't handle it correctly # Need to partially serialize payload b/c the mock doesn't handle it correctly
...@@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase): ...@@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase):
class CourseGradingTest(CourseTestCase): class CourseGradingTest(CourseTestCase):
"""
Tests for the course settings grading page.
"""
def test_initial_grader(self): def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location) descriptor = get_modulestore(self.course_location).get_item(self.course_location)
test_grader = CourseGradingModel(descriptor) test_grader = CourseGradingModel(descriptor)
...@@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase): ...@@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase):
class CourseMetadataEditingTest(CourseTestCase): class CourseMetadataEditingTest(CourseTestCase):
"""
Tests for CourseMetadata.
"""
def setUp(self): def setUp(self):
CourseTestCase.setUp(self) CourseTestCase.setUp(self)
# add in the full class too # add in the full class too
......
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
......
...@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse ...@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse
from .utils import parse_json, user, registration from .utils import parse_json, user, registration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase 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): class ContentStoreTestCase(ModuleStoreTestCase):
...@@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase):
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302)
# Logged in should work. # 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): ...@@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore. @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. @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) course_tabs = copy.copy(course.tabs)
changed = False 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) tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel not in course_tabs: 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) course_tabs.append(tab_panel)
changed = True changed = True
return changed, course_tabs return changed, course_tabs
...@@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course): ...@@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore. @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. @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) course_tabs = copy.copy(course.tabs)
changed = False 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) tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel in course_tabs: 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] course_tabs = [ct for ct in course_tabs if ct != tab_panel]
changed = True changed = True
return changed, course_tabs return changed, course_tabs
...@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore ...@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.util.date_utils import get_default_time_display 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 ..utils import get_url_reverse
from .access import get_location_and_verify_access from .access import get_location_and_verify_access
...@@ -78,10 +80,17 @@ def asset_index(request, org, course, name): ...@@ -78,10 +80,17 @@ def asset_index(request, org, course, name):
'active_tab': 'assets', 'active_tab': 'assets',
'context_course': course_module, 'context_course': course_module,
'assets': asset_display, '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): def upload_asset(request, org, course, coursename):
''' '''
cdodge: this method allows for POST uploading of files into the course asset library, which will cdodge: this method allows for POST uploading of files into the course asset library, which will
...@@ -147,6 +156,57 @@ def upload_asset(request, org, course, coursename): ...@@ -147,6 +156,57 @@ def upload_asset(request, org, course, coursename):
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @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): def import_course(request, org, course, name):
location = get_location_and_verify_access(request, org, course, name) location = get_location_and_verify_access(request, org, course, name)
......
...@@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse ...@@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, \
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError InvalidLocationError
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update 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, \ ...@@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
import datetime import datetime
from django.utils.timezone import UTC from django.utils.timezone import UTC
# TODO: should explicitly enumerate exports with __all__
__all__ = ['course_index', 'create_new_course', 'course_info', __all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings', 'course_info_updates', 'get_course_settings',
'course_config_graders_page', 'course_config_graders_page',
...@@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name): ...@@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name):
kwargs={"org": org, kwargs={"org": org,
"course": course, "course": course,
"name": name, "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): ...@@ -103,7 +103,7 @@ def clone_item(request):
@expect_json @expect_json
def delete_item(request): def delete_item(request):
item_location = request.POST['id'] item_location = request.POST['id']
item_loc = Location(item_location) item_location = Location(item_location)
# check permissions for this user within this course # check permissions for this user within this course
if not has_access(request.user, item_location): if not has_access(request.user, item_location):
...@@ -124,11 +124,11 @@ def delete_item(request): ...@@ -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 # cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
if delete_all_versions: 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: for parent_loc in parent_locs:
parent = modulestore('direct').get_item(parent_loc) parent = modulestore('direct').get_item(parent_loc)
item_url = item_loc.url() item_url = item_location.url()
if item_url in parent.children: if item_url in parent.children:
children = parent.children children = parent.children
children.remove(item_url) children.remove(item_url)
......
...@@ -41,25 +41,25 @@ class CourseDetails(object): ...@@ -41,25 +41,25 @@ class CourseDetails(object):
course.enrollment_start = descriptor.enrollment_start course.enrollment_start = descriptor.enrollment_start
course.enrollment_end = descriptor.enrollment_end course.enrollment_end = descriptor.enrollment_end
temploc = course_location._replace(category='about', name='syllabus') temploc = course_location.replace(category='about', name='syllabus')
try: try:
course.syllabus = get_modulestore(temploc).get_item(temploc).data course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
pass pass
temploc = temploc._replace(name='overview') temploc = temploc.replace(name='overview')
try: try:
course.overview = get_modulestore(temploc).get_item(temploc).data course.overview = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
pass pass
temploc = temploc._replace(name='effort') temploc = temploc.replace(name='effort')
try: try:
course.effort = get_modulestore(temploc).get_item(temploc).data course.effort = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError: except ItemNotFoundError:
pass pass
temploc = temploc._replace(name='video') temploc = temploc.replace(name='video')
try: try:
raw_video = get_modulestore(temploc).get_item(temploc).data raw_video = get_modulestore(temploc).get_item(temploc).data
course.intro_video = CourseDetails.parse_video_tag(raw_video) course.intro_video = CourseDetails.parse_video_tag(raw_video)
...@@ -126,16 +126,16 @@ class CourseDetails(object): ...@@ -126,16 +126,16 @@ class CourseDetails(object):
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # 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. # 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']) update_item(temploc, jsondict['syllabus'])
temploc = temploc._replace(name='overview') temploc = temploc.replace(name='overview')
update_item(temploc, jsondict['overview']) update_item(temploc, jsondict['overview'])
temploc = temploc._replace(name='effort') temploc = temploc.replace(name='effort')
update_item(temploc, jsondict['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']) recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag) update_item(temploc, recomposed_video_tag)
...@@ -153,9 +153,9 @@ class CourseDetails(object): ...@@ -153,9 +153,9 @@ class CourseDetails(object):
if not raw_video: if not raw_video:
return None 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: 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: if keystring_matcher:
return keystring_matcher.group(0) return keystring_matcher.group(0)
...@@ -174,10 +174,10 @@ class CourseDetails(object): ...@@ -174,10 +174,10 @@ class CourseDetails(object):
return result 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): class CourseSettingsEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel): if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
return obj.__dict__ return obj.__dict__
elif isinstance(obj, Location): elif isinstance(obj, Location):
return obj.dict() return obj.dict()
......
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope from xblock.core import Scope
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
......
...@@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) ...@@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value 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 = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'], logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
...@@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR, ...@@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR,
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
AUTH_TOKENS = json.load(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_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES'] DATABASES = AUTH_TOKENS['DATABASES']
......
...@@ -21,7 +21,7 @@ Longer TODO: ...@@ -21,7 +21,7 @@ Longer TODO:
# We intentionally define lots of variables that aren't used, and # We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files # want to import all variables from base settings files
# pylint: disable=W0401, W0614 # pylint: disable=W0401, W0611, W0614
import sys import sys
import lms.envs.common import lms.envs.common
...@@ -32,13 +32,23 @@ from path import path ...@@ -32,13 +32,23 @@ from path import path
MITX_FEATURES = { MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True, 'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False, 'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False, 'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES': 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, '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 URL that shows information about the status of various services
'ENABLE_SERVICE_STATUS': False, 'ENABLE_SERVICE_STATUS': False,
...@@ -225,10 +235,10 @@ PIPELINE_JS = { ...@@ -225,10 +235,10 @@ PIPELINE_JS = {
'source_filenames': sorted( 'source_filenames': sorted(
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') + rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js') rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
) + ['js/hesitate.js', 'js/base.js', ) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
'js/models/feedback.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.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', 'output_filename': 'js/cms-application.js',
'test_order': 0 'test_order': 0
}, },
......
...@@ -43,10 +43,15 @@ CONTENTSTORE = { ...@@ -43,10 +43,15 @@ CONTENTSTORE = {
'OPTIONS': { 'OPTIONS': {
'host': 'localhost', 'host': 'localhost',
'db': 'xcontent', 'db': 'xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
} }
} }
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
...@@ -163,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False ...@@ -163,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
# Enable URL that shows information about the status of variuous services # Enable URL that shows information about the status of variuous services
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
# segment-io key for dev ############################# SEGMENT-IO ##################################
SEGMENT_IO_KEY = 'mty8edrrsg'
# 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 @@ ...@@ -7,9 +7,7 @@
# FORCE_SCRIPT_NAME = '/cms' # FORCE_SCRIPT_NAME = '/cms'
from .common import * from .common import *
from logsettings import get_logger_config
from .dev import * from .dev import *
import socket
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
......
...@@ -70,7 +70,13 @@ CONTENTSTORE = { ...@@ -70,7 +70,13 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': { 'OPTIONS': {
'host': 'localhost', '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 @@ ...@@ -7,6 +7,7 @@
"js/vendor/jquery.cookie.js", "js/vendor/jquery.cookie.js",
"js/vendor/json2.js", "js/vendor/json2.js",
"js/vendor/underscore-min.js", "js/vendor/underscore-min.js",
"js/vendor/underscore.string.min.js",
"js/vendor/backbone-min.js", "js/vendor/backbone-min.js",
"js/vendor/jquery.leanModal.min.js", "js/vendor/jquery.leanModal.min.js",
"js/vendor/sinon-1.7.1.js", "js/vendor/sinon-1.7.1.js",
......
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 -> ...@@ -18,79 +18,105 @@ beforeEach ->
else else
return trimmedText.indexOf(text) != -1; return trimmedText.indexOf(text) != -1;
describe "CMS.Views.Alert as base class", -> describe "CMS.Views.SystemFeedback", ->
beforeEach -> beforeEach ->
@model = new CMS.Models.ConfirmationMessage({ @options =
title: "Portal" title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center" 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 # 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()
it "renders on initalize", -> @hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough()
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", -> 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.$(".action-close")).toBeDefined()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
expect(view.$el).toContainText(@model.get("title")) expect(view.$el).toContainText(@options.title)
expect(view.$el).toContainText(@model.get("message")) expect(view.$el).toContainText(@options.message)
it "close button sends a .hide() message", -> it "close button sends a .hide() message", ->
spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough() view = new CMS.Views.Alert.Confirmation(@options).show()
view = new CMS.Views.Alert({model: @model})
view.$(".action-close").click() view.$(".action-close").click()
expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled() expect(@hideSpy).toHaveBeenCalled()
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
describe "CMS.Views.Prompt", -> 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 # 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 # just exercises the Prompt rather than asserting on anything. Best I can
# do for now. :( # do for now. :(
it "changes class on body", -> it "changes class on body", ->
# expect($("body")).not.toHaveClass("prompt-is-shown") # 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") # expect($("body")).toHaveClass("prompt-is-shown")
view.hide() view.hide()
# expect($("body")).not.toHaveClass("prompt-is-shown") # expect($("body")).not.toHaveClass("prompt-is-shown")
describe "CMS.Views.Alert click events", -> describe "CMS.Views.SystemFeedback click events", ->
beforeEach -> beforeEach ->
@model = new CMS.Models.WarningMessage( @primaryClickSpy = jasmine.createSpy('primaryClick')
@secondaryClickSpy = jasmine.createSpy('secondaryClick')
@view = new CMS.Views.Notification.Warning(
title: "Unsaved", title: "Unsaved",
message: "Your content is currently Unsaved.", message: "Your content is currently Unsaved.",
actions: actions:
primary: primary:
text: "Save", text: "Save",
class: "save-button", class: "save-button",
click: jasmine.createSpy('primaryClick') click: @primaryClickSpy
secondary: [{ secondary: [{
text: "Revert", text: "Revert",
class: "cancel-button", class: "cancel-button",
click: jasmine.createSpy('secondaryClick') click: @secondaryClickSpy
}] }]
) )
@view.show()
@view = new CMS.Views.Alert({model: @model})
it "should trigger the primary event on a primary click", -> it "should trigger the primary event on a primary click", ->
@view.primaryClick() @view.$(".action-primary").click()
expect(@model.get('actions').primary.click).toHaveBeenCalled() expect(@primaryClickSpy).toHaveBeenCalled()
expect(@secondaryClickSpy).not.toHaveBeenCalled()
it "should trigger the secondary event on a secondary click", -> it "should trigger the secondary event on a secondary click", ->
@view.secondaryClick() @view.$(".action-secondary").click()
expect(@model.get('actions').secondary[0].click).toHaveBeenCalled() expect(@secondaryClickSpy).toHaveBeenCalled()
expect(@primaryClickSpy).not.toHaveBeenCalled()
it "should apply class to primary action", -> it "should apply class to primary action", ->
expect(@view.$(".action-primary")).toHaveClass("save-button") expect(@view.$(".action-primary")).toHaveClass("save-button")
...@@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", -> ...@@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", ->
describe "CMS.Views.Notification minShown and maxShown", -> describe "CMS.Views.Notification minShown and maxShown", ->
beforeEach -> beforeEach ->
@model = new CMS.Models.SystemFeedback( @showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show')
intent: "saving" @showSpy.andCallThrough()
title: "Saving" @hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide')
) @hideSpy.andCallThrough()
spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough()
spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough()
@clock = sinon.useFakeTimers() @clock = sinon.useFakeTimers()
afterEach -> afterEach ->
@clock.restore() @clock.restore()
it "a minShown view should not hide too quickly", -> it "a minShown view should not hide too quickly", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000}) view = new CMS.Views.Notification.Saving({minShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
# call hide() on it, but the minShown should prevent it from hiding right away # 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", -> ...@@ -125,8 +149,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view should hide by itself", -> it "a maxShown view should hide by itself", ->
view = new CMS.Views.Notification({model: @model, maxShown: 1000}) view = new CMS.Views.Notification.Saving({maxShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
# wait for the maxShown timeout to expire, and check again # wait for the maxShown timeout to expire, and check again
...@@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a minShown view can stay visible longer", -> it "a minShown view can stay visible longer", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000}) view = new CMS.Views.Notification.Saving({minShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
# wait for the minShown timeout to expire, and check again # wait for the minShown timeout to expire, and check again
@clock.tick(1001) @clock.tick(1001)
expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled() expect(@hideSpy).not.toHaveBeenCalled()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
# can now hide immediately # can now hide immediately
...@@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view can hide early", -> it "a maxShown view can hide early", ->
view = new CMS.Views.Notification({model: @model, maxShown: 1000}) view = new CMS.Views.Notification.Saving({maxShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled() view.show()
expect(view.$('.wrapper')).toBeShown() expect(view.$('.wrapper')).toBeShown()
# wait 50 milliseconds, and hide it early # wait 50 milliseconds, and hide it early
...@@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", -> ...@@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding() expect(view.$('.wrapper')).toBeHiding()
it "a view can have both maxShown and minShown", -> 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 # can't hide early
@clock.tick(50) @clock.tick(50)
......
...@@ -18,11 +18,15 @@ $ -> ...@@ -18,11 +18,15 @@ $ ->
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) -> $(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false if ajaxSettings.notifyOnError is false
return 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") "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 = -> window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i navigator.userAgent.match /iPhone|iPod|iPad/i
......
...@@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View ...@@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View
[@metadataEditor.getDisplayName()]) [@metadataEditor.getDisplayName()])
@$el.find('.component-name').html(title) @$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: -> changedMetadata: ->
return @metadataEditor.getModifiedMetadataValues() return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
cloneTemplate: (parent, template) -> cloneTemplate: (parent, template) ->
$.post("/clone_item", { $.post("/clone_item", {
......
...@@ -32,8 +32,6 @@ $(document).ready(function() { ...@@ -32,8 +32,6 @@ $(document).ready(function() {
$modal.bind('click', hideModal); $modal.bind('click', hideModal);
$modalCover.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() { $body.on('click', '.embeddable-xml-input', function() {
$(this).select(); $(this).select();
...@@ -145,8 +143,6 @@ $(document).ready(function() { ...@@ -145,8 +143,6 @@ $(document).ready(function() {
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate); $('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate); $('.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 .edit-button', editSectionPublishDate);
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate); $body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate); $body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
...@@ -398,73 +394,6 @@ function _deleteItem($el) { ...@@ -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() { function markAsLoaded() {
$('.upload-modal .copy-button').css('display', 'inline-block'); $('.upload-modal .copy-button').css('display', 'inline-block');
$('.upload-modal .progress-bar').addClass('loaded'); $('.upload-modal .progress-bar').addClass('loaded');
......
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({ ...@@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({
}, },
showNotification: function() { showNotification: function() {
if(!this.msg) { if(!this.msg) {
this.msg = new CMS.Models.SystemFeedback({ this.msg = new CMS.Views.Notification.Saving({
intent: "saving", title: gettext("Saving&hellip;"),
title: gettext("Saving&hellip;")
});
}
if(!this.msgView) {
this.msgView = new CMS.Views.Notification({
model: this.msg,
closeIcon: false, closeIcon: false,
minShown: 1250 minShown: 1250
}); });
} }
this.msgView.show(); this.msg.show();
}, },
hideNotification: function() { hideNotification: function() {
if(!this.msgView) { return; } if(!this.msg) { return; }
this.msgView.hide(); this.msg.hide();
} }
}); });
$(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
});
}
CMS.Views.Alert = Backbone.View.extend({ CMS.Views.SystemFeedback = Backbone.View.extend({
options: { 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? shown: true, // is this view currently being shown?
icon: true, // should we render an icon related to the message intent? 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? 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) 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) 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() { 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(); var tpl = $("#system-feedback-tpl").text();
if(!tpl) { if(!tpl) {
console.error("Couldn't load system-feedback template"); console.error("Couldn't load system-feedback template");
} }
this.template = _.template(tpl); this.template = _.template(tpl);
this.setElement($("#page-"+this.options.type)); 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; return this;
}, },
events: { // public API: show() and hide()
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
show: function() { show: function() {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.options.shown = true; this.options.shown = true;
this.shownAt = new Date(); this.shownAt = new Date();
this.render(); this.render();
if($.isNumeric(this.options.maxShown)) { if($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout($.proxy(this.hide, this), this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown); this.options.maxShown);
} }
return this; return this;
...@@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({ ...@@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({
this.options.minShown > new Date() - this.shownAt) this.options.minShown > new Date() - this.shownAt)
{ {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout($.proxy(this.hide, this), this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt)); this.options.minShown - (new Date() - this.shownAt));
} else { } else {
this.options.shown = false; this.options.shown = false;
...@@ -52,40 +77,64 @@ CMS.Views.Alert = Backbone.View.extend({ ...@@ -52,40 +77,64 @@ CMS.Views.Alert = Backbone.View.extend({
} }
return this; return this;
}, },
primaryClick: function() { // the rest of the API should be considered semi-private
var actions = this.model.get("actions"); 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; } if(!actions) { return; }
var primary = actions.primary; var primary = actions.primary;
if(!primary) { return; } if(!primary) { return; }
if(primary.click) { if(primary.click) {
primary.click.call(this.model, this); primary.click.call(event.target, this, event);
} }
}, },
secondaryClick: function(e) { secondaryClick: function(event) {
var actions = this.model.get("actions"); var actions = this.options.actions;
if(!actions) { return; } if(!actions) { return; }
var secondaryList = actions.secondary; var secondaryList = actions.secondary;
if(!secondaryList) { return; } if(!secondaryList) { return; }
// which secondary action was clicked? // which secondary action was clicked?
var i = 0; // default to the first secondary action (easier for testing) var i = 0; // default to the first secondary action (easier for testing)
if(e && e.target) { if(event && event.target) {
i = _.indexOf(this.$(".action-secondary"), e.target); i = _.indexOf(this.$(".action-secondary"), event.target);
} }
var secondary = this.model.get("actions").secondary[i]; var secondary = secondaryList[i];
if(secondary.click) { if(secondary.click) {
secondary.click.call(this.model, this); secondary.click.call(event.target, this, event);
} }
} }
}); });
CMS.Views.Notification = CMS.Views.Alert.extend({ CMS.Views.Alert = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, { 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", type: "notification",
closeIcon: false closeIcon: false
}) })
}); });
CMS.Views.Prompt = CMS.Views.Alert.extend({ CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, { options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "prompt", type: "prompt",
closeIcon: false, closeIcon: false,
icon: false icon: false
...@@ -98,6 +147,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({ ...@@ -98,6 +147,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({
$body.removeClass('prompt-is-shown'); $body.removeClass('prompt-is-shown');
} }
// super() in Javascript has awkward syntax :( // 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({ ...@@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({
showInvalidMessage: function(model, error, options) { showInvalidMessage: function(model, error, options) {
model.set("name", model.previous("name")); model.set("name", model.previous("name"));
var that = this; var that = this;
var msg = new CMS.Models.ErrorMessage({ var prompt = new CMS.Views.Prompt.Error({
title: gettext("Your change could not be saved"), title: gettext("Your change could not be saved"),
message: error, message: error,
actions: { actions: {
...@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({ ...@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({
} }
} }
}); });
new CMS.Views.Prompt({model: msg}); prompt.show();
} }
}); });
// studio - elements - system help // 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 { ...@@ -76,6 +76,10 @@ body.course.uploads {
width: 250px; width: 250px;
} }
.delete-col {
width: 20px;
}
.embeddable-xml-input { .embeddable-xml-input {
@include box-shadow(none); @include box-shadow(none);
width: 100%; width: 100%;
......
...@@ -21,7 +21,7 @@ body.course.settings { ...@@ -21,7 +21,7 @@ body.course.settings {
font-size: 14px; font-size: 14px;
} }
.message-status { .message-status {
display: none; display: none;
@include border-top-radius(2px); @include border-top-radius(2px);
@include box-sizing(border-box); @include box-sizing(border-box);
...@@ -52,6 +52,12 @@ body.course.settings { ...@@ -52,6 +52,12 @@ body.course.settings {
} }
} }
// notices - used currently for edx mktg
.notice-workflow {
margin-top: ($baseline);
}
// in form - elements // in form - elements
.group-settings { .group-settings {
margin: 0 0 ($baseline*2) 0; margin: 0 0 ($baseline*2) 0;
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%block name="bodyclass">is-signedin course uploads</%block> <%block name="bodyclass">is-signedin course uploads</%block>
<%block name="title">Files &amp; Uploads</%block> <%block name="title">Files &amp; Uploads</%block>
...@@ -30,6 +31,9 @@ ...@@ -30,6 +31,9 @@
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly> <input type="text" class="embeddable-xml-input" value='{{url}}' readonly>
</td> </td>
<td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td>
</tr> </tr>
</script> </script>
...@@ -56,7 +60,7 @@ ...@@ -56,7 +60,7 @@
<div class="page-actions"> <div class="page-actions">
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/> <input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
</div> </div>
<article class="asset-library"> <article class="asset-library" data-remove-asset-callback-url='${remove_asset_callback_url}'>
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -64,6 +68,7 @@ ...@@ -64,6 +68,7 @@
<th class="name-col">Name</th> <th class="name-col">Name</th>
<th class="date-col">Date Added</th> <th class="date-col">Date Added</th>
<th class="embed-col">URL</th> <th class="embed-col">URL</th>
<th class="delete-col"></th>
</tr> </tr>
</thead> </thead>
<tbody id="asset_table_body"> <tbody id="asset_table_body">
...@@ -86,6 +91,9 @@ ...@@ -86,6 +91,9 @@
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly> <input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly>
</td> </td>
<td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td>
</tr> </tr>
% endfor % endfor
</tbody> </tbody>
...@@ -129,3 +137,21 @@ ...@@ -129,3 +137,21 @@
</%block> </%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 @@ ...@@ -38,6 +38,7 @@
<script type="text/javascript" src="/jsi18n/"></script> <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/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-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/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/jquery.markitup.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script>
...@@ -54,7 +55,6 @@ ...@@ -54,7 +55,6 @@
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script> <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 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> <script src="${static.url('js/views/feedback.js')}"></script>
<!-- view --> <!-- view -->
......
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 please copy and paste this address into your web browser's
address bar: address bar:
......
from django.conf import settings from django.conf import settings
from django.conf.urls import patterns, include, url 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 from . import one_time_startup
# Uncomment the next two lines to enable the admin: # Uncomment the next two lines to enable the admin:
...@@ -35,6 +38,8 @@ urlpatterns = ('', # nopep8 ...@@ -35,6 +38,8 @@ urlpatterns = ('', # nopep8
'contentstore.views.preview_dispatch', name='preview_dispatch'), 'contentstore.views.preview_dispatch', name='preview_dispatch'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
'contentstore.views.upload_asset', name='upload_asset'), 'contentstore.views.upload_asset', name='upload_asset'),
url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'), url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'),
url(r'^add_user/(?P<location>.*?)$', url(r'^add_user/(?P<location>.*?)$',
'contentstore.views.add_user', name='add_user'), 'contentstore.views.add_user', name='add_user'),
...@@ -71,8 +76,11 @@ urlpatterns = ('', # nopep8 ...@@ -71,8 +76,11 @@ urlpatterns = ('', # nopep8
'contentstore.views.edit_static', name='edit_static'), 'contentstore.views.edit_static', name='edit_static'),
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
'contentstore.views.edit_tabs', name='edit_tabs'), 'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
'contentstore.views.asset_index', name='asset_index'), '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 # this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$', url(r'^module_info/(?P<module_location>.*)$',
......
...@@ -12,7 +12,6 @@ from django.core.cache import cache ...@@ -12,7 +12,6 @@ from django.core.cache import cache
from django.db import DEFAULT_DB_ALIAS from django.db import DEFAULT_DB_ALIAS
from . import app_settings from . import app_settings
from xmodule.contentstore.content import StaticContent
def get_instance(model, instance_or_pk, timeout=None, using=None): 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 ...@@ -3,7 +3,6 @@ This file contains the logic for cohort groups, as exposed internally to the
forums, and to the cohort admin views. forums, and to the cohort admin views.
""" """
from django.contrib.auth.models import User
from django.http import Http404 from django.http import Http404
import logging import logging
import random import random
...@@ -27,7 +26,7 @@ def local_random(): ...@@ -27,7 +26,7 @@ def local_random():
""" """
# ironic, isn't it? # ironic, isn't it?
global _local_random global _local_random
if _local_random is None: if _local_random is None:
_local_random = random.Random() _local_random = random.Random()
......
from django_future.csrf import ensure_csrf_cookie 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.views.decorators.http import require_POST
from django.contrib.auth.models import User 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.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseForbidden, Http404 from django.http import HttpResponse
from django.shortcuts import redirect
import json import json
import logging import logging
import re import re
from courseware.courses import get_course_with_access 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 from . import cohorts
import track.views
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
# -*- 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']
\ No newline at end of file
...@@ -7,7 +7,6 @@ from django.template.loaders.filesystem import Loader as FilesystemLoader ...@@ -7,7 +7,6 @@ from django.template.loaders.filesystem import Loader as FilesystemLoader
from django.template.loaders.app_directories import Loader as AppDirectoriesLoader from django.template.loaders.app_directories import Loader as AppDirectoriesLoader
from mitxmako.template import Template from mitxmako.template import Template
import mitxmako.middleware
import tempdir import tempdir
......
...@@ -6,7 +6,6 @@ from django.conf import settings ...@@ -6,7 +6,6 @@ from django.conf import settings
import json import json
import logging import logging
import os import os
import sys
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
...@@ -11,12 +11,7 @@ ...@@ -11,12 +11,7 @@
import datetime import datetime
import json import json
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import UserProfile from student.models import UserProfile
......
...@@ -3,17 +3,11 @@ ...@@ -3,17 +3,11 @@
## See export for more info ## See export for more info
import datetime
import json import json
import dateutil.parser import dateutil.parser
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import UserProfile from student.models import UserProfile
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
...@@ -2,9 +2,7 @@ ...@@ -2,9 +2,7 @@
## A script to create some dummy users ## A script to create some dummy users
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from student.models import CourseEnrollment
from django.contrib.auth.models import User
from student.models import UserProfile, CourseEnrollment
from student.views import _do_create_account, get_random_post_override from student.views import _do_create_account, get_random_post_override
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
import os.path import os.path
import time import time
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
...@@ -2,7 +2,7 @@ from optparse import make_option ...@@ -2,7 +2,7 @@ from optparse import make_option
from json import dump from json import dump
from datetime import datetime from datetime import datetime
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from student.models import TestCenterRegistration from student.models import TestCenterRegistration
......
...@@ -3,11 +3,8 @@ import csv ...@@ -3,11 +3,8 @@ import csv
from zipfile import ZipFile, is_zipfile from zipfile import ZipFile, is_zipfile
from time import strptime, strftime from time import strptime, strftime
from collections import OrderedDict
from datetime import datetime from datetime import datetime
from os.path import isdir from dogapi import dog_http_api
from optparse import make_option
from dogapi import dog_http_api, dog_stats_api
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.conf import settings from django.conf import settings
......
...@@ -26,7 +26,7 @@ class Command(BaseCommand): ...@@ -26,7 +26,7 @@ class Command(BaseCommand):
raise CommandError('Usage is set_staff {0}'.format(self.args)) raise CommandError('Usage is set_staff {0}'.format(self.args))
for user in args: for user in args:
if re.match('[^@]+@[^@]+\.[^@]+', user): if re.match(r'[^@]+@[^@]+\.[^@]+', user):
try: try:
v = User.objects.get(email=user) v = User.objects.get(email=user)
except: except:
......
...@@ -14,7 +14,7 @@ from django.test import TestCase ...@@ -14,7 +14,7 @@ from django.test import TestCase
from django.core.management import call_command from django.core.management import call_command
from nose.plugins.skip import SkipTest 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__) log = logging.getLogger(__name__)
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
......
...@@ -3,8 +3,8 @@ import feedparser ...@@ -3,8 +3,8 @@ import feedparser
import json import json
import logging import logging
import random import random
import re
import string import string
import sys
import urllib import urllib
import uuid import uuid
import time import time
...@@ -20,9 +20,9 @@ from django.core.mail import send_mail ...@@ -20,9 +20,9 @@ from django.core.mail import send_mail
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import validate_email, validate_slug, ValidationError from django.core.validators import validate_email, validate_slug, ValidationError
from django.db import IntegrityError, transaction 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.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 django.utils.http import cookie_date
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
...@@ -39,14 +39,13 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud ...@@ -39,14 +39,13 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from collections import namedtuple from collections import namedtuple
from courseware.courses import get_courses, sort_by_announcement from courseware.courses import get_courses, sort_by_announcement
from courseware.access import has_access 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 statsd import statsd
from pytz import UTC from pytz import UTC
...@@ -99,9 +98,8 @@ def course_from_id(course_id): ...@@ -99,9 +98,8 @@ def course_from_id(course_id):
course_loc = CourseDescriptor.id_to_location(course_id) course_loc = CourseDescriptor.id_to_location(course_id)
return modulestore().get_instance(course_id, course_loc) return modulestore().get_instance(course_id, course_loc)
import re day_pattern = re.compile(r'\s\d+,\s')
day_pattern = re.compile('\s\d+,\s') multimonth_pattern = re.compile(r'\s?\-\s?\S+\s')
multimonth_pattern = re.compile('\s?\-\s?\S+\s')
def get_date_for_press(publish_date): def get_date_for_press(publish_date):
...@@ -230,7 +228,7 @@ def signin_user(request): ...@@ -230,7 +228,7 @@ def signin_user(request):
@ensure_csrf_cookie @ensure_csrf_cookie
def register_user(request): def register_user(request, extra_context={}):
""" """
This view will display the non-modal registration form This view will display the non-modal registration form
""" """
...@@ -241,6 +239,8 @@ def register_user(request): ...@@ -241,6 +239,8 @@ def register_user(request):
'course_id': request.GET.get('course_id'), 'course_id': request.GET.get('course_id'),
'enrollment_action': request.GET.get('enrollment_action') 'enrollment_action': request.GET.get('enrollment_action')
} }
context.update(extra_context)
return render_to_response('register.html', context) return render_to_response('register.html', context)
...@@ -282,9 +282,17 @@ def dashboard(request): ...@@ -282,9 +282,17 @@ def dashboard(request):
# Get the 3 most recent news # Get the 3 most recent news
top_news = _get_news(top=3) if not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) else None 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, context = {'courses': courses,
'message': message, 'message': message,
'external_auth_map': external_auth_map,
'staff_access': staff_access, 'staff_access': staff_access,
'errored_courses': errored_courses, 'errored_courses': errored_courses,
'show_courseware_links_for': show_courseware_links_for, 'show_courseware_links_for': show_courseware_links_for,
...@@ -571,15 +579,23 @@ def create_account(request, post_override=None): ...@@ -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 # 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 # 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 DoExternalAuth = 'ExternalAuthMap' in request.session
if DoExternalAuth: if DoExternalAuth:
eamap = request.session['ExternalAuthMap'] eamap = request.session['ExternalAuthMap']
email = eamap.external_email try:
name = eamap.external_name 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 password = eamap.internal_password
post_vars = dict(post_vars.items()) post_vars = dict(post_vars.items())
post_vars.update(dict(email=email, name=name, password=password)) 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 # Confirm we have a properly formed request
for a in ['username', 'email', 'password', 'name']: for a in ['username', 'email', 'password', 'name']:
...@@ -593,17 +609,28 @@ def create_account(request, post_override=None): ...@@ -593,17 +609,28 @@ def create_account(request, post_override=None):
js['field'] = 'honor_code' js['field'] = 'honor_code'
return HttpResponse(json.dumps(js)) return HttpResponse(json.dumps(js))
if post_vars.get('terms_of_service', 'false') != u'true': # Can't have terms of service for certain SHIB users, like at Stanford
js['value'] = "You must accept the terms of service.".format(field=a) tos_not_required = settings.MITX_FEATURES.get("AUTH_USE_SHIB") \
js['field'] = 'terms_of_service' and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') \
return HttpResponse(json.dumps(js)) 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. # Confirm appropriate fields are there.
# TODO: Check e-mail format is correct. # TODO: Check e-mail format is correct.
# TODO: Confirm e-mail is not from a generic domain (mailinator, etc.)? Not sure if # TODO: Confirm e-mail is not from a generic domain (mailinator, etc.)? Not sure if
# this is a good idea # this is a good idea
# TODO: Check password is sane # 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: if len(post_vars[a]) < 2:
error_str = {'username': 'Username must be minimum of two characters long.', error_str = {'username': 'Username must be minimum of two characters long.',
'email': 'A properly formatted e-mail is required.', 'email': 'A properly formatted e-mail is required.',
...@@ -665,19 +692,20 @@ def create_account(request, post_override=None): ...@@ -665,19 +692,20 @@ def create_account(request, post_override=None):
login(request, login_user) login(request, login_user)
request.session.set_expiry(0) request.session.set_expiry(0)
try_change_enrollment(request)
if DoExternalAuth: if DoExternalAuth:
eamap.user = login_user eamap.user = login_user
eamap.dtsignup = datetime.datetime.now(UTC) eamap.dtsignup = datetime.datetime.now(UTC)
eamap.save() 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'): 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.is_active = True
login_user.save() login_user.save()
try_change_enrollment(request)
statsd.increment("common.student.account_created") statsd.increment("common.student.account_created")
js = {'success': True} js = {'success': True}
......
...@@ -4,7 +4,6 @@ Browser set up for acceptance tests. ...@@ -4,7 +4,6 @@ Browser set up for acceptance tests.
#pylint: disable=E1101 #pylint: disable=E1101
#pylint: disable=W0613 #pylint: disable=W0613
#pylint: disable=W0611
from lettuce import before, after, world from lettuce import before, after, world
from splinter.browser import Browser from splinter.browser import Browser
...@@ -15,8 +14,9 @@ from selenium.common.exceptions import WebDriverException ...@@ -15,8 +14,9 @@ from selenium.common.exceptions import WebDriverException
# Let the LMS and CMS do their one-time setup # Let the LMS and CMS do their one-time setup
# For example, setting up mongo caches # For example, setting up mongo caches
from lms import one_time_startup # These names aren't used, but do important work on import.
from cms import one_time_startup 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 # There is an import issue when using django-staticfiles with lettuce
# Lettuce assumes that we are using django.contrib.staticfiles, # Lettuce assumes that we are using django.contrib.staticfiles,
......
#pylint: disable=C0111 # pylint: disable=C0111
#pylint: disable=W0621 # pylint: disable=W0621
from lettuce import world, step from lettuce import world
from .factories import * from .factories import *
from django.conf import settings from django.conf import settings
from django.http import HttpRequest from django.http import HttpRequest
...@@ -15,7 +15,6 @@ from xmodule.templates import update_templates ...@@ -15,7 +15,6 @@ from xmodule.templates import update_templates
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import os.path import os.path
from urllib import quote_plus from urllib import quote_plus
from lettuce.django import django_url
@world.absorb @world.absorb
......
...@@ -15,13 +15,13 @@ from lettuce import world, step ...@@ -15,13 +15,13 @@ from lettuce import world, step
from .course_helpers import * from .course_helpers import *
from .ui_helpers import * from .ui_helpers import *
from lettuce.django import django_url from lettuce.django import django_url
from nose.tools import assert_equals, assert_in from nose.tools import assert_equals
from logging import getLogger from logging import getLogger
logger = getLogger(__name__) logger = getLogger(__name__)
@step(u'I wait (?:for )?"(\d+)" seconds?$') @step(r'I wait (?:for )?"(\d+)" seconds?$')
def wait(step, seconds): def wait(step, seconds):
world.wait(seconds) world.wait(seconds)
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from lettuce import world from lettuce import world
import time import time
import platform
from urllib import quote_plus from urllib import quote_plus
from selenium.common.exceptions import WebDriverException, StaleElementReferenceException from selenium.common.exceptions import WebDriverException, StaleElementReferenceException
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
...@@ -48,7 +49,7 @@ def css_has_text(css_selector, text): ...@@ -48,7 +49,7 @@ def css_has_text(css_selector, text):
@world.absorb @world.absorb
def css_find(css, wait_time=5): 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,)) return EC.visibility_of_element_located((By.CSS_SELECTOR, css,))
world.browser.is_element_present_by_css(css, wait_time=wait_time) world.browser.is_element_present_by_css(css, wait_time=wait_time)
...@@ -57,32 +58,79 @@ def css_find(css, wait_time=5): ...@@ -57,32 +58,79 @@ def css_find(css, wait_time=5):
@world.absorb @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) assert is_css_present(css_selector)
try: attempt = 0
world.browser.find_by_css(css_selector).click() 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_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.
except WebDriverException: This function handles errors that may be thrown if the component cannot be clicked on.
# Occassionally, MathJax or other JavaScript can cover up However, there are cases where an error may not be thrown, and yet the operation did not
# an element temporarily. actually succeed. For those cases, a success_condition lambda can be supplied to verify that the check worked.
# If this happens, wait a second, then try again
world.wait(1) This function will return True if the check worked (taking into account both errors and the optional
world.browser.find_by_css(css_selector).click() 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 @world.absorb
def css_click_at(css, x=10, y=10): def css_click_at(css, x_cord=10, y_cord=10):
''' '''
A method to click at x,y coordinates of the element A method to click at x,y coordinates of the element
rather than in the center of the element rather than in the center of the element
''' '''
e = css_find(css).first element = css_find(css).first
e.action_chains.move_to_element_with_offset(e._element, x, y) element.action_chains.move_to_element_with_offset(element._element, x_cord, y_cord)
e.action_chains.click() element.action_chains.click()
e.action_chains.perform() element.action_chains.perform()
@world.absorb @world.absorb
...@@ -127,7 +175,7 @@ def css_visible(css_selector): ...@@ -127,7 +175,7 @@ def css_visible(css_selector):
@world.absorb @world.absorb
def dialogs_closed(): def dialogs_closed():
def are_dialogs_closed(driver): def are_dialogs_closed(_driver):
''' '''
Return True when no modal dialogs are visible Return True when no modal dialogs are visible
''' '''
...@@ -138,12 +186,12 @@ def dialogs_closed(): ...@@ -138,12 +186,12 @@ def dialogs_closed():
@world.absorb @world.absorb
def save_the_html(path='/tmp'): def save_the_html(path='/tmp'):
u = world.browser.url url = world.browser.url
html = world.browser.html.encode('ascii', 'ignore') html = world.browser.html.encode('ascii', 'ignore')
filename = '%s.html' % quote_plus(u) filename = '%s.html' % quote_plus(url)
f = open('%s/%s' % (path, filename), 'w') file = open('%s/%s' % (path, filename), 'w')
f.write(html) file.write(html)
f.close() file.close()
@world.absorb @world.absorb
...@@ -158,3 +206,8 @@ def click_tools(): ...@@ -158,3 +206,8 @@ def click_tools():
tools_css = 'li.nav-course-tools' tools_css = 'li.nav-course-tools'
if world.browser.is_element_present_by_css(tools_css): if world.browser.is_element_present_by_css(tools_css):
world.css_click(tools_css) world.css_click(tools_css)
@world.absorb
def is_mac():
return platform.mac_ver()[0] is not ''
import json import json
from django.conf import settings
import views import views
......
import json import json
import logging import logging
import os
import pytz import pytz
import datetime import datetime
import dateutil.parser import dateutil.parser
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.http import Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf import settings from django.conf import settings
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
...@@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', ' ...@@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', '
def log_event(event): def log_event(event):
"""Write tracking event to log file, and optionally to TrackingLog model."""
event_str = json.dumps(event) event_str = json.dumps(event)
log.info(event_str[:settings.TRACK_MAX_EVENT]) log.info(event_str[:settings.TRACK_MAX_EVENT])
if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
...@@ -34,6 +33,11 @@ def log_event(event): ...@@ -34,6 +33,11 @@ def log_event(event):
def user_track(request): 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 try: # TODO: Do the same for many of the optional META parameters
username = request.user.username username = request.user.username
except: except:
...@@ -50,7 +54,6 @@ def user_track(request): ...@@ -50,7 +54,6 @@ def user_track(request):
except: except:
agent = '' agent = ''
# TODO: Move a bunch of this into log_event
event = { event = {
"username": username, "username": username,
"session": scookie, "session": scookie,
...@@ -68,6 +71,7 @@ def user_track(request): ...@@ -68,6 +71,7 @@ def user_track(request):
def server_track(request, event_type, event, page=None): def server_track(request, event_type, event, page=None):
"""Log events related to server requests."""
try: try:
username = request.user.username username = request.user.username
except: except:
...@@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None): ...@@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None):
log_event(event) 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 @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def view_tracking_log(request, args=''): def view_tracking_log(request, args=''):
"""View to output contents of TrackingLog model. For staff use only."""
if not request.user.is_staff: if not request.user.is_staff:
return redirect('/') return redirect('/')
nlen = 100 nlen = 100
......
from django.db import models
# Create your models here. # Create your models here.
...@@ -4,7 +4,6 @@ Tests for memcache in util app ...@@ -4,7 +4,6 @@ Tests for memcache in util app
from django.test import TestCase from django.test import TestCase
from django.core.cache import get_cache from django.core.cache import get_cache
from django.conf import settings
from util.memcache import safe_key from util.memcache import safe_key
......
"""Tests for the Zendesk""" """Tests for the Zendesk"""
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http import Http404 from django.http import Http404
from django.test import TestCase from django.test import TestCase
......
import datetime
import json import json
import logging import logging
import pprint
import sys import sys
from django.conf import settings 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.core.validators import ValidationError, validate_email
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError from django.http import Http404, HttpResponse, HttpResponseNotAllowed
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from dogapi import dog_stats_api from dogapi import dog_stats_api
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response
from urllib import urlencode
import zendesk import zendesk
import calc import calc
......
import re
import json import json
import logging import logging
import static_replace import static_replace
......
...@@ -10,10 +10,9 @@ import sys ...@@ -10,10 +10,9 @@ import sys
from path import path from path import path
from cStringIO import StringIO from cStringIO import StringIO
from collections import defaultdict
from .calc import UndefinedVariable from calc import UndefinedVariable
from .capa_problem import LoncapaProblem from capa.capa_problem import LoncapaProblem
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
logging.basicConfig(format="%(levelname)s %(message)s") logging.basicConfig(format="%(levelname)s %(message)s")
......
...@@ -10,8 +10,6 @@ from .registry import TagRegistry ...@@ -10,8 +10,6 @@ from .registry import TagRegistry
import logging import logging
import re import re
import shlex # for splitting quoted strings
import json
from lxml import etree from lxml import etree
import xml.sax.saxutils as saxutils import xml.sax.saxutils as saxutils
...@@ -28,7 +26,7 @@ class MathRenderer(object): ...@@ -28,7 +26,7 @@ class MathRenderer(object):
tags = ['math'] tags = ['math']
def __init__(self, system, xml): def __init__(self, system, xml):
''' r'''
Render math using latex-like formatting. Render math using latex-like formatting.
Examples: Examples:
...@@ -43,7 +41,7 @@ class MathRenderer(object): ...@@ -43,7 +41,7 @@ class MathRenderer(object):
self.system = system self.system = system
self.xml = xml self.xml = xml
mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text) mathstr = re.sub(r'\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
mtag = 'mathjax' mtag = 'mathjax'
if not r'\displaystyle' in mathstr: if not r'\displaystyle' in mathstr:
mtag += 'inline' mtag += 'inline'
......
...@@ -856,7 +856,7 @@ class ImageInput(InputTypeBase): ...@@ -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 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(' ', '')) self.value.strip().replace(' ', ''))
if m: if m:
# Note: we subtract 15 to compensate for the size of the dot on the screen. # Note: we subtract 15 to compensate for the size of the dot on the screen.
......
...@@ -11,7 +11,6 @@ Used by capa_problem.py ...@@ -11,7 +11,6 @@ Used by capa_problem.py
# standard library imports # standard library imports
import abc import abc
import cgi import cgi
import hashlib
import inspect import inspect
import json import json
import logging import logging
...@@ -1903,8 +1902,7 @@ class ImageResponse(LoncapaResponse): ...@@ -1903,8 +1902,7 @@ class ImageResponse(LoncapaResponse):
if not given: # No answer to parse. Mark as incorrect and move on if not given: # No answer to parse. Mark as incorrect and move on
continue continue
# parse given answer # parse given answer
m = re.match( m = re.match(r'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
if not m: if not m:
raise Exception('[capamodule.capa.responsetypes.imageinput] ' raise Exception('[capamodule.capa.responsetypes.imageinput] '
'error grading %s (input=%s)' % (aid, given)) 'error grading %s (input=%s)' % (aid, given))
...@@ -1919,7 +1917,7 @@ class ImageResponse(LoncapaResponse): ...@@ -1919,7 +1917,7 @@ class ImageResponse(LoncapaResponse):
# parse expected answer # parse expected answer
# TODO: Compile regexp on file load # TODO: Compile regexp on file load
m = re.match( m = re.match(
'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', r'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
solution_rectangle.strip().replace(' ', '')) solution_rectangle.strip().replace(' ', ''))
if not m: if not m:
msg = 'Error in problem specification! cannot parse rectangle in %s' % ( msg = 'Error in problem specification! cannot parse rectangle in %s' % (
......
"""
Tests to verify that CorrectMap behaves correctly
"""
import unittest import unittest
from capa.correctmap import CorrectMap from capa.correctmap import CorrectMap
import datetime import datetime
class CorrectMapTest(unittest.TestCase): class CorrectMapTest(unittest.TestCase):
"""
Tests to verify that CorrectMap behaves correctly
"""
def setUp(self): def setUp(self):
self.cmap = CorrectMap() self.cmap = CorrectMap()
def test_set_input_properties(self): def test_set_input_properties(self):
# Set the correctmap properties for two inputs # Set the correctmap properties for two inputs
self.cmap.set(answer_id='1_2_1', self.cmap.set(
correctness='correct', answer_id='1_2_1',
npoints=5, correctness='correct',
msg='Test message', npoints=5,
hint='Test hint', msg='Test message',
hintmode='always', hint='Test hint',
queuestate={'key':'secretstring', hintmode='always',
'time':'20130228100026'}) queuestate={
'key': 'secretstring',
self.cmap.set(answer_id='2_2_1', 'time': '20130228100026'
correctness='incorrect', }
npoints=None, )
msg=None,
hint=None, self.cmap.set(
hintmode=None, answer_id='2_2_1',
queuestate=None) correctness='incorrect',
npoints=None,
msg=None,
hint=None,
hintmode=None,
queuestate=None
)
# Assert that each input has the expected properties # Assert that each input has the expected properties
self.assertTrue(self.cmap.is_correct('1_2_1')) self.assertTrue(self.cmap.is_correct('1_2_1'))
...@@ -62,7 +75,6 @@ class CorrectMapTest(unittest.TestCase): ...@@ -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', ''))
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None)) self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None))
def test_get_npoints(self): def test_get_npoints(self):
# Set the correctmap properties for 4 inputs # Set the correctmap properties for 4 inputs
# 1) correct, 5 points # 1) correct, 5 points
...@@ -70,25 +82,35 @@ class CorrectMapTest(unittest.TestCase): ...@@ -70,25 +82,35 @@ class CorrectMapTest(unittest.TestCase):
# 3) incorrect, 5 points # 3) incorrect, 5 points
# 4) incorrect, None points # 4) incorrect, None points
# 5) correct, 0 points # 5) correct, 0 points
self.cmap.set(answer_id='1_2_1', self.cmap.set(
correctness='correct', answer_id='1_2_1',
npoints=5) correctness='correct',
npoints=5
self.cmap.set(answer_id='2_2_1', )
correctness='correct',
npoints=None) self.cmap.set(
answer_id='2_2_1',
self.cmap.set(answer_id='3_2_1', correctness='correct',
correctness='incorrect', npoints=None
npoints=5) )
self.cmap.set(answer_id='4_2_1', self.cmap.set(
correctness='incorrect', answer_id='3_2_1',
npoints=None) correctness='incorrect',
npoints=5
self.cmap.set(answer_id='5_2_1', )
correctness='correct',
npoints=0) self.cmap.set(
answer_id='4_2_1',
correctness='incorrect',
npoints=None
)
self.cmap.set(
answer_id='5_2_1',
correctness='correct',
npoints=0
)
# Assert that we get the expected points # Assert that we get the expected points
# If points assigned --> npoints # If points assigned --> npoints
...@@ -100,7 +122,6 @@ class CorrectMapTest(unittest.TestCase): ...@@ -100,7 +122,6 @@ class CorrectMapTest(unittest.TestCase):
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0) self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0) self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
def test_set_overall_message(self): def test_set_overall_message(self):
# Default is an empty string string # Default is an empty string string
...@@ -118,14 +139,18 @@ class CorrectMapTest(unittest.TestCase): ...@@ -118,14 +139,18 @@ class CorrectMapTest(unittest.TestCase):
def test_update_from_correctmap(self): def test_update_from_correctmap(self):
# Initialize a CorrectMap with some properties # Initialize a CorrectMap with some properties
self.cmap.set(answer_id='1_2_1', self.cmap.set(
correctness='correct', answer_id='1_2_1',
npoints=5, correctness='correct',
msg='Test message', npoints=5,
hint='Test hint', msg='Test message',
hintmode='always', hint='Test hint',
queuestate={'key':'secretstring', hintmode='always',
'time':'20130228100026'}) queuestate={
'key': 'secretstring',
'time': '20130228100026'
}
)
self.cmap.set_overall_message("Test message") self.cmap.set_overall_message("Test message")
...@@ -133,14 +158,17 @@ class CorrectMapTest(unittest.TestCase): ...@@ -133,14 +158,17 @@ class CorrectMapTest(unittest.TestCase):
# as the first cmap # as the first cmap
other_cmap = CorrectMap() other_cmap = CorrectMap()
other_cmap.update(self.cmap) 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())
# 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()
)
def test_update_from_invalid(self): def test_update_from_invalid(self):
# Should get an exception if we try to update() a CorrectMap # Should get an exception if we try to update() a CorrectMap
......
...@@ -2,7 +2,6 @@ import unittest ...@@ -2,7 +2,6 @@ import unittest
from lxml import etree from lxml import etree
import os import os
import textwrap import textwrap
import json
import mock import mock
......
from calc import evaluator, UndefinedVariable from calc import evaluator
from cmath import isinf from cmath import isinf
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
......
from __future__ import division from __future__ import division
import copy
from fractions import Fraction from fractions import Fraction
import logging
import math from pyparsing import (Literal, StringEnd, OneOrMore, ParseException)
import operator
import re
import numpy
import numbers
import scipy.constants
from pyparsing import (Literal, Keyword, Word, nums, StringEnd, Optional,
Forward, OneOrMore, ParseException)
import nltk import nltk
from nltk.tree import Tree from nltk.tree import Tree
......
...@@ -10,7 +10,6 @@ ...@@ -10,7 +10,6 @@
# Provides sympy representation. # Provides sympy representation.
import os import os
import sys
import string import string
import re import re
import logging import logging
...@@ -25,8 +24,7 @@ from sympy.physics.quantum.state import * ...@@ -25,8 +24,7 @@ from sympy.physics.quantum.state import *
# from sympy.core.operations import LatticeOp # from sympy.core.operations import LatticeOp
# import sympy.physics.quantum.qubit # import sympy.physics.quantum.qubit
import urllib from xml.sax.saxutils import unescape
from xml.sax.saxutils import escape, unescape
import sympy import sympy
import unicodedata import unicodedata
from lxml import etree from lxml import etree
...@@ -52,7 +50,7 @@ class dot(sympy.operations.LatticeOp): # my dot product ...@@ -52,7 +50,7 @@ class dot(sympy.operations.LatticeOp): # my dot product
def _print_dot(self, expr): 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 LatexPrinter._print_dot = _print_dot
...@@ -204,7 +202,7 @@ class formula(object): ...@@ -204,7 +202,7 @@ class formula(object):
return xml return xml
def preprocess_pmathml(self, xml): def preprocess_pmathml(self, xml):
''' r'''
Pre-process presentation MathML from ASCIIMathML to make it more Pre-process presentation MathML from ASCIIMathML to make it more
acceptable for SnuggleTeX, and also to accomodate some sympy acceptable for SnuggleTeX, and also to accomodate some sympy
conventions (eg hat(i) for \hat{i}). conventions (eg hat(i) for \hat{i}).
......
...@@ -8,10 +8,6 @@ ...@@ -8,10 +8,6 @@
# #
# Takes in math expressions given as Presentation MathML (from ASCIIMathML), converts to Content MathML using SnuggleTeX # 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 import traceback
from .formula import * from .formula import *
import logging import logging
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment