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>
Jonah Stanley <Jonah_Stanley@brown.edu>
Slater Victoroff <slater.r.victoroff@gmail.com>
Peter Fogg <peter.p.fogg@gmail.com>
Renzo Lucioni <renzolucioni@gmail.com>
\ No newline at end of file
Bethany LaPenta <lapentab@mit.edu>
Renzo Lucioni <renzolucioni@gmail.com>
Felix Sun <felixsun@mit.edu>
......@@ -5,16 +5,60 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Studio: Remove XML from the video component editor. All settings are
moved to be edited as metadata.
XModule: Only write out assets files if the contents have changed.
XModule: Don't delete generated xmodule asset files when compiling (for
instance, when XModule provides a coffeescript file, don't delete
the associated javascript)
Studio: For courses running on edx.org (marketing site), disable fields in
Course Settings that do not apply.
Common: Make asset watchers run as singletons (so they won't start if the
watcher is already running in another shell).
Common: Use coffee directly when watching for coffeescript file changes.
Common: Make rake provide better error messages if packages are missing.
Common: Repairs development documentation generation by sphinx.
LMS: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow all students' submissions for a
particular problem to be rescored. Also supports resetting all
students' number of attempts to zero. Provides a list of background
tasks that are currently running for the course, and an option to
see a history of background tasks for a given problem.
LMS: Fixed the preferences scope for storing data in xmodules.
LMS: Forums. Added handling for case where discussion module can get `None` as
value of lms.start in `lms/djangoapps/django_comment_client/utils.py`
Studio, LMS: Make ModelTypes more strict about their expected content (for
instance, Boolean, Integer, String), but also allow them to hold either the
typed value, or a String that can be converted to their typed value. For example,
an Integer can contain 3 or '3'. This changed an update to the xblock library.
LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django
setting now run entirely outside the Python sandbox.
Blades: Added tests for Video Alpha player.
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
captions.
CMS: Allow editors to delete uploaded files/assets
XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the
`location` parameter (and added it as a field), and renamed `system` to `runtime`,
to accord more closely to `XBlock.__init__`
LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
......@@ -39,6 +83,9 @@ Blades: Staff debug info is now accessible for Graphical Slider Tool problems.
Blades: For Video Alpha the events ready, play, pause, seek, and speed change
are logged on the server (in the logs).
Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive
datetimes.
Common: Developers can now have private Django settings files.
Common: Safety code added to prevent anything above the vertical level in the
......
......@@ -4,3 +4,4 @@ gem 'sass', '3.1.15'
gem 'bourbon', '~> 1.3.6'
gem 'colorize', '~> 0.5.8'
gem 'launchy', '~> 2.1.2'
gem 'sys-proctable', '~> 0.9.3'
Instructions
============
For each pull request, add one or more lines to the bottom of the change list. When
code is released to production, change the `Upcoming` entry to todays date, and add
a new block at the bottom of the file.
Upcoming
--------
Change log entries should be targeted at end users. A good place to start is the
user story that instigated the pull request.
Changes
=======
Upcoming
--------
* Fix: Deleting last component in a unit does not work
* Fix: Unit name is editable when a unit is public
* Fix: Visual feedback inconsistent when saving a unit name change
......@@ -39,8 +39,6 @@ def get_users_in_course_group_by_role(location, role):
'''
Create all permission groups for a new course and subscribe the caller into those roles
'''
def create_all_course_groups(creator, location):
create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME)
create_new_course_group(creator, location, STAFF_ROLE_NAME)
......@@ -57,13 +55,11 @@ def create_new_course_group(creator, location, role):
return
'''
This is to be called only by either a command line code path or through a app which has already
asserted permissions
'''
def _delete_course_group(location):
'''
This is to be called only by either a command line code path or through a app which has already
asserted permissions
'''
# remove all memberships
instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
......@@ -75,13 +71,11 @@ def _delete_course_group(location):
user.groups.remove(staff)
user.save()
'''
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
'''
def _copy_course_group(source, dest):
'''
This is to be called only by either a command line code path or through an app which has already
asserted permissions to do this action
'''
instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME))
new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME))
for user in instructors.user_set.all():
......
......@@ -2,12 +2,8 @@
#pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_false, assert_equal, assert_regexp_matches
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
"""
from selenium.webdriver.common.keys import Keys
from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true
from common import type_in_codemirror
KEY_CSS = '.key input.policy-key'
VALUE_CSS = 'textarea.json'
......@@ -32,18 +28,20 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower()
world.css_click(css)
# Save was clicked if either the save notification bar is gone, or we have a error notification
# overlaying it (expected in the case of typing Object into display_name).
def save_clicked():
confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning')
error_showing = world.is_css_present('.is-shown.wrapper-notification-error')
return confirmation_dismissed or error_showing
assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.')
@step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step):
"""
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click()
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
g._element.send_keys(Keys.ARROW_LEFT, ' ', 'X')
type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
@step(u'I edit the value of a policy key and save$')
......@@ -132,13 +130,5 @@ def change_display_name_value(step, new_value):
def change_value(step, key, new_value):
index = get_index_of(key)
world.css_find(".CodeMirror")[index].click()
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
current_value = world.css_find(VALUE_CSS)[index].value
g._element.send_keys(Keys.CONTROL + Keys.END)
for count in range(len(current_value)):
g._element.send_keys(Keys.END, Keys.BACK_SPACE)
# Must delete "" before typing the JSON value
g._element.send_keys(Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
type_in_codemirror(get_index_of(key), new_value)
press_the_notification_button(step, "Save")
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from nose.tools import assert_true
......@@ -16,7 +16,7 @@ logger = getLogger(__name__)
########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step):
def i_visit_the_studio_homepage(_step):
# To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001
# in your settings.py file.
......@@ -26,17 +26,17 @@ def i_visit_the_studio_homepage(step):
@step('I am logged into Studio$')
def i_am_logged_into_studio(step):
def i_am_logged_into_studio(_step):
log_into_studio()
@step('I confirm the alert$')
def i_confirm_with_ok(step):
def i_confirm_with_ok(_step):
world.browser.get_alert().accept()
@step(u'I press the "([^"]*)" delete icon$')
def i_press_the_category_delete_icon(step, category):
def i_press_the_category_delete_icon(_step, category):
if category == 'section':
css = 'a.delete-button.delete-section-button span.delete-icon'
elif category == 'subsection':
......@@ -47,7 +47,7 @@ def i_press_the_category_delete_icon(step, category):
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
def i_have_opened_a_new_course(_step):
open_new_course()
......@@ -73,7 +73,6 @@ def create_studio_user(
registration.register(studio_user)
registration.activate()
def fill_in_course_info(
name='Robot Super Course',
org='MITx',
......@@ -107,7 +106,7 @@ def log_into_studio(
def create_a_course():
c = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
# Add the user to the instructor group of the course
# so they will have the permissions to see it in studio
......@@ -147,6 +146,7 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
world.css_fill(date_css, desired_date)
# hit TAB to get to the time field
e = world.css_find(date_css).first
# pylint: disable=W0212
e._element.send_keys(Keys.TAB)
world.css_fill(time_css, desired_time)
e = world.css_find(time_css).first
......@@ -169,3 +169,24 @@ def open_new_unit(step):
step.given('I have added a new subsection')
step.given('I expand the first section')
world.css_click('a.new-unit-item')
@step('when I view the video it (.*) show the captions')
def shows_captions(step, show_captions):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
if show_captions == 'does not':
assert world.css_find('.video')[0].has_class('closed')
else:
assert world.is_css_not_present('.video.closed')
def type_in_codemirror(index, text):
world.css_click(".CodeMirror", index=index)
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
if world.is_mac():
g._element.send_keys(Keys.COMMAND + 'a')
else:
g._element.send_keys(Keys.CONTROL + 'a')
g._element.send_keys(Keys.DELETE)
g._element.send_keys(text)
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
Scenario: User can view metadata
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I see five alphabetized settings and their expected values
And Edit High Level Source is not visible
Scenario: User can modify String values
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I can modify the display name
And my display name change is persisted on save
Scenario: User can specify special characters in String values
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I can specify special characters in the display name
And my special characters and persisted on save
Scenario: User can revert display name to unset
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I can revert the display name to unset
And my display name is unset on save
Scenario: User can select values in a Select
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I can select Per Student for Randomization
And my change to randomization is persisted
And I can revert to the default value for randomization
Scenario: User can modify float input values
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I can set the weight to "3.5"
And my change to weight is persisted
And I can revert to the default value of unset for weight
Scenario: User cannot type letters in float number field
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then if I set the weight to "abc", it remains unset
Scenario: User cannot type decimal values integer number field
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
Scenario: User cannot type out of range values in an integer number field
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
Scenario: Settings changes are not saved on Cancel
Given I have created a Blank Common Problem
And I edit and select Settings
When I edit and select Settings
Then I can set the weight to "3.5"
And I can modify the display name
Then If I press Cancel my changes are not persisted
Scenario: Edit High Level source is available for LaTeX problem
Given I have created a LaTeX Problem
And I edit and select Settings
When I edit and select Settings
Then Edit High Level Source is visible
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
Given I have created a LaTeX Problem
When I edit and compile the High Level Source
Then my change to the High Level Source is persisted
And when I view the High Level Source I see my changes
......@@ -3,6 +3,7 @@
from lettuce import world, step
from nose.tools import assert_equal
from common import type_in_codemirror
DISPLAY_NAME = "Display Name"
MAXIMUM_ATTEMPTS = "Maximum Attempts"
......@@ -135,12 +136,12 @@ def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_att
@step('Edit High Level Source is not visible')
def edit_high_level_source_not_visible(step):
verify_high_level_source(step, False)
verify_high_level_source_links(step, False)
@step('Edit High Level Source is visible')
def edit_high_level_source_visible(step):
verify_high_level_source(step, True)
def edit_high_level_source_links_visible(step):
verify_high_level_source_links(step, True)
@step('If I press Cancel my changes are not persisted')
......@@ -153,13 +154,33 @@ def cancel_does_not_save_changes(step):
@step('I have created a LaTeX Problem')
def create_latex_problem(step):
world.click_new_component_button(step, '.large-problem-icon')
# Go to advanced tab (waiting for the tab to be visible)
world.css_find('#ui-id-2')
# Go to advanced tab.
world.css_click('#ui-id-2')
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
def verify_high_level_source(step, visible):
@step('I edit and compile the High Level Source')
def edit_latex_source(step):
open_high_level_source()
type_in_codemirror(1, "hi")
world.css_click('.hls-compile')
@step('my change to the High Level Source is persisted')
def high_level_source_persisted(step):
def verify_text(driver):
return world.css_find('.problem').text == 'hi'
world.wait_for(verify_text)
@step('I view the High Level Source I see my changes')
def high_level_source_in_editor(step):
open_high_level_source()
assert_equal('hi', world.css_find('.source-edit-box').value)
def verify_high_level_source_links(step, visible):
assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
world.cancel_component(step)
assert_equal(visible, world.is_css_present('.upload-button'))
......@@ -187,3 +208,8 @@ def verify_unset_display_name():
def set_weight(weight):
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
def open_high_level_source():
world.css_click('a.edit-button')
world.css_click('.launch-latex-compiler > a')
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from common import *
......@@ -8,7 +8,7 @@ from nose.tools import assert_equal
############### ACTIONS ####################
@step('I click the new section link$')
@step('I click the New Section link$')
def i_click_new_section_link(_step):
link_css = 'a.new-courseware-section-button'
world.css_click(link_css)
......
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from common import *
......
......@@ -4,10 +4,20 @@ Feature: Video Component Editor
Scenario: User can view metadata
Given I have created a Video component
And I edit and select Settings
Then I see only the Video display name setting
Then I see the correct settings and default values
Scenario: User can modify display name
Given I have created a Video component
And I edit and select Settings
Then I can modify the display name
And my display name change is persisted on save
Scenario: Captions are hidden when "show captions" is false
Given I have created a Video component
And I have set "show captions" to False
Then when I view the video it does not show the captions
Scenario: Captions are shown when "show captions" is true
Given I have created a Video component
And I have set "show captions" to True
Then when I view the video it does show the captions
......@@ -4,6 +4,20 @@
from lettuce import world, step
@step('I see only the video display name setting$')
def i_see_only_the_video_display_name(step):
world.verify_all_setting_entries([['Display Name', "default", True]])
@step('I see the correct settings and default values$')
def i_see_the_correct_settings_and_values(step):
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
['Display Name', 'default', True],
['Download Track', '', False],
['Download Video', '', False],
['Show Captions', 'True', False],
['Speed: .75x', '', False],
['Speed: 1.25x', '', False],
['Speed: 1.5x', '', False]])
@step('I have set "show captions" to (.*)')
def set_show_captions(step, setting):
world.css_click('a.edit-button')
world.browser.select('Show Captions', setting)
world.css_click('a.save-button')
......@@ -9,7 +9,16 @@ Feature: Video Component
Given I have clicked the new unit button
Then creating a video takes a single click
Scenario: Captions are shown correctly
Scenario: Captions are hidden correctly
Given I have created a Video component
And I have hidden captions
Then when I view the video it does not show the captions
Scenario: Captions are shown correctly
Given I have created a Video component
Then when I view the video it does show the captions
Scenario: Captions are toggled correctly
Given I have created a Video component
And I have toggled captions
Then when I view the video it does show the captions
......@@ -6,23 +6,28 @@ from lettuce import world, step
@step('when I view the video it does not have autoplay enabled')
def does_not_autoplay(step):
def does_not_autoplay(_step):
assert world.css_find('.video')[0]['data-autoplay'] == 'False'
assert world.css_find('.video_control')[0].has_class('play')
@step('creating a video takes a single click')
def video_takes_a_single_click(step):
def video_takes_a_single_click(_step):
assert(not world.is_css_present('.xmodule_VideoModule'))
world.css_click("a[data-location='i4x://edx/templates/video/default']")
assert(world.is_css_present('.xmodule_VideoModule'))
@step('I have hidden captions')
def set_show_captions_false(step):
world.css_click('a.hide-subtitles')
@step('when I view the video it does not show the captions')
def does_not_show_captions(step):
assert world.css_find('.video')[0].has_class('closed')
@step('I have (hidden|toggled) captions')
def hide_or_show_captions(step, shown):
button_css = 'a.hide-subtitles'
if shown == 'hidden':
world.css_click(button_css)
if shown == 'toggled':
world.css_click(button_css)
# When we click the first time, a tooltip shows up. We want to
# click the button rather than the tooltip, so move the mouse
# away to make it disappear.
button = world.css_find(button_css)
button.mouse_out()
world.css_click(button_css)
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=
def set_module_info(store, location, post_data):
module = None
try:
if location.revision is None:
module = store.get_item(location)
else:
module = store.get_item(location)
module = store.get_item(location)
except:
pass
......
......@@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase):
modulestore = get_modulestore(self.course.location)
return modulestore.get_item(self.course.location).checklists
def compare_checklists(self, persisted, request):
"""
Handles url expansion as possible difference and descends into guts
......
"""
Tests for Studio Course Settings.
"""
import datetime
import json
import copy
import mock
from django.contrib.auth.models import User
from django.test.client import Client
from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
from django.test.utils import override_settings
from xmodule.modulestore import Location
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
......@@ -21,6 +26,9 @@ from xmodule.fields import Date
class CourseTestCase(ModuleStoreTestCase):
"""
Base class for test classes below.
"""
def setUp(self):
"""
These tests need a user in the DB so that the django Test Client
......@@ -51,6 +59,9 @@ class CourseTestCase(ModuleStoreTestCase):
class CourseDetailsTestCase(CourseTestCase):
"""
Tests the first course settings page (course dates, overview, etc.).
"""
def test_virgin_fetch(self):
details = CourseDetails.fetch(self.course_location)
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
......@@ -81,9 +92,9 @@ class CourseDetailsTestCase(CourseTestCase):
Test the encoder out of its original constrained purpose to see if it functions for general use
"""
details = {'location': Location(['tag', 'org', 'course', 'category', 'name']),
'number': 1,
'string': 'string',
'datetime': datetime.datetime.now(UTC())}
'number': 1,
'string': 'string',
'datetime': datetime.datetime.now(UTC())}
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
jsondetails = json.loads(jsondetails)
......@@ -118,8 +129,60 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails.effort, "After set effort"
)
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
def test_marketing_site_fetch(self):
settings_details_url = reverse(
'settings_details',
kwargs={
'org': self.course_location.org,
'name': self.course_location.name,
'course': self.course_location.course
}
)
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertNotContains(response, "Enrollment Start Date")
self.assertNotContains(response, "Enrollment End Date")
self.assertContains(response, "not the dates shown on your course summary page")
self.assertNotContains(response, "Introducing Your Course")
self.assertNotContains(response, "Requirements")
def test_regular_site_fetch(self):
settings_details_url = reverse(
'settings_details',
kwargs={
'org': self.course_location.org,
'name': self.course_location.name,
'course': self.course_location.course
}
)
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertNotContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertContains(response, "Enrollment Start Date")
self.assertContains(response, "Enrollment End Date")
self.assertNotContains(response, "not the dates shown on your course summary page")
self.assertContains(response, "Introducing Your Course")
self.assertContains(response, "Requirements")
class CourseDetailsViewTest(CourseTestCase):
"""
Tests for modifying content on the first course settings page (course dates, overview, etc.).
"""
def alter_field(self, url, details, field, val):
setattr(details, field, val)
# Need to partially serialize payload b/c the mock doesn't handle it correctly
......@@ -181,6 +244,9 @@ class CourseDetailsViewTest(CourseTestCase):
class CourseGradingTest(CourseTestCase):
"""
Tests for the course settings grading page.
"""
def test_initial_grader(self):
descriptor = get_modulestore(self.course_location).get_item(self.course_location)
test_grader = CourseGradingModel(descriptor)
......@@ -256,6 +322,9 @@ class CourseGradingTest(CourseTestCase):
class CourseMetadataEditingTest(CourseTestCase):
"""
Tests for CourseMetadata.
"""
def setUp(self):
CourseTestCase.setUp(self)
# add in the full class too
......
from contentstore.utils import get_modulestore, get_url_reverse
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
......
......@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse
from .utils import parse_json, user, registration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
import datetime
from pytz import UTC
class ContentStoreTestCase(ModuleStoreTestCase):
......@@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase):
self.assertEqual(resp.status_code, 302)
# Logged in should work.
class ForumTestCase(CourseTestCase):
def setUp(self):
""" Creates the test course. """
super(ForumTestCase, self).setUp()
self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course')
def test_blackouts(self):
now = datetime.datetime.now(UTC)
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
[(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)),
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
self.assertTrue(self.course.forum_posts_allowed)
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
[(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)),
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
self.assertFalse(self.course.forum_posts_allowed)
......@@ -224,14 +224,14 @@ def add_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
"""
#Copy course tabs
# Copy course tabs
course_tabs = copy.copy(course.tabs)
changed = False
#Check to see if open ended panel is defined in the course
# Check to see if open ended panel is defined in the course
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel not in course_tabs:
#Add panel to the tabs if it is not defined
# Add panel to the tabs if it is not defined
course_tabs.append(tab_panel)
changed = True
return changed, course_tabs
......@@ -244,14 +244,14 @@ def remove_extra_panel_tab(tab_type, course):
@param course: A course object from the modulestore.
@return: Boolean indicating whether or not a tab was added and a list of tabs for the course.
"""
#Copy course tabs
# Copy course tabs
course_tabs = copy.copy(course.tabs)
changed = False
#Check to see if open ended panel is defined in the course
# Check to see if open ended panel is defined in the course
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
if tab_panel in course_tabs:
#Add panel to the tabs if it is not defined
# Add panel to the tabs if it is not defined
course_tabs = [ct for ct in course_tabs if ct != tab_panel]
changed = True
return changed, course_tabs
......@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent
from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore import InvalidLocationError
from xmodule.exceptions import NotFoundError
from ..utils import get_url_reverse
from .access import get_location_and_verify_access
......@@ -78,10 +80,17 @@ def asset_index(request, org, course, name):
'active_tab': 'assets',
'context_course': course_module,
'assets': asset_display,
'upload_asset_callback_url': upload_asset_callback_url
'upload_asset_callback_url': upload_asset_callback_url,
'remove_asset_callback_url': reverse('remove_asset', kwargs={
'org': org,
'course': course,
'name': name
})
})
@login_required
@ensure_csrf_cookie
def upload_asset(request, org, course, coursename):
'''
cdodge: this method allows for POST uploading of files into the course asset library, which will
......@@ -147,6 +156,57 @@ def upload_asset(request, org, course, coursename):
@ensure_csrf_cookie
@login_required
def remove_asset(request, org, course, name):
'''
This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
the main GridFS collection and into a Trashcan
'''
get_location_and_verify_access(request, org, course, name)
location = request.POST['location']
# make sure the location is valid
try:
loc = StaticContent.get_location_from_path(location)
except InvalidLocationError:
# return a 'Bad Request' to browser as we have a malformed Location
response = HttpResponse()
response.status_code = 400
return response
# also make sure the item to delete actually exists
try:
content = contentstore().find(loc)
except NotFoundError:
response = HttpResponse()
response.status_code = 404
return response
# ok, save the content into the trashcan
contentstore('trashcan').save(content)
# see if there is a thumbnail as well, if so move that as well
if content.thumbnail_location is not None:
try:
thumbnail_content = contentstore().find(content.thumbnail_location)
contentstore('trashcan').save(thumbnail_content)
# hard delete thumbnail from origin
contentstore().delete(thumbnail_content.get_id())
# remove from any caching
del_cached_content(thumbnail_content.location)
except:
pass # OK if this is left dangling
# delete the original
contentstore().delete(content.get_id())
# remove from cache
del_cached_content(content.location)
return HttpResponse()
@ensure_csrf_cookie
@login_required
def import_course(request, org, course, name):
location = get_location_and_verify_access(request, org, course, name)
......
......@@ -12,8 +12,8 @@ from django.core.urlresolvers import reverse
from mitxmako.shortcuts import render_to_response
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
from xmodule.modulestore.exceptions import ItemNotFoundError, \
InvalidLocationError
from xmodule.modulestore import Location
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
......@@ -33,9 +33,6 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
from django_comment_common.utils import seed_permissions_roles
import datetime
from django.utils.timezone import UTC
# TODO: should explicitly enumerate exports with __all__
__all__ = ['course_index', 'create_new_course', 'course_info',
'course_info_updates', 'get_course_settings',
'course_config_graders_page',
......@@ -230,7 +227,8 @@ def get_course_settings(request, org, course, name):
kwargs={"org": org,
"course": course,
"name": name,
"section": "details"})
"section": "details"}),
'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False)
})
......
......@@ -103,7 +103,7 @@ def clone_item(request):
@expect_json
def delete_item(request):
item_location = request.POST['id']
item_loc = Location(item_location)
item_location = Location(item_location)
# check permissions for this user within this course
if not has_access(request.user, item_location):
......@@ -124,11 +124,11 @@ def delete_item(request):
# cdodge: we need to remove our parent's pointer to us so that it is no longer dangling
if delete_all_versions:
parent_locs = modulestore('direct').get_parent_locations(item_loc, None)
parent_locs = modulestore('direct').get_parent_locations(item_location, None)
for parent_loc in parent_locs:
parent = modulestore('direct').get_item(parent_loc)
item_url = item_loc.url()
item_url = item_location.url()
if item_url in parent.children:
children = parent.children
children.remove(item_url)
......
......@@ -41,25 +41,25 @@ class CourseDetails(object):
course.enrollment_start = descriptor.enrollment_start
course.enrollment_end = descriptor.enrollment_end
temploc = course_location._replace(category='about', name='syllabus')
temploc = course_location.replace(category='about', name='syllabus')
try:
course.syllabus = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='overview')
temploc = temploc.replace(name='overview')
try:
course.overview = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='effort')
temploc = temploc.replace(name='effort')
try:
course.effort = get_modulestore(temploc).get_item(temploc).data
except ItemNotFoundError:
pass
temploc = temploc._replace(name='video')
temploc = temploc.replace(name='video')
try:
raw_video = get_modulestore(temploc).get_item(temploc).data
course.intro_video = CourseDetails.parse_video_tag(raw_video)
......@@ -126,16 +126,16 @@ class CourseDetails(object):
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
# to make faster, could compare against db or could have client send over a list of which fields changed.
temploc = Location(course_location)._replace(category='about', name='syllabus')
temploc = Location(course_location).replace(category='about', name='syllabus')
update_item(temploc, jsondict['syllabus'])
temploc = temploc._replace(name='overview')
temploc = temploc.replace(name='overview')
update_item(temploc, jsondict['overview'])
temploc = temploc._replace(name='effort')
temploc = temploc.replace(name='effort')
update_item(temploc, jsondict['effort'])
temploc = temploc._replace(name='video')
temploc = temploc.replace(name='video')
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
update_item(temploc, recomposed_video_tag)
......@@ -153,9 +153,9 @@ class CourseDetails(object):
if not raw_video:
return None
keystring_matcher = re.search('(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
keystring_matcher = re.search(r'(?<=embed/)[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher is None:
keystring_matcher = re.search('<?=\d+:[a-zA-Z0-9_-]+', raw_video)
keystring_matcher = re.search(r'<?=\d+:[a-zA-Z0-9_-]+', raw_video)
if keystring_matcher:
return keystring_matcher.group(0)
......@@ -174,10 +174,10 @@ class CourseDetails(object):
return result
# TODO move to a more general util? Is there a better way to do the isinstance model check?
# TODO move to a more general util?
class CourseSettingsEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel):
if isinstance(obj, (CourseDetails, course_grading.CourseGradingModel)):
return obj.__dict__
elif isinstance(obj, Location):
return obj.dict()
......
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope
from xmodule.course_module import CourseDescriptor
......
......@@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value
# load segment.io key, provide a dummy if it does not exist
SEGMENT_IO_KEY = ENV_TOKENS.get('SEGMENT_IO_KEY', '***REMOVED***')
LOGGING = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
......@@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR,
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
AUTH_TOKENS = json.load(auth_file)
# If Segment.io key specified, load it and turn on Segment.io if the feature flag is set
# Note that this is the Studio key. There is a separate key for the LMS.
SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES']
......
......@@ -21,7 +21,7 @@ Longer TODO:
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
# pylint: disable=W0401, W0611, W0614
import sys
import lms.envs.common
......@@ -32,13 +32,23 @@ from path import path
MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES': False,
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
'STAFF_EMAIL': '', # email address for staff (eg to request course creation)
# do not display video when running automated acceptance tests
'STUB_VIDEO_FOR_TESTING': False,
# email address for staff (eg to request course creation)
'STAFF_EMAIL': '',
'STUDIO_NPS_SURVEY': True,
'SEGMENT_IO': True,
# Segment.io - must explicitly turn it on for production
'SEGMENT_IO': False,
# Enable URL that shows information about the status of various services
'ENABLE_SERVICE_STATUS': False,
......@@ -225,10 +235,10 @@ PIPELINE_JS = {
'source_filenames': sorted(
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
) + ['js/hesitate.js', 'js/base.js',
'js/models/feedback.js', 'js/views/feedback.js',
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js'],
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/views/assets.js'],
'output_filename': 'js/cms-application.js',
'test_order': 0
},
......
......@@ -43,10 +43,15 @@ CONTENTSTORE = {
'OPTIONS': {
'host': 'localhost',
'db': 'xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
......@@ -163,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
# Enable URL that shows information about the status of variuous services
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
# segment-io key for dev
SEGMENT_IO_KEY = 'mty8edrrsg'
############################# SEGMENT-IO ##################################
# If there's an environment variable set, grab it and turn on Segment.io
# Note that this is the Studio key. There is a separate key for the LMS.
import os
SEGMENT_IO_KEY = os.environ.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = True
#####################################################################
......
......@@ -7,9 +7,7 @@
# FORCE_SCRIPT_NAME = '/cms'
from .common import *
from logsettings import get_logger_config
from .dev import *
import socket
MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
......
......@@ -70,7 +70,13 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'xcontent',
'db': 'test_xmodule',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
......
......@@ -7,6 +7,7 @@
"js/vendor/jquery.cookie.js",
"js/vendor/json2.js",
"js/vendor/underscore-min.js",
"js/vendor/underscore.string.min.js",
"js/vendor/backbone-min.js",
"js/vendor/jquery.leanModal.min.js",
"js/vendor/sinon-1.7.1.js",
......
describe "CMS.Models.SystemFeedback", ->
beforeEach ->
@model = new CMS.Models.SystemFeedback()
it "should have an empty message by default", ->
expect(@model.get("message")).toEqual("")
it "should have an empty title by default", ->
expect(@model.get("title")).toEqual("")
it "should not have an intent set by default", ->
expect(@model.get("intent")).toBeNull()
describe "CMS.Models.WarningMessage", ->
beforeEach ->
@model = new CMS.Models.WarningMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("warning")
describe "CMS.Models.ErrorMessage", ->
beforeEach ->
@model = new CMS.Models.ErrorMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("error")
describe "CMS.Models.ConfirmationMessage", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage()
it "should have the correct intent", ->
expect(@model.get("intent")).toEqual("confirmation")
......@@ -18,79 +18,105 @@ beforeEach ->
else
return trimmedText.indexOf(text) != -1;
describe "CMS.Views.Alert as base class", ->
describe "CMS.Views.SystemFeedback", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage({
@options =
title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
})
# it will be interesting to see when this.render is called, so lets spy on it
spyOn(CMS.Views.Alert.prototype, 'render').andCallThrough()
it "renders on initalize", ->
view = new CMS.Views.Alert({model: @model})
expect(view.render).toHaveBeenCalled()
@renderSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'render').andCallThrough()
@showSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'show').andCallThrough()
@hideSpy = spyOn(CMS.Views.Alert.Confirmation.prototype, 'hide').andCallThrough()
it "requires a type and an intent", ->
neither = =>
new CMS.Views.SystemFeedback(@options)
noType = =>
options = $.extend({}, @options)
options.intent = "confirmation"
new CMS.Views.SystemFeedback(options)
noIntent = =>
options = $.extend({}, @options)
options.type = "alert"
new CMS.Views.SystemFeedback(options)
both = =>
options = $.extend({}, @options)
options.type = "alert"
options.intent = "confirmation"
new CMS.Views.SystemFeedback(options)
expect(neither).toThrow()
expect(noType).toThrow()
expect(noIntent).toThrow()
expect(both).not.toThrow()
# for simplicity, we'll use CMS.Views.Alert.Confirmation from here on,
# which extends and proxies to CMS.Views.SystemFeedback
it "does not show on initalize", ->
view = new CMS.Views.Alert.Confirmation(@options)
expect(@renderSpy).not.toHaveBeenCalled()
expect(@showSpy).not.toHaveBeenCalled()
it "renders the template", ->
view = new CMS.Views.Alert({model: @model})
view = new CMS.Views.Alert.Confirmation(@options)
view.show()
expect(view.$(".action-close")).toBeDefined()
expect(view.$('.wrapper')).toBeShown()
expect(view.$el).toContainText(@model.get("title"))
expect(view.$el).toContainText(@model.get("message"))
expect(view.$el).toContainText(@options.title)
expect(view.$el).toContainText(@options.message)
it "close button sends a .hide() message", ->
spyOn(CMS.Views.Alert.prototype, 'hide').andCallThrough()
view = new CMS.Views.Alert({model: @model})
view = new CMS.Views.Alert.Confirmation(@options).show()
view.$(".action-close").click()
expect(CMS.Views.Alert.prototype.hide).toHaveBeenCalled()
expect(@hideSpy).toHaveBeenCalled()
expect(view.$('.wrapper')).toBeHiding()
describe "CMS.Views.Prompt", ->
beforeEach ->
@model = new CMS.Models.ConfirmationMessage({
title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
})
# for some reason, expect($("body")) blows up the test runner, so this test
# just exercises the Prompt rather than asserting on anything. Best I can
# do for now. :(
it "changes class on body", ->
# expect($("body")).not.toHaveClass("prompt-is-shown")
view = new CMS.Views.Prompt({model: @model})
view = new CMS.Views.Prompt.Confirmation({
title: "Portal"
message: "Welcome to the Aperture Science Computer-Aided Enrichment Center"
})
# expect($("body")).toHaveClass("prompt-is-shown")
view.hide()
# expect($("body")).not.toHaveClass("prompt-is-shown")
describe "CMS.Views.Alert click events", ->
describe "CMS.Views.SystemFeedback click events", ->
beforeEach ->
@model = new CMS.Models.WarningMessage(
@primaryClickSpy = jasmine.createSpy('primaryClick')
@secondaryClickSpy = jasmine.createSpy('secondaryClick')
@view = new CMS.Views.Notification.Warning(
title: "Unsaved",
message: "Your content is currently Unsaved.",
actions:
primary:
text: "Save",
class: "save-button",
click: jasmine.createSpy('primaryClick')
click: @primaryClickSpy
secondary: [{
text: "Revert",
class: "cancel-button",
click: jasmine.createSpy('secondaryClick')
click: @secondaryClickSpy
}]
)
@view = new CMS.Views.Alert({model: @model})
@view.show()
it "should trigger the primary event on a primary click", ->
@view.primaryClick()
expect(@model.get('actions').primary.click).toHaveBeenCalled()
@view.$(".action-primary").click()
expect(@primaryClickSpy).toHaveBeenCalled()
expect(@secondaryClickSpy).not.toHaveBeenCalled()
it "should trigger the secondary event on a secondary click", ->
@view.secondaryClick()
expect(@model.get('actions').secondary[0].click).toHaveBeenCalled()
@view.$(".action-secondary").click()
expect(@secondaryClickSpy).toHaveBeenCalled()
expect(@primaryClickSpy).not.toHaveBeenCalled()
it "should apply class to primary action", ->
expect(@view.$(".action-primary")).toHaveClass("save-button")
......@@ -100,20 +126,18 @@ describe "CMS.Views.Alert click events", ->
describe "CMS.Views.Notification minShown and maxShown", ->
beforeEach ->
@model = new CMS.Models.SystemFeedback(
intent: "saving"
title: "Saving"
)
spyOn(CMS.Views.Notification.prototype, 'show').andCallThrough()
spyOn(CMS.Views.Notification.prototype, 'hide').andCallThrough()
@showSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'show')
@showSpy.andCallThrough()
@hideSpy = spyOn(CMS.Views.Notification.Saving.prototype, 'hide')
@hideSpy.andCallThrough()
@clock = sinon.useFakeTimers()
afterEach ->
@clock.restore()
it "a minShown view should not hide too quickly", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({minShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# call hide() on it, but the minShown should prevent it from hiding right away
......@@ -125,8 +149,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view should hide by itself", ->
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({maxShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# wait for the maxShown timeout to expire, and check again
......@@ -134,13 +158,13 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a minShown view can stay visible longer", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({minShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# wait for the minShown timeout to expire, and check again
@clock.tick(1001)
expect(CMS.Views.Notification.prototype.hide).not.toHaveBeenCalled()
expect(@hideSpy).not.toHaveBeenCalled()
expect(view.$('.wrapper')).toBeShown()
# can now hide immediately
......@@ -148,8 +172,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a maxShown view can hide early", ->
view = new CMS.Views.Notification({model: @model, maxShown: 1000})
expect(CMS.Views.Notification.prototype.show).toHaveBeenCalled()
view = new CMS.Views.Notification.Saving({maxShown: 1000})
view.show()
expect(view.$('.wrapper')).toBeShown()
# wait 50 milliseconds, and hide it early
......@@ -162,7 +186,8 @@ describe "CMS.Views.Notification minShown and maxShown", ->
expect(view.$('.wrapper')).toBeHiding()
it "a view can have both maxShown and minShown", ->
view = new CMS.Views.Notification({model: @model, minShown: 1000, maxShown: 2000})
view = new CMS.Views.Notification.Saving({minShown: 1000, maxShown: 2000})
view.show()
# can't hide early
@clock.tick(50)
......
......@@ -18,11 +18,15 @@ $ ->
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false
return
msg = new CMS.Models.ErrorMessage(
if jqXHR.responseText
message = _.str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new CMS.Views.Notification.Error(
"title": gettext("Studio's having trouble saving your work")
"message": jqXHR.responseText || gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
"message": message
)
new CMS.Views.Notification({model: msg})
msg.show()
window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i
......
......@@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View
[@metadataEditor.getDisplayName()])
@$el.find('.component-name').html(title)
customMetadata: ->
# Hack to support metadata fields that aren't part of the metadata editor (ie, LaTeX high level source).
# Walk through the set of elements which have the 'data-metadata_name' attribute and
# build up an object to pass back to the server on the subsequent POST.
# Note that these values will always be sent back on POST, even if they did not actually change.
_metadata = {}
_metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', @$component_editor())
return _metadata
changedMetadata: ->
return @metadataEditor.getModifiedMetadataValues()
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
cloneTemplate: (parent, template) ->
$.post("/clone_item", {
......
......@@ -32,8 +32,6 @@ $(document).ready(function() {
$modal.bind('click', hideModal);
$modalCover.bind('click', hideModal);
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$body.on('click', '.embeddable-xml-input', function() {
$(this).select();
......@@ -145,8 +143,6 @@ $(document).ready(function() {
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
$body.on('click', '.section-published-date .edit-button', editSectionPublishDate);
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
......@@ -398,73 +394,6 @@ function _deleteItem($el) {
});
}
function showUploadModal(e) {
e.preventDefault();
$modal = $('.upload-modal').show();
$('.file-input').bind('change', startUpload);
$modalCover.show();
}
function showFileSelectionMenu(e) {
e.preventDefault();
$('.file-input').click();
}
function startUpload(e) {
var files = $('.file-input').get(0).files;
if (files.length === 0)
return;
$('.upload-modal h1').html(gettext('Uploading…'));
$('.upload-modal .file-name').html(files[0].name);
$('.upload-modal .file-chooser').ajaxSubmit({
beforeSend: resetUploadBar,
uploadProgress: showUploadFeedback,
complete: displayFinishedUpload
});
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
}
function resetUploadBar() {
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function showUploadFeedback(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function displayFinishedUpload(xhr) {
if (xhr.status = 200) {
markAsLoaded();
}
var resp = JSON.parse(xhr.responseText);
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg);
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
$('.upload-modal .progress-fill').width('100%');
// see if this id already exists, if so, then user must have updated an existing piece of content
$("tr[data-id='" + resp.url + "']").remove();
var template = $('#new-asset-element').html();
var html = Mustache.to_html(template, resp);
$('table > tbody').prepend(html);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': resp.url
});
}
function markAsLoaded() {
$('.upload-modal .copy-button').css('display', 'inline-block');
$('.upload-modal .progress-bar').addClass('loaded');
......
CMS.Models.SystemFeedback = Backbone.Model.extend({
defaults: {
"intent": null, // "warning", "confirmation", "error", "announcement", "step-required", etc
"title": "",
"message": ""
/* could also have an "actions" hash: here is an example demonstrating
the expected structure
"actions": {
"primary": {
"text": "Save",
"class": "action-save",
"click": function() {
// do something when Save is clicked
// `this` refers to the model
}
},
"secondary": [
{
"text": "Cancel",
"class": "action-cancel",
"click": function() {}
}, {
"text": "Discard Changes",
"class": "action-discard",
"click": function() {}
}
]
}
*/
}
});
CMS.Models.WarningMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "warning"
})
});
CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "error"
})
});
CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "confirmation"
})
});
......@@ -22,22 +22,16 @@ CMS.Models.Section = Backbone.Model.extend({
},
showNotification: function() {
if(!this.msg) {
this.msg = new CMS.Models.SystemFeedback({
intent: "saving",
title: gettext("Saving&hellip;")
});
}
if(!this.msgView) {
this.msgView = new CMS.Views.Notification({
model: this.msg,
this.msg = new CMS.Views.Notification.Saving({
title: gettext("Saving&hellip;"),
closeIcon: false,
minShown: 1250
});
}
this.msgView.show();
this.msg.show();
},
hideNotification: function() {
if(!this.msgView) { return; }
this.msgView.hide();
if(!this.msg) { return; }
this.msg.hide();
}
});
$(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: {
type: "alert",
title: "",
message: "",
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
type: null, // "alert", "notification", or "prompt": set by subclass
shown: true, // is this view currently being shown?
icon: true, // should we render an icon related to the message intent?
closeIcon: true, // should we render a close button in the top right corner?
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
/* could also have an "actions" hash: here is an example demonstrating
the expected structure
actions: {
primary: {
"text": "Save",
"class": "action-save",
"click": function(view) {
// do something when Save is clicked
}
},
secondary: [
{
"text": "Cancel",
"class": "action-cancel",
"click": function(view) {}
}, {
"text": "Discard Changes",
"class": "action-discard",
"click": function(view) {}
}
]
}
*/
},
initialize: function() {
if(!this.options.type) {
throw "SystemFeedback: type required (given " +
JSON.stringify(this.options) + ")";
}
if(!this.options.intent) {
throw "SystemFeedback: intent required (given " +
JSON.stringify(this.options) + ")";
}
var tpl = $("#system-feedback-tpl").text();
if(!tpl) {
console.error("Couldn't load system-feedback template");
}
this.template = _.template(tpl);
this.setElement($("#page-"+this.options.type));
this.listenTo(this.model, 'change', this.render);
return this.show();
},
render: function() {
var attrs = $.extend({}, this.options, this.model.attributes);
this.$el.html(this.template(attrs));
return this;
},
events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
// public API: show() and hide()
show: function() {
clearTimeout(this.hideTimeout);
this.options.shown = true;
this.shownAt = new Date();
this.render();
if($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout($.proxy(this.hide, this),
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown);
}
return this;
......@@ -43,7 +68,7 @@ CMS.Views.Alert = Backbone.View.extend({
this.options.minShown > new Date() - this.shownAt)
{
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout($.proxy(this.hide, this),
this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt));
} else {
this.options.shown = false;
......@@ -52,40 +77,64 @@ CMS.Views.Alert = Backbone.View.extend({
}
return this;
},
primaryClick: function() {
var actions = this.model.get("actions");
// the rest of the API should be considered semi-private
events: {
"click .action-close": "hide",
"click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick"
},
render: function() {
// there can be only one active view of a given type at a time: only
// one alert, only one notification, only one prompt. Therefore, we'll
// use a singleton approach.
var parent = CMS.Views[_.str.capitalize(this.options.type)];
if(parent && parent.active && parent.active !== this) {
parent.active.stopListening();
parent.active.undelegateEvents();
}
this.$el.html(this.template(this.options));
parent.active = this;
return this;
},
primaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; }
var primary = actions.primary;
if(!primary) { return; }
if(primary.click) {
primary.click.call(this.model, this);
primary.click.call(event.target, this, event);
}
},
secondaryClick: function(e) {
var actions = this.model.get("actions");
secondaryClick: function(event) {
var actions = this.options.actions;
if(!actions) { return; }
var secondaryList = actions.secondary;
if(!secondaryList) { return; }
// which secondary action was clicked?
var i = 0; // default to the first secondary action (easier for testing)
if(e && e.target) {
i = _.indexOf(this.$(".action-secondary"), e.target);
if(event && event.target) {
i = _.indexOf(this.$(".action-secondary"), event.target);
}
var secondary = this.model.get("actions").secondary[i];
var secondary = secondaryList[i];
if(secondary.click) {
secondary.click.call(this.model, this);
secondary.click.call(event.target, this, event);
}
}
});
CMS.Views.Notification = CMS.Views.Alert.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, {
CMS.Views.Alert = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "alert"
})
});
CMS.Views.Notification = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "notification",
closeIcon: false
})
});
CMS.Views.Prompt = CMS.Views.Alert.extend({
options: $.extend({}, CMS.Views.Alert.prototype.options, {
CMS.Views.Prompt = CMS.Views.SystemFeedback.extend({
options: $.extend({}, CMS.Views.SystemFeedback.prototype.options, {
type: "prompt",
closeIcon: false,
icon: false
......@@ -98,6 +147,27 @@ CMS.Views.Prompt = CMS.Views.Alert.extend({
$body.removeClass('prompt-is-shown');
}
// super() in Javascript has awkward syntax :(
return CMS.Views.Alert.prototype.render.apply(this, arguments);
return CMS.Views.SystemFeedback.prototype.render.apply(this, arguments);
}
});
// create CMS.Views.Alert.Warning, CMS.Views.Notification.Confirmation,
// CMS.Views.Prompt.StepRequired, etc
var capitalCamel, types, intents;
capitalCamel = _.compose(_.str.capitalize, _.str.camelize);
types = ["alert", "notification", "prompt"];
intents = ["warning", "error", "confirmation", "announcement", "step-required", "help", "saving"];
_.each(types, function(type) {
_.each(intents, function(intent) {
// "class" is a reserved word in Javascript, so use "klass" instead
var klass, subklass;
klass = CMS.Views[capitalCamel(type)];
subklass = klass.extend({
options: $.extend({}, klass.prototype.options, {
type: type,
intent: intent
})
});
klass[capitalCamel(intent)] = subklass;
});
});
......@@ -67,7 +67,7 @@ CMS.Views.SectionEdit = Backbone.View.extend({
showInvalidMessage: function(model, error, options) {
model.set("name", model.previous("name"));
var that = this;
var msg = new CMS.Models.ErrorMessage({
var prompt = new CMS.Views.Prompt.Error({
title: gettext("Your change could not be saved"),
message: error,
actions: {
......@@ -80,6 +80,6 @@ CMS.Views.SectionEdit = Backbone.View.extend({
}
}
});
new CMS.Views.Prompt({model: msg});
prompt.show();
}
});
// studio - elements - system help
// ====================
// notices - in-context: to be used as notices to users within the context of a form/action
.notice-incontext {
@extend .ui-well;
@include border-radius(($baseline/10));
.title {
@extend .t-title7;
margin-bottom: ($baseline/4);
font-weight: 600;
}
.copy {
@extend .t-copy-sub1;
@include transition(opacity 0.25s ease-in-out 0);
opacity: 0.75;
}
strong {
font-weight: 600;
}
&:hover {
.copy {
opacity: 1.0;
}
}
}
// particular warnings around a workflow for something
.notice-workflow {
background: $yellow-l5;
.copy {
color: $gray-d1;
}
}
......@@ -76,6 +76,10 @@ body.course.uploads {
width: 250px;
}
.delete-col {
width: 20px;
}
.embeddable-xml-input {
@include box-shadow(none);
width: 100%;
......
......@@ -21,7 +21,7 @@ body.course.settings {
font-size: 14px;
}
.message-status {
.message-status {
display: none;
@include border-top-radius(2px);
@include box-sizing(border-box);
......@@ -52,6 +52,12 @@ body.course.settings {
}
}
// notices - used currently for edx mktg
.notice-workflow {
margin-top: ($baseline);
}
// in form - elements
.group-settings {
margin: 0 0 ($baseline*2) 0;
......
<%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%block name="bodyclass">is-signedin course uploads</%block>
<%block name="title">Files &amp; Uploads</%block>
......@@ -30,6 +31,9 @@
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly>
</td>
<td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td>
</tr>
</script>
......@@ -56,7 +60,7 @@
<div class="page-actions">
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
</div>
<article class="asset-library">
<article class="asset-library" data-remove-asset-callback-url='${remove_asset_callback_url}'>
<table>
<thead>
<tr>
......@@ -64,6 +68,7 @@
<th class="name-col">Name</th>
<th class="date-col">Date Added</th>
<th class="embed-col">URL</th>
<th class="delete-col"></th>
</tr>
</thead>
<tbody id="asset_table_body">
......@@ -86,6 +91,9 @@
<td class="embed-col">
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly>
</td>
<td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td>
</tr>
% endfor
</tbody>
......@@ -129,3 +137,21 @@
</%block>
<%block name="view_alerts">
<!-- alert: save confirmed with close -->
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
<div class="alert confirmation">
<i class="icon-ok"></i>
<div class="copy">
<h2 class="title title-3">${_('Your file has been deleted.')}</h2>
</div>
<a href="" rel="view" class="action action-alert-close">
<i class="icon-remove-sign"></i>
<span class="label">${_('close alert')}</span>
</a>
</div>
</div>
</%block>
......@@ -38,6 +38,7 @@
<script type="text/javascript" src="/jsi18n/"></script>
<script type="text/javascript" src="${static.url('js/vendor/json2.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/jquery.markitup.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/markitup/sets/wiki/set.js')}"></script>
......@@ -54,7 +55,6 @@
<script type="text/javascript" src="${static.url('js/vendor/CodeMirror/css.js')}"></script>
<script type="text/javascript" src="//www.youtube.com/player_api"></script>
<script src="${static.url('js/models/feedback.js')}"></script>
<script src="${static.url('js/views/feedback.js')}"></script>
<!-- view -->
......
Thank you for signing up for edX edge! To activate your account,
Thank you for signing up for edX Studio! To activate your account,
please copy and paste this address into your web browser's
address bar:
......
from django.conf import settings
from django.conf.urls import patterns, include, url
# Import this file so it can do its work, even though we don't use the name.
# pylint: disable=W0611
from . import one_time_startup
# Uncomment the next two lines to enable the admin:
......@@ -35,6 +38,8 @@ urlpatterns = ('', # nopep8
'contentstore.views.preview_dispatch', name='preview_dispatch'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
'contentstore.views.upload_asset', name='upload_asset'),
url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'),
url(r'^add_user/(?P<location>.*?)$',
'contentstore.views.add_user', name='add_user'),
......@@ -71,8 +76,11 @@ urlpatterns = ('', # nopep8
'contentstore.views.edit_static', name='edit_static'),
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
'contentstore.views.asset_index', name='asset_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$',
'contentstore.views.assets.remove_asset', name='remove_asset'),
# this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$',
......
......@@ -12,7 +12,6 @@ from django.core.cache import cache
from django.db import DEFAULT_DB_ALIAS
from . import app_settings
from xmodule.contentstore.content import StaticContent
def get_instance(model, instance_or_pk, timeout=None, using=None):
......
......@@ -3,7 +3,6 @@ This file contains the logic for cohort groups, as exposed internally to the
forums, and to the cohort admin views.
"""
from django.contrib.auth.models import User
from django.http import Http404
import logging
import random
......@@ -27,7 +26,7 @@ def local_random():
"""
# ironic, isn't it?
global _local_random
if _local_random is None:
_local_random = random.Random()
......
from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.contrib.auth.models import User
from django.core.context_processors import csrf
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseForbidden, Http404
from django.shortcuts import redirect
from django.http import HttpResponse
import json
import logging
import re
from courseware.courses import get_course_with_access
from mitxmako.shortcuts import render_to_response, render_to_string
from mitxmako.shortcuts import render_to_response
from .models import CourseUserGroup
from . import cohorts
import track.views
log = logging.getLogger(__name__)
......
# -*- 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
from django.template.loaders.app_directories import Loader as AppDirectoriesLoader
from mitxmako.template import Template
import mitxmako.middleware
import tempdir
......
......@@ -6,7 +6,6 @@ from django.conf import settings
import json
import logging
import os
import sys
log = logging.getLogger(__name__)
......
......@@ -11,12 +11,7 @@
import datetime
import json
import os.path
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
from student.models import UserProfile
......
......@@ -3,17 +3,11 @@
## See export for more info
import datetime
import json
import dateutil.parser
import os.path
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
from student.models import UserProfile
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware
......
......@@ -2,9 +2,7 @@
## A script to create some dummy users
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
from student.models import UserProfile, CourseEnrollment
from student.models import CourseEnrollment
from student.views import _do_create_account, get_random_post_override
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware
......
import os.path
import time
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware
......
......@@ -2,7 +2,7 @@ from optparse import make_option
from json import dump
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from student.models import TestCenterRegistration
......
......@@ -3,11 +3,8 @@ import csv
from zipfile import ZipFile, is_zipfile
from time import strptime, strftime
from collections import OrderedDict
from datetime import datetime
from os.path import isdir
from optparse import make_option
from dogapi import dog_http_api, dog_stats_api
from dogapi import dog_http_api
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
......
......@@ -26,7 +26,7 @@ class Command(BaseCommand):
raise CommandError('Usage is set_staff {0}'.format(self.args))
for user in args:
if re.match('[^@]+@[^@]+\.[^@]+', user):
if re.match(r'[^@]+@[^@]+\.[^@]+', user):
try:
v = User.objects.get(email=user)
except:
......
......@@ -14,7 +14,7 @@ from django.test import TestCase
from django.core.management import call_command
from nose.plugins.skip import SkipTest
from student.models import User, TestCenterRegistration, TestCenterUser, get_testcenter_registration
from student.models import User, TestCenterUser, get_testcenter_registration
log = logging.getLogger(__name__)
......
import os.path
from lxml import etree
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User
import mitxmako.middleware as middleware
......
......@@ -3,8 +3,8 @@ import feedparser
import json
import logging
import random
import re
import string
import sys
import urllib
import uuid
import time
......@@ -20,9 +20,9 @@ from django.core.mail import send_mail
from django.core.urlresolvers import reverse
from django.core.validators import validate_email, validate_slug, ValidationError
from django.db import IntegrityError, transaction
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseRedirect, Http404
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, Http404
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
from django_future.csrf import ensure_csrf_cookie
from django.utils.http import cookie_date
from mitxmako.shortcuts import render_to_response, render_to_string
......@@ -39,14 +39,13 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from collections import namedtuple
from courseware.courses import get_courses, sort_by_announcement
from courseware.access import has_access
from courseware.views import get_module_for_descriptor, jump_to
from courseware.model_data import ModelDataCache
from external_auth.models import ExternalAuthMap
from statsd import statsd
from pytz import UTC
......@@ -99,9 +98,8 @@ def course_from_id(course_id):
course_loc = CourseDescriptor.id_to_location(course_id)
return modulestore().get_instance(course_id, course_loc)
import re
day_pattern = re.compile('\s\d+,\s')
multimonth_pattern = re.compile('\s?\-\s?\S+\s')
day_pattern = re.compile(r'\s\d+,\s')
multimonth_pattern = re.compile(r'\s?\-\s?\S+\s')
def get_date_for_press(publish_date):
......@@ -230,7 +228,7 @@ def signin_user(request):
@ensure_csrf_cookie
def register_user(request):
def register_user(request, extra_context={}):
"""
This view will display the non-modal registration form
"""
......@@ -241,6 +239,8 @@ def register_user(request):
'course_id': request.GET.get('course_id'),
'enrollment_action': request.GET.get('enrollment_action')
}
context.update(extra_context)
return render_to_response('register.html', context)
......@@ -282,9 +282,17 @@ def dashboard(request):
# Get the 3 most recent news
top_news = _get_news(top=3) if not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) else None
# get info w.r.t ExternalAuthMap
external_auth_map = None
try:
external_auth_map = ExternalAuthMap.objects.get(user=user)
except ExternalAuthMap.DoesNotExist:
pass
context = {'courses': courses,
'message': message,
'external_auth_map': external_auth_map,
'staff_access': staff_access,
'errored_courses': errored_courses,
'show_courseware_links_for': show_courseware_links_for,
......@@ -571,15 +579,23 @@ def create_account(request, post_override=None):
# if doing signup for an external authorization, then get email, password, name from the eamap
# don't use the ones from the form, since the user could have hacked those
# unless originally we didn't get a valid email or name from the external auth
DoExternalAuth = 'ExternalAuthMap' in request.session
if DoExternalAuth:
eamap = request.session['ExternalAuthMap']
email = eamap.external_email
name = eamap.external_name
try:
validate_email(eamap.external_email)
email = eamap.external_email
except ValidationError:
email = post_vars.get('email', '')
if eamap.external_name.strip() == '':
name = post_vars.get('name', '')
else:
name = eamap.external_name
password = eamap.internal_password
post_vars = dict(post_vars.items())
post_vars.update(dict(email=email, name=name, password=password))
log.debug('extauth test: post_vars = %s' % post_vars)
log.info('In create_account with external_auth: post_vars = %s' % post_vars)
# Confirm we have a properly formed request
for a in ['username', 'email', 'password', 'name']:
......@@ -593,17 +609,28 @@ def create_account(request, post_override=None):
js['field'] = 'honor_code'
return HttpResponse(json.dumps(js))
if post_vars.get('terms_of_service', 'false') != u'true':
js['value'] = "You must accept the terms of service.".format(field=a)
js['field'] = 'terms_of_service'
return HttpResponse(json.dumps(js))
# Can't have terms of service for certain SHIB users, like at Stanford
tos_not_required = settings.MITX_FEATURES.get("AUTH_USE_SHIB") \
and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') \
and DoExternalAuth and ("shib" in eamap.external_domain)
if not tos_not_required:
if post_vars.get('terms_of_service', 'false') != u'true':
js['value'] = "You must accept the terms of service.".format(field=a)
js['field'] = 'terms_of_service'
return HttpResponse(json.dumps(js))
# Confirm appropriate fields are there.
# TODO: Check e-mail format is correct.
# TODO: Confirm e-mail is not from a generic domain (mailinator, etc.)? Not sure if
# this is a good idea
# TODO: Check password is sane
for a in ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code']:
required_post_vars = ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code']
if tos_not_required:
required_post_vars = ['username', 'email', 'name', 'password', 'honor_code']
for a in required_post_vars:
if len(post_vars[a]) < 2:
error_str = {'username': 'Username must be minimum of two characters long.',
'email': 'A properly formatted e-mail is required.',
......@@ -665,19 +692,20 @@ def create_account(request, post_override=None):
login(request, login_user)
request.session.set_expiry(0)
try_change_enrollment(request)
if DoExternalAuth:
eamap.user = login_user
eamap.dtsignup = datetime.datetime.now(UTC)
eamap.save()
log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
log.info("User registered with external_auth %s" % post_vars['username'])
log.info('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
log.debug('bypassing activation email')
log.info('bypassing activation email')
login_user.is_active = True
login_user.save()
try_change_enrollment(request)
statsd.increment("common.student.account_created")
js = {'success': True}
......
......@@ -4,7 +4,6 @@ Browser set up for acceptance tests.
#pylint: disable=E1101
#pylint: disable=W0613
#pylint: disable=W0611
from lettuce import before, after, world
from splinter.browser import Browser
......@@ -15,8 +14,9 @@ from selenium.common.exceptions import WebDriverException
# Let the LMS and CMS do their one-time setup
# For example, setting up mongo caches
from lms import one_time_startup
from cms import one_time_startup
# These names aren't used, but do important work on import.
from lms import one_time_startup # pylint: disable=W0611
from cms import one_time_startup # pylint: disable=W0611
# There is an import issue when using django-staticfiles with lettuce
# Lettuce assumes that we are using django.contrib.staticfiles,
......
#pylint: disable=C0111
#pylint: disable=W0621
# pylint: disable=C0111
# pylint: disable=W0621
from lettuce import world, step
from lettuce import world
from .factories import *
from django.conf import settings
from django.http import HttpRequest
......@@ -15,7 +15,6 @@ from xmodule.templates import update_templates
from bs4 import BeautifulSoup
import os.path
from urllib import quote_plus
from lettuce.django import django_url
@world.absorb
......
......@@ -15,13 +15,13 @@ from lettuce import world, step
from .course_helpers import *
from .ui_helpers import *
from lettuce.django import django_url
from nose.tools import assert_equals, assert_in
from nose.tools import assert_equals
from logging import getLogger
logger = getLogger(__name__)
@step(u'I wait (?:for )?"(\d+)" seconds?$')
@step(r'I wait (?:for )?"(\d+)" seconds?$')
def wait(step, seconds):
world.wait(seconds)
......
......@@ -3,6 +3,7 @@
from lettuce import world
import time
import platform
from urllib import quote_plus
from selenium.common.exceptions import WebDriverException, StaleElementReferenceException
from selenium.webdriver.support import expected_conditions as EC
......@@ -48,7 +49,7 @@ def css_has_text(css_selector, text):
@world.absorb
def css_find(css, wait_time=5):
def is_visible(driver):
def is_visible(_driver):
return EC.visibility_of_element_located((By.CSS_SELECTOR, css,))
world.browser.is_element_present_by_css(css, wait_time=wait_time)
......@@ -57,32 +58,79 @@ def css_find(css, wait_time=5):
@world.absorb
def css_click(css_selector):
def css_click(css_selector, index=0, max_attempts=5, success_condition=lambda: True):
"""
Perform a click on a CSS selector, retrying if it initially fails
Perform a click on a CSS selector, retrying if it initially fails.
This function handles errors that may be thrown if the component cannot be clicked on.
However, there are cases where an error may not be thrown, and yet the operation did not
actually succeed. For those cases, a success_condition lambda can be supplied to verify that the click worked.
This function will return True if the click worked (taking into account both errors and the optional
success_condition).
"""
assert is_css_present(css_selector)
try:
world.browser.find_by_css(css_selector).click()
attempt = 0
result = False
while attempt < max_attempts:
try:
world.css_find(css_selector)[index].click()
if success_condition():
result = True
break
except WebDriverException:
# Occasionally, MathJax or other JavaScript can cover up
# an element temporarily.
# If this happens, wait a second, then try again
world.wait(1)
attempt += 1
except:
attempt += 1
return result
@world.absorb
def css_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:
# Occassionally, MathJax or other JavaScript can cover up
# an element temporarily.
# If this happens, wait a second, then try again
world.wait(1)
world.browser.find_by_css(css_selector).click()
This function handles errors that may be thrown if the component cannot be clicked on.
However, there are cases where an error may not be thrown, and yet the operation did not
actually succeed. For those cases, a success_condition lambda can be supplied to verify that the check worked.
This function will return True if the check worked (taking into account both errors and the optional
success_condition).
"""
assert is_css_present(css_selector)
attempt = 0
result = False
while attempt < max_attempts:
try:
world.css_find(css_selector)[index].check()
if success_condition():
result = True
break
except WebDriverException:
# Occasionally, MathJax or other JavaScript can cover up
# an element temporarily.
# If this happens, wait a second, then try again
world.wait(1)
attempt += 1
except:
attempt += 1
return result
@world.absorb
def css_click_at(css, x=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
rather than in the center of the element
'''
e = css_find(css).first
e.action_chains.move_to_element_with_offset(e._element, x, y)
e.action_chains.click()
e.action_chains.perform()
element = css_find(css).first
element.action_chains.move_to_element_with_offset(element._element, x_cord, y_cord)
element.action_chains.click()
element.action_chains.perform()
@world.absorb
......@@ -127,7 +175,7 @@ def css_visible(css_selector):
@world.absorb
def dialogs_closed():
def are_dialogs_closed(driver):
def are_dialogs_closed(_driver):
'''
Return True when no modal dialogs are visible
'''
......@@ -138,12 +186,12 @@ def dialogs_closed():
@world.absorb
def save_the_html(path='/tmp'):
u = world.browser.url
url = world.browser.url
html = world.browser.html.encode('ascii', 'ignore')
filename = '%s.html' % quote_plus(u)
f = open('%s/%s' % (path, filename), 'w')
f.write(html)
f.close()
filename = '%s.html' % quote_plus(url)
file = open('%s/%s' % (path, filename), 'w')
file.write(html)
file.close()
@world.absorb
......@@ -158,3 +206,8 @@ def click_tools():
tools_css = 'li.nav-course-tools'
if world.browser.is_element_present_by_css(tools_css):
world.css_click(tools_css)
@world.absorb
def is_mac():
return platform.mac_ver()[0] is not ''
import json
from django.conf import settings
import views
......
import json
import logging
import os
import pytz
import datetime
import dateutil.parser
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.http import Http404
from django.shortcuts import redirect
from django.conf import settings
from mitxmako.shortcuts import render_to_response
......@@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', '
def log_event(event):
"""Write tracking event to log file, and optionally to TrackingLog model."""
event_str = json.dumps(event)
log.info(event_str[:settings.TRACK_MAX_EVENT])
if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
......@@ -34,6 +33,11 @@ def log_event(event):
def user_track(request):
"""
Log when GET call to "event" URL is made by a user.
GET call should provide "event_type", "event", and "page" arguments.
"""
try: # TODO: Do the same for many of the optional META parameters
username = request.user.username
except:
......@@ -50,7 +54,6 @@ def user_track(request):
except:
agent = ''
# TODO: Move a bunch of this into log_event
event = {
"username": username,
"session": scookie,
......@@ -68,6 +71,7 @@ def user_track(request):
def server_track(request, event_type, event, page=None):
"""Log events related to server requests."""
try:
username = request.user.username
except:
......@@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None):
log_event(event)
def task_track(request_info, task_info, event_type, event, page=None):
"""
Logs tracking information for events occuring within celery tasks.
The `event_type` is a string naming the particular event being logged,
while `event` is a dict containing whatever additional contextual information
is desired.
The `request_info` is a dict containing information about the original
task request. Relevant keys are `username`, `ip`, `agent`, and `host`.
While the dict is required, the values in it are not, so that {} can be
passed in.
In addition, a `task_info` dict provides more information about the current
task, to be stored with the `event` dict. This may also be an empty dict.
The `page` parameter is optional, and allows the name of the page to
be provided.
"""
# supplement event information with additional information
# about the task in which it is running.
full_event = dict(event, **task_info)
# All fields must be specified, in case the tracking information is
# also saved to the TrackingLog model. Get values from the task-level
# information, or just add placeholder values.
event = {
"username": request_info.get('username', 'unknown'),
"ip": request_info.get('ip', 'unknown'),
"event_source": "task",
"event_type": event_type,
"event": full_event,
"agent": request_info.get('agent', 'unknown'),
"page": page,
"time": datetime.datetime.utcnow().isoformat(),
"host": request_info.get('host', 'unknown')
}
log_event(event)
@login_required
@ensure_csrf_cookie
def view_tracking_log(request, args=''):
"""View to output contents of TrackingLog model. For staff use only."""
if not request.user.is_staff:
return redirect('/')
nlen = 100
......
from django.db import models
# Create your models here.
......@@ -4,7 +4,6 @@ Tests for memcache in util app
from django.test import TestCase
from django.core.cache import get_cache
from django.conf import settings
from util.memcache import safe_key
......
"""Tests for the Zendesk"""
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from django.test import TestCase
......
import datetime
import json
import logging
import pprint
import sys
from django.conf import settings
from django.contrib.auth.models import User
from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.core.validators import ValidationError, validate_email
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError
from django.shortcuts import redirect
from django_future.csrf import ensure_csrf_cookie
from django.http import Http404, HttpResponse, HttpResponseNotAllowed
from dogapi import dog_stats_api
from mitxmako.shortcuts import render_to_response, render_to_string
from urllib import urlencode
from mitxmako.shortcuts import render_to_response
import zendesk
import calc
......
import re
import json
import logging
import static_replace
......
......@@ -10,10 +10,9 @@ import sys
from path import path
from cStringIO import StringIO
from collections import defaultdict
from .calc import UndefinedVariable
from .capa_problem import LoncapaProblem
from calc import UndefinedVariable
from capa.capa_problem import LoncapaProblem
from mako.lookup import TemplateLookup
logging.basicConfig(format="%(levelname)s %(message)s")
......
......@@ -10,8 +10,6 @@ from .registry import TagRegistry
import logging
import re
import shlex # for splitting quoted strings
import json
from lxml import etree
import xml.sax.saxutils as saxutils
......@@ -28,7 +26,7 @@ class MathRenderer(object):
tags = ['math']
def __init__(self, system, xml):
'''
r'''
Render math using latex-like formatting.
Examples:
......@@ -43,7 +41,7 @@ class MathRenderer(object):
self.system = system
self.xml = xml
mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
mathstr = re.sub(r'\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
mtag = 'mathjax'
if not r'\displaystyle' in mathstr:
mtag += 'inline'
......
......@@ -856,7 +856,7 @@ class ImageInput(InputTypeBase):
"""
if value is of the form [x,y] then parse it and send along coordinates of previous answer
"""
m = re.match('\[([0-9]+),([0-9]+)]',
m = re.match(r'\[([0-9]+),([0-9]+)]',
self.value.strip().replace(' ', ''))
if m:
# Note: we subtract 15 to compensate for the size of the dot on the screen.
......
......@@ -11,7 +11,6 @@ Used by capa_problem.py
# standard library imports
import abc
import cgi
import hashlib
import inspect
import json
import logging
......@@ -1903,8 +1902,7 @@ class ImageResponse(LoncapaResponse):
if not given: # No answer to parse. Mark as incorrect and move on
continue
# parse given answer
m = re.match(
'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
m = re.match(r'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
if not m:
raise Exception('[capamodule.capa.responsetypes.imageinput] '
'error grading %s (input=%s)' % (aid, given))
......@@ -1919,7 +1917,7 @@ class ImageResponse(LoncapaResponse):
# parse expected answer
# TODO: Compile regexp on file load
m = re.match(
'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
r'[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
solution_rectangle.strip().replace(' ', ''))
if not m:
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
......
"""
Tests to verify that CorrectMap behaves correctly
"""
import unittest
from capa.correctmap import CorrectMap
import datetime
class CorrectMapTest(unittest.TestCase):
"""
Tests to verify that CorrectMap behaves correctly
"""
def setUp(self):
self.cmap = CorrectMap()
def test_set_input_properties(self):
# Set the correctmap properties for two inputs
self.cmap.set(answer_id='1_2_1',
correctness='correct',
npoints=5,
msg='Test message',
hint='Test hint',
hintmode='always',
queuestate={'key':'secretstring',
'time':'20130228100026'})
self.cmap.set(answer_id='2_2_1',
correctness='incorrect',
npoints=None,
msg=None,
hint=None,
hintmode=None,
queuestate=None)
self.cmap.set(
answer_id='1_2_1',
correctness='correct',
npoints=5,
msg='Test message',
hint='Test hint',
hintmode='always',
queuestate={
'key': 'secretstring',
'time': '20130228100026'
}
)
self.cmap.set(
answer_id='2_2_1',
correctness='incorrect',
npoints=None,
msg=None,
hint=None,
hintmode=None,
queuestate=None
)
# Assert that each input has the expected properties
self.assertTrue(self.cmap.is_correct('1_2_1'))
......@@ -62,7 +75,6 @@ class CorrectMapTest(unittest.TestCase):
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', ''))
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None))
def test_get_npoints(self):
# Set the correctmap properties for 4 inputs
# 1) correct, 5 points
......@@ -70,25 +82,35 @@ class CorrectMapTest(unittest.TestCase):
# 3) incorrect, 5 points
# 4) incorrect, None points
# 5) correct, 0 points
self.cmap.set(answer_id='1_2_1',
correctness='correct',
npoints=5)
self.cmap.set(answer_id='2_2_1',
correctness='correct',
npoints=None)
self.cmap.set(answer_id='3_2_1',
correctness='incorrect',
npoints=5)
self.cmap.set(answer_id='4_2_1',
correctness='incorrect',
npoints=None)
self.cmap.set(answer_id='5_2_1',
correctness='correct',
npoints=0)
self.cmap.set(
answer_id='1_2_1',
correctness='correct',
npoints=5
)
self.cmap.set(
answer_id='2_2_1',
correctness='correct',
npoints=None
)
self.cmap.set(
answer_id='3_2_1',
correctness='incorrect',
npoints=5
)
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
# If points assigned --> npoints
......@@ -100,7 +122,6 @@ class CorrectMapTest(unittest.TestCase):
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
def test_set_overall_message(self):
# Default is an empty string string
......@@ -118,14 +139,18 @@ class CorrectMapTest(unittest.TestCase):
def test_update_from_correctmap(self):
# Initialize a CorrectMap with some properties
self.cmap.set(answer_id='1_2_1',
correctness='correct',
npoints=5,
msg='Test message',
hint='Test hint',
hintmode='always',
queuestate={'key':'secretstring',
'time':'20130228100026'})
self.cmap.set(
answer_id='1_2_1',
correctness='correct',
npoints=5,
msg='Test message',
hint='Test hint',
hintmode='always',
queuestate={
'key': 'secretstring',
'time': '20130228100026'
}
)
self.cmap.set_overall_message("Test message")
......@@ -133,14 +158,17 @@ class CorrectMapTest(unittest.TestCase):
# as the first cmap
other_cmap = CorrectMap()
other_cmap.update(self.cmap)
# Assert that it has all the same properties
self.assertEqual(other_cmap.get_overall_message(),
self.cmap.get_overall_message())
self.assertEqual(other_cmap.get_dict(),
self.cmap.get_dict())
# 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):
# Should get an exception if we try to update() a CorrectMap
......
......@@ -2,7 +2,6 @@ import unittest
from lxml import etree
import os
import textwrap
import json
import mock
......
from calc import evaluator, UndefinedVariable
from calc import evaluator
from cmath import isinf
#-----------------------------------------------------------------------------
......
from __future__ import division
import copy
from fractions import Fraction
import logging
import math
import operator
import re
import numpy
import numbers
import scipy.constants
from pyparsing import (Literal, Keyword, Word, nums, StringEnd, Optional,
Forward, OneOrMore, ParseException)
from pyparsing import (Literal, StringEnd, OneOrMore, ParseException)
import nltk
from nltk.tree import Tree
......
......@@ -10,7 +10,6 @@
# Provides sympy representation.
import os
import sys
import string
import re
import logging
......@@ -25,8 +24,7 @@ from sympy.physics.quantum.state import *
# from sympy.core.operations import LatticeOp
# import sympy.physics.quantum.qubit
import urllib
from xml.sax.saxutils import escape, unescape
from xml.sax.saxutils import unescape
import sympy
import unicodedata
from lxml import etree
......@@ -52,7 +50,7 @@ class dot(sympy.operations.LatticeOp): # my dot product
def _print_dot(self, expr):
return '{((%s) \cdot (%s))}' % (expr.args[0], expr.args[1])
return r'{((%s) \cdot (%s))}' % (expr.args[0], expr.args[1])
LatexPrinter._print_dot = _print_dot
......@@ -204,7 +202,7 @@ class formula(object):
return xml
def preprocess_pmathml(self, xml):
'''
r'''
Pre-process presentation MathML from ASCIIMathML to make it more
acceptable for SnuggleTeX, and also to accomodate some sympy
conventions (eg hat(i) for \hat{i}).
......
......@@ -8,10 +8,6 @@
#
# Takes in math expressions given as Presentation MathML (from ASCIIMathML), converts to Content MathML using SnuggleTeX
import os
import sys
import string
import re
import traceback
from .formula import *
import logging
......
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