Merge branch 'master' into blapenta/fix-test-coverage
Conflicts: AUTHORS common/lib/xmodule/xmodule/tests/test_annotatable_module.py common/lib/xmodule/xmodule/tests/test_capa_module.py common/lib/xmodule/xmodule/tests/test_combined_open_ended.py common/lib/xmodule/xmodule/tests/test_conditional.py common/lib/xmodule/xmodule/tests/test_html_module.py common/lib/xmodule/xmodule/tests/test_progress.py common/lib/xmodule/xmodule/tests/test_xml_module.py lms/djangoapps/courseware/tests/__init__.py
This commit is contained in:
1
AUTHORS
1
AUTHORS
@@ -76,3 +76,4 @@ Jonah Stanley <Jonah_Stanley@brown.edu>
|
||||
Slater Victoroff <slater.r.victoroff@gmail.com>
|
||||
Peter Fogg <peter.p.fogg@gmail.com>
|
||||
Bethany LaPenta <lapentab@mit.edu>
|
||||
Renzo Lucioni <renzolucioni@gmail.com>
|
||||
|
||||
110
CHANGELOG.rst
Normal file
110
CHANGELOG.rst
Normal file
@@ -0,0 +1,110 @@
|
||||
Change Log
|
||||
----------
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
SEGMENT_IO_LMS feature flag is on)
|
||||
|
||||
Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions.
|
||||
|
||||
LMS: Background colors on login, register, and courseware have been corrected
|
||||
back to white.
|
||||
|
||||
LMS: Accessibility improvements have been made to several courseware and
|
||||
navigation elements.
|
||||
|
||||
LMS: Small design/presentation changes to login and register views.
|
||||
|
||||
LMS: Functionality added to instructor enrollment tab in LMS such that invited
|
||||
student can be auto-enrolled in course or when activating if not current
|
||||
student.
|
||||
|
||||
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
|
||||
course tree from being marked as version='draft'. It will raise an exception if
|
||||
the code tries to so mark a node. We need the backtraces to figure out where
|
||||
this very infrequent intermittent marking was occurring. It was making courses
|
||||
look different in Studio than in LMS.
|
||||
|
||||
Deploy: MKTG_URLS is now read from env.json.
|
||||
|
||||
Common: Theming makes it possible to change the look of the site, from
|
||||
Stanford.
|
||||
|
||||
Common: Accessibility UI fixes.
|
||||
|
||||
Common: The "duplicate email" error message is more informative.
|
||||
|
||||
Studio: Component metadata settings editor.
|
||||
|
||||
Studio: Autoplay for Video Alpha is disabled (only in Studio).
|
||||
|
||||
Studio: Single-click creation for video and discussion components.
|
||||
|
||||
Studio: fixed a bad link in the activation page.
|
||||
|
||||
LMS: Changed the help button text.
|
||||
|
||||
LMS: Fixed failing numeric response (decimal but no trailing digits).
|
||||
|
||||
LMS: XML Error module no longer shows students a stack trace.
|
||||
|
||||
Blades: Videoalpha.
|
||||
|
||||
XModules: Added partial credit for foldit module.
|
||||
|
||||
XModules: Added "randomize" XModule to list of XModule types.
|
||||
|
||||
XModules: Show errors with full descriptors.
|
||||
|
||||
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
|
||||
dropped suddenly.
|
||||
|
||||
XQueue: Upload file submissions to a specially named bucket in S3.
|
||||
|
||||
Common: Removed request debugger.
|
||||
|
||||
Common: Updated Django to version 1.4.5.
|
||||
|
||||
Common: Updated CodeJail.
|
||||
|
||||
Common: Allow setting of authentication session cookie name.
|
||||
|
||||
@@ -115,7 +115,7 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run:
|
||||
|
||||
If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
|
||||
zsh will assume that you are doing
|
||||
[shell globbing](https://en.wikipedia.org/wiki/Glob_(programming)), search for
|
||||
[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for
|
||||
a file in your directory named `django-adminsyncdb` or `django-adminmigrate`,
|
||||
and fail. To fix this, just surround the argument with quotation marks, so that
|
||||
you're running `rake "django-admin[syncdb]"`.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
Instructions
|
||||
============
|
||||
For each pull request, add one or more lines to the bottom of the change list. When
|
||||
code is released to production, change the `Upcoming` entry to todays date, and add
|
||||
a new block at the bottom of the file.
|
||||
|
||||
Upcoming
|
||||
--------
|
||||
|
||||
Change log entries should be targeted at end users. A good place to start is the
|
||||
user story that instigated the pull request.
|
||||
|
||||
|
||||
Changes
|
||||
=======
|
||||
|
||||
Upcoming
|
||||
--------
|
||||
* Fix: Deleting last component in a unit does not work
|
||||
* Fix: Unit name is editable when a unit is public
|
||||
* Fix: Visual feedback inconsistent when saving a unit name change
|
||||
@@ -28,11 +28,18 @@ Feature: Advanced (manual) course policy
|
||||
|
||||
Scenario: Test how multi-line input appears
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value
|
||||
When I create a JSON object as a value for "discussion_topics"
|
||||
Then it is displayed as formatted
|
||||
And I reload the page
|
||||
Then it is displayed as formatted
|
||||
|
||||
Scenario: Test error if value supplied is of the wrong type
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value for "display_name"
|
||||
Then I get an error on save
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
Scenario: Test automatic quoting of non-JSON values
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a non-JSON value not in quotes
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_false, assert_equal
|
||||
|
||||
"""
|
||||
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
|
||||
from common import type_in_codemirror
|
||||
|
||||
KEY_CSS = '.key input.policy-key'
|
||||
VALUE_CSS = 'textarea.json'
|
||||
@@ -38,13 +33,7 @@ def press_the_notification_button(step, name):
|
||||
|
||||
@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$')
|
||||
@@ -52,9 +41,9 @@ def edit_the_value_of_a_policy_key_and_save(step):
|
||||
change_display_name_value(step, '"foo"')
|
||||
|
||||
|
||||
@step('I create a JSON object as a value$')
|
||||
def create_JSON_object(step):
|
||||
change_display_name_value(step, '{"key": "value", "key_2": "value_2"}')
|
||||
@step('I create a JSON object as a value for "(.*)"$')
|
||||
def create_JSON_object(step, key):
|
||||
change_value(step, key, '{"key": "value", "key_2": "value_2"}')
|
||||
|
||||
|
||||
@step('I create a non-JSON value not in quotes$')
|
||||
@@ -82,7 +71,12 @@ def they_are_alphabetized(step):
|
||||
|
||||
@step('it is displayed as formatted$')
|
||||
def it_is_formatted(step):
|
||||
assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
|
||||
assert_policy_entries(['discussion_topics'], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
|
||||
|
||||
|
||||
@step('I get an error on save$')
|
||||
def error_on_save(step):
|
||||
assert_regexp_matches(world.css_text('#notification-error-description'), 'Incorrect setting format')
|
||||
|
||||
|
||||
@step('it is displayed as a string')
|
||||
@@ -124,12 +118,9 @@ def get_display_name_value():
|
||||
|
||||
|
||||
def change_display_name_value(step, new_value):
|
||||
change_value(step, DISPLAY_NAME_KEY, new_value)
|
||||
|
||||
world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click()
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
display_name = get_display_name_value()
|
||||
for count in range(len(display_name)):
|
||||
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)
|
||||
|
||||
def change_value(step, key, new_value):
|
||||
type_in_codemirror(get_index_of(key), new_value)
|
||||
press_the_notification_button(step, "Save")
|
||||
|
||||
@@ -169,3 +169,14 @@ 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')
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
@@ -41,7 +42,9 @@ def i_see_five_settings_with_values(step):
|
||||
|
||||
@step('I can modify the display name')
|
||||
def i_can_modify_the_display_name(step):
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified')
|
||||
# Verifying that the display name can be a string containing a floating point value
|
||||
# (to confirm that we don't throw an error because it is of the wrong type).
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4')
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@@ -133,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')
|
||||
@@ -151,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'))
|
||||
@@ -172,7 +195,7 @@ def verify_modified_randomization():
|
||||
|
||||
|
||||
def verify_modified_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True)
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True)
|
||||
|
||||
|
||||
def verify_modified_display_name_with_special_chars():
|
||||
@@ -185,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')
|
||||
|
||||
@@ -9,34 +9,34 @@ from nose.tools import assert_equal
|
||||
|
||||
|
||||
@step('I click the new section link$')
|
||||
def i_click_new_section_link(step):
|
||||
def i_click_new_section_link(_step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I enter the section name and click save$')
|
||||
def i_save_section_name(step):
|
||||
def i_save_section_name(_step):
|
||||
save_section_name('My Section')
|
||||
|
||||
|
||||
@step('I enter a section name with a quote and click save$')
|
||||
def i_save_section_name_with_quote(step):
|
||||
def i_save_section_name_with_quote(_step):
|
||||
save_section_name('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I have added a new section$')
|
||||
def i_have_added_new_section(step):
|
||||
def i_have_added_new_section(_step):
|
||||
add_section()
|
||||
|
||||
|
||||
@step('I click the Edit link for the release date$')
|
||||
def i_click_the_edit_link_for_the_release_date(step):
|
||||
def i_click_the_edit_link_for_the_release_date(_step):
|
||||
button_css = 'div.section-published-date a.edit-button'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I save a new section release date$')
|
||||
def i_save_a_new_section_release_date(step):
|
||||
def i_save_a_new_section_release_date(_step):
|
||||
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013',
|
||||
'input.start-time.time.ui-timepicker-input', '00:00')
|
||||
world.browser.click_link_by_text('Save')
|
||||
@@ -46,35 +46,35 @@ def i_save_a_new_section_release_date(step):
|
||||
|
||||
|
||||
@step('I see my section on the Courseware page$')
|
||||
def i_see_my_section_on_the_courseware_page(step):
|
||||
def i_see_my_section_on_the_courseware_page(_step):
|
||||
see_my_section_on_the_courseware_page('My Section')
|
||||
|
||||
|
||||
@step('I see my section name with a quote on the Courseware page$')
|
||||
def i_see_my_section_name_with_quote_on_the_courseware_page(step):
|
||||
def i_see_my_section_name_with_quote_on_the_courseware_page(_step):
|
||||
see_my_section_on_the_courseware_page('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I click to edit the section name$')
|
||||
def i_click_to_edit_section_name(step):
|
||||
def i_click_to_edit_section_name(_step):
|
||||
world.css_click('span.section-name-span')
|
||||
|
||||
|
||||
@step('I see the complete section name with a quote in the editor$')
|
||||
def i_see_complete_section_name_with_quote_in_editor(step):
|
||||
def i_see_complete_section_name_with_quote_in_editor(_step):
|
||||
css = '.section-name-edit input[type=text]'
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
|
||||
|
||||
|
||||
@step('the section does not exist$')
|
||||
def section_does_not_exist(step):
|
||||
def section_does_not_exist(_step):
|
||||
css = 'h3[data-name="My Section"]'
|
||||
assert world.is_css_not_present(css)
|
||||
|
||||
|
||||
@step('I see a release date for my section$')
|
||||
def i_see_a_release_date_for_my_section(step):
|
||||
def i_see_a_release_date_for_my_section(_step):
|
||||
import re
|
||||
|
||||
css = 'span.published-status'
|
||||
@@ -83,26 +83,32 @@ def i_see_a_release_date_for_my_section(step):
|
||||
|
||||
# e.g. 11/06/2012 at 16:25
|
||||
msg = 'Will Release:'
|
||||
date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]'
|
||||
time_regex = '[0-2][0-9]:[0-5][0-9]'
|
||||
match_string = '%s %s at %s' % (msg, date_regex, time_regex)
|
||||
date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}'
|
||||
if not re.search(date_regex, status_text):
|
||||
print status_text, date_regex
|
||||
time_regex = r'[0-2]\d:[0-5]\d( \w{3})?'
|
||||
if not re.search(time_regex, status_text):
|
||||
print status_text, time_regex
|
||||
match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex)
|
||||
if not re.match(match_string, status_text):
|
||||
print status_text, match_string
|
||||
assert re.match(match_string, status_text)
|
||||
|
||||
|
||||
@step('I see a link to create a new subsection$')
|
||||
def i_see_a_link_to_create_a_new_subsection(step):
|
||||
def i_see_a_link_to_create_a_new_subsection(_step):
|
||||
css = 'a.new-subsection-item'
|
||||
assert world.is_css_present(css)
|
||||
|
||||
|
||||
@step('the section release date picker is not visible$')
|
||||
def the_section_release_date_picker_not_visible(step):
|
||||
def the_section_release_date_picker_not_visible(_step):
|
||||
css = 'div.edit-subsection-publish-settings'
|
||||
assert not world.css_visible(css)
|
||||
|
||||
|
||||
@step('the section release date is updated$')
|
||||
def the_section_release_date_is_updated(step):
|
||||
def the_section_release_date_is_updated(_step):
|
||||
css = 'span.published-status'
|
||||
status_text = world.css_text(css)
|
||||
assert_equal(status_text, 'Will Release: 12/25/2013 at 00:00 UTC')
|
||||
|
||||
@@ -8,3 +8,8 @@ Feature: Video Component
|
||||
Scenario: Creating a video takes a single click
|
||||
Given I have clicked the new unit button
|
||||
Then creating a video takes a single click
|
||||
|
||||
Scenario: Captions are shown 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
|
||||
|
||||
@@ -16,3 +16,13 @@ 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')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.contentstore.utils import empty_asset_trashcan
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from .prompt import query_yes_no
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("empty_asset_trashcan requires one or no arguments: |<location>|")
|
||||
|
||||
locs = []
|
||||
|
||||
if len(args) == 1:
|
||||
locs.append(CourseDescriptor.id_to_location(args[0]))
|
||||
else:
|
||||
courses = modulestore('direct').get_courses()
|
||||
for course in courses:
|
||||
locs.append(course.location)
|
||||
|
||||
if query_yes_no("Emptying trashcan. Confirm?", default="no"):
|
||||
empty_asset_trashcan(locs)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Restore a deleted asset from the trashcan back to it's original course'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) != 1 and len(args) != 0:
|
||||
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
|
||||
|
||||
restore_asset_from_trashcan(args[0])
|
||||
|
||||
@@ -19,6 +19,24 @@ 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
|
||||
:param persisted:
|
||||
:param request:
|
||||
"""
|
||||
self.assertEqual(persisted['short_description'], request['short_description'])
|
||||
compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded'))
|
||||
for pers, req in zip(persisted['items'], request['items']):
|
||||
self.assertEqual(pers['short_description'], req['short_description'])
|
||||
self.assertEqual(pers['long_description'], req['long_description'])
|
||||
self.assertEqual(pers['is_checked'], req['is_checked'])
|
||||
if compare_urls:
|
||||
self.assertEqual(pers['action_url'], req['action_url'])
|
||||
self.assertEqual(pers['action_text'], req['action_text'])
|
||||
self.assertEqual(pers['action_external'], req['action_external'])
|
||||
|
||||
def test_get_checklists(self):
|
||||
""" Tests the get checklists method. """
|
||||
checklists_url = get_url_reverse('Checklists', self.course)
|
||||
@@ -31,9 +49,9 @@ class ChecklistTestCase(CourseTestCase):
|
||||
self.course.checklists = None
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
modulestore.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.assertEquals(self.get_persisted_checklists(), None)
|
||||
self.assertEqual(self.get_persisted_checklists(), None)
|
||||
response = self.client.get(checklists_url)
|
||||
self.assertEquals(payload, response.content)
|
||||
self.assertEqual(payload, response.content)
|
||||
|
||||
def test_update_checklists_no_index(self):
|
||||
""" No checklist index, should return all of them. """
|
||||
@@ -43,7 +61,8 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
|
||||
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_index_ignored_on_get(self):
|
||||
""" Checklist index ignored on get. """
|
||||
@@ -53,7 +72,8 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'checklist_index': 1})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
|
||||
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_post_no_index(self):
|
||||
""" No checklist index, will error on post. """
|
||||
@@ -78,13 +98,18 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 2})
|
||||
|
||||
def get_first_item(checklist):
|
||||
return checklist['items'][0]
|
||||
|
||||
payload = self.course.checklists[2]
|
||||
self.assertFalse(payload.get('is_checked'))
|
||||
payload['is_checked'] = True
|
||||
self.assertFalse(get_first_item(payload).get('is_checked'))
|
||||
get_first_item(payload)['is_checked'] = True
|
||||
|
||||
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
|
||||
self.assertTrue(returned_checklist.get('is_checked'))
|
||||
self.assertEqual(self.get_persisted_checklists()[2], returned_checklist)
|
||||
self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
|
||||
pers = self.get_persisted_checklists()
|
||||
self.compare_checklists(pers[2], returned_checklist)
|
||||
|
||||
def test_update_checklists_delete_unsupported(self):
|
||||
""" Delete operation is not supported. """
|
||||
@@ -93,4 +118,4 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 100})
|
||||
response = self.client.delete(update_url)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
|
||||
@@ -28,6 +28,8 @@ from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.utils import restore_asset_from_trashcan, empty_asset_trashcan
|
||||
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
@@ -35,6 +37,7 @@ from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
@@ -271,7 +274,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
)
|
||||
self.assertTrue(getattr(draft_problem, 'is_draft', False))
|
||||
|
||||
#now requery with depth
|
||||
# now requery with depth
|
||||
course = modulestore('draft').get_item(
|
||||
Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]),
|
||||
depth=None
|
||||
@@ -382,6 +385,159 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
course = module_store.get_item(source_location)
|
||||
self.assertFalse(course.hide_progress_tab)
|
||||
|
||||
def test_asset_import(self):
|
||||
'''
|
||||
This test validates that an image asset is imported and a thumbnail was generated for a .gif
|
||||
'''
|
||||
content_store = contentstore()
|
||||
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
# make sure we have some assets in our contentstore
|
||||
all_assets = content_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our contentstore
|
||||
all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
#
|
||||
# self.assertGreater(len(all_thumbnails), 0)
|
||||
|
||||
content = None
|
||||
try:
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location)
|
||||
except NotFoundError:
|
||||
pass
|
||||
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
# self.assertIsNotNone(content.thumbnail_location)
|
||||
#
|
||||
# thumbnail = None
|
||||
# try:
|
||||
# thumbnail = content_store.find(content.thumbnail_location)
|
||||
# except:
|
||||
# pass
|
||||
#
|
||||
# self.assertIsNotNone(thumbnail)
|
||||
|
||||
def test_asset_delete_and_restore(self):
|
||||
'''
|
||||
This test will exercise the soft delete/restore functionality of the assets
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
# look up original (and thumbnail) in content store, should be there after import
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
thumbnail_location = content.thumbnail_location
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
# self.assertIsNotNone(thumbnail_location)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
|
||||
# now try to find it in store, but they should not be there any longer
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
self.assertIsNone(content)
|
||||
|
||||
if thumbnail_location:
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNone(thumbnail)
|
||||
|
||||
# now try to find it and the thumbnail in trashcan - should be in there
|
||||
content = trash_store.find(asset_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
if thumbnail_location:
|
||||
thumbnail = trash_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
# let's restore the asset
|
||||
restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
|
||||
# now try to find it in courseware store, and they should be back after restore
|
||||
content = content_store.find(asset_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
if thumbnail_location:
|
||||
thumbnail = content_store.find(thumbnail_location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(thumbnail)
|
||||
|
||||
def test_empty_trashcan(self):
|
||||
'''
|
||||
This test will exercise the empting of the asset trashcan
|
||||
'''
|
||||
content_store = contentstore()
|
||||
trash_store = contentstore('trashcan')
|
||||
module_store = modulestore('direct')
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store)
|
||||
|
||||
course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
|
||||
|
||||
location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif')
|
||||
content = content_store.find(location, throw_on_not_found=False)
|
||||
self.assertIsNotNone(content)
|
||||
|
||||
# go through the website to do the delete, since the soft-delete logic is in the view
|
||||
|
||||
url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'})
|
||||
resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# make sure there's something in the trashcan
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
self.assertGreater(len(all_assets), 0)
|
||||
|
||||
# make sure we have some thumbnails in our trashcan
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
#
|
||||
# cdodge: temporarily comment out assertion on thumbnails because many environments
|
||||
# will not have the jpeg converter installed and this test will fail
|
||||
#
|
||||
# self.assertGreater(len(all_thumbnails), 0)
|
||||
|
||||
# empty the trashcan
|
||||
empty_asset_trashcan([course_location])
|
||||
|
||||
# make sure trashcan is empty
|
||||
all_assets = trash_store.get_all_content_for_course(course_location)
|
||||
self.assertEqual(len(all_assets), 0)
|
||||
|
||||
|
||||
all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location)
|
||||
self.assertEqual(len(all_thumbnails), 0)
|
||||
|
||||
def test_clone_course(self):
|
||||
|
||||
course_data = {
|
||||
@@ -539,7 +695,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
on_disk = loads(grading_policy.read())
|
||||
self.assertEqual(on_disk, course.grading_policy)
|
||||
|
||||
#check for policy.json
|
||||
# check for policy.json
|
||||
self.assertTrue(filesystem.exists('policy.json'))
|
||||
|
||||
# compare what's on disk to what we have in the course module
|
||||
|
||||
@@ -54,6 +54,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
|
||||
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
|
||||
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
|
||||
@@ -67,7 +68,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=")
|
||||
# Note, start_date is being initialized someplace. I'm not sure why b/c the default will make no sense.
|
||||
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
|
||||
@@ -76,6 +76,23 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
|
||||
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
|
||||
|
||||
def test_ooc_encoder(self):
|
||||
"""
|
||||
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())}
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
|
||||
self.assertIn('location', jsondetails)
|
||||
self.assertIn('org', jsondetails['location'])
|
||||
self.assertEquals('org', jsondetails['location'][1])
|
||||
self.assertEquals(1, jsondetails['number'])
|
||||
self.assertEqual(jsondetails['string'], 'string')
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
|
||||
jsondetails = CourseDetails.fetch(self.course_location)
|
||||
@@ -116,11 +133,8 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(datetime):
|
||||
if datetime is not None:
|
||||
return datetime.isoformat("T")
|
||||
else:
|
||||
return None
|
||||
def convert_datetime_to_iso(dt):
|
||||
return Date().to_json(dt)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
@@ -151,22 +165,12 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
||||
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
||||
|
||||
@staticmethod
|
||||
def struct_to_datetime(struct_time):
|
||||
return datetime.datetime(*struct_time[:6], tzinfo=UTC())
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
encoded_encoded = date.from_json(encoded[field])
|
||||
dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded)
|
||||
|
||||
if isinstance(details[field], datetime.datetime):
|
||||
dt2 = details[field]
|
||||
else:
|
||||
details_encoded = date.from_json(details[field])
|
||||
dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded)
|
||||
dt1 = date.from_json(encoded[field])
|
||||
dt2 = details[field]
|
||||
|
||||
expected_delta = datetime.timedelta(0)
|
||||
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +64,7 @@ def asset_index(request, org, course, name):
|
||||
asset_id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'])
|
||||
|
||||
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
@@ -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
|
||||
@@ -103,6 +112,9 @@ def upload_asset(request, org, course, coursename):
|
||||
logging.error('Could not find course' + location)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if 'file' not in request.FILES:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# compute a 'filename' which is similar to the location formatting, we're using the 'filename'
|
||||
# nomenclature since we're using a FileSystem paradigm here. We're just imposing
|
||||
# the Location string formatting expectations to keep things a bit more consistent
|
||||
@@ -131,7 +143,7 @@ def upload_asset(request, org, course, coursename):
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
@@ -142,6 +154,57 @@ def upload_asset(request, org, course, coursename):
|
||||
return response
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def remove_asset(request, org, course, name):
|
||||
'''
|
||||
This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
|
||||
the main GridFS collection and into a Trashcan
|
||||
'''
|
||||
get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
location = request.POST['location']
|
||||
|
||||
# make sure the location is valid
|
||||
try:
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
except InvalidLocationError:
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
|
||||
# also make sure the item to delete actually exists
|
||||
try:
|
||||
content = contentstore().find(loc)
|
||||
except NotFoundError:
|
||||
response = HttpResponse()
|
||||
response.status_code = 404
|
||||
return response
|
||||
|
||||
# ok, save the content into the trashcan
|
||||
contentstore('trashcan').save(content)
|
||||
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
try:
|
||||
thumbnail_content = contentstore().find(content.thumbnail_location)
|
||||
contentstore('trashcan').save(thumbnail_content)
|
||||
# hard delete thumbnail from origin
|
||||
contentstore().delete(thumbnail_content.get_id())
|
||||
# remove from any caching
|
||||
del_cached_content(thumbnail_content.location)
|
||||
except:
|
||||
pass # OK if this is left dangling
|
||||
|
||||
# delete the original
|
||||
contentstore().delete(content.get_id())
|
||||
# remove from cache
|
||||
del_cached_content(content.location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
@@ -227,11 +290,9 @@ def generate_export_course(request, org, course, name):
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
#filename = root_dir / name + '.tar.gz'
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
@@ -32,6 +31,8 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
|
||||
NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
# TODO: should explicitly enumerate exports with __all__
|
||||
|
||||
@@ -130,7 +131,7 @@ def create_new_course(request):
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = time.gmtime()
|
||||
new_course.start = datetime.datetime.now(UTC())
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
@@ -357,52 +358,55 @@ def course_advanced_updates(request, org, course, name):
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
|
||||
#Check to see if the user instantiated any advanced components. This is a hack
|
||||
#that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically if the user
|
||||
# Check to see if the user instantiated any advanced components. This is a hack
|
||||
# that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically if the user
|
||||
# has indicated that they want to edit the combinedopendended or peergrading module
|
||||
# 2) adds/removes the notes panel tab to a course automatically if the user has
|
||||
# indicated that they want the notes module enabled in their course
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
#Get the course so that we can scrape current tabs
|
||||
# Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
#Maps tab types to components
|
||||
# Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
|
||||
#Check to see if the user instantiated any notes or open ended components
|
||||
# Check to see if the user instantiated any notes or open ended components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
#Add tab to the course if needed
|
||||
# Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
|
||||
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
# If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
# Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
#Set this flag to avoid the tab removal code below.
|
||||
# Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True
|
||||
break
|
||||
#If we did not find a module type in the advanced settings,
|
||||
# If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type:
|
||||
#Remove tab from the course if needed
|
||||
# Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should *not* be filtered out of the metadata
|
||||
# Indicate that tabs should *not* be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
try:
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
except (TypeError, ValueError), e:
|
||||
return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain")
|
||||
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
|
||||
@@ -3,26 +3,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
import time
|
||||
from contentstore.utils import get_modulestore
|
||||
from models.settings import course_grading
|
||||
from contentstore.utils import update_item
|
||||
from xmodule.fields import Date
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
class CourseDetails(object):
|
||||
def __init__(self, location):
|
||||
self.course_location = location # a Location obj
|
||||
self.course_location = location # a Location obj
|
||||
self.start_date = None # 'start'
|
||||
self.end_date = None # 'end'
|
||||
self.end_date = None # 'end'
|
||||
self.enrollment_start = None
|
||||
self.enrollment_end = None
|
||||
self.syllabus = None # a pdf file asset
|
||||
self.overview = "" # html to render as the overview
|
||||
self.intro_video = None # a video pointer
|
||||
self.effort = None # int hours/week
|
||||
self.syllabus = None # a pdf file asset
|
||||
self.overview = "" # html to render as the overview
|
||||
self.intro_video = None # a video pointer
|
||||
self.effort = None # int hours/week
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
@@ -73,9 +73,9 @@ class CourseDetails(object):
|
||||
"""
|
||||
Decode the json into CourseDetails and save any changed attrs to the db
|
||||
"""
|
||||
## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
|
||||
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
|
||||
course_location = jsondict['course_location']
|
||||
## Will probably want to cache the inflight courses because every blur generates an update
|
||||
# Will probably want to cache the inflight courses because every blur generates an update
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
@@ -181,7 +181,7 @@ class CourseSettingsEncoder(json.JSONEncoder):
|
||||
return obj.__dict__
|
||||
elif isinstance(obj, Location):
|
||||
return obj.dict()
|
||||
elif isinstance(obj, time.struct_time):
|
||||
elif isinstance(obj, datetime.datetime):
|
||||
return Date().to_json(obj)
|
||||
else:
|
||||
return JSONEncoder.default(self, obj)
|
||||
|
||||
@@ -23,7 +23,7 @@ MODULESTORE_OPTIONS = {
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'acceptance_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string'
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -25,19 +25,30 @@ Longer TODO:
|
||||
|
||||
import sys
|
||||
import lms.envs.common
|
||||
from lms.envs.common import USE_TZ
|
||||
from path import path
|
||||
|
||||
############################ FEATURE CONFIGURATION #############################
|
||||
|
||||
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,
|
||||
@@ -183,7 +194,7 @@ STATICFILES_DIRS = [
|
||||
|
||||
# Locale/Internationalization
|
||||
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
@@ -227,7 +238,8 @@ PIPELINE_JS = {
|
||||
) + ['js/hesitate.js', 'js/base.js',
|
||||
'js/models/feedback.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
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ modulestore_options = {
|
||||
'db': 'xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': GITHUB_REPO_ROOT,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string'
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
@@ -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',
|
||||
@@ -64,7 +69,7 @@ REPOS = {
|
||||
},
|
||||
'content-mit-6002x': {
|
||||
'branch': 'master',
|
||||
#'origin': 'git@github.com:MITx/6002x-fall-2012.git',
|
||||
# 'origin': 'git@github.com:MITx/6002x-fall-2012.git',
|
||||
'origin': 'git@github.com:MITx/content-mit-6002x.git',
|
||||
},
|
||||
'6.00x': {
|
||||
@@ -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
|
||||
|
||||
|
||||
#####################################################################
|
||||
|
||||
@@ -48,7 +48,7 @@ MODULESTORE_OPTIONS = {
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'test_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string'
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +127,7 @@ CELERY_RESULT_BACKEND = 'cache'
|
||||
BROKER_TRANSPORT = 'memory'
|
||||
|
||||
################### Make tests faster
|
||||
#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
|
||||
# http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
|
||||
PASSWORD_HASHERS = (
|
||||
'django.contrib.auth.hashers.SHA1PasswordHasher',
|
||||
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||
|
||||
11
cms/pydev_manage.py
Normal file
11
cms/pydev_manage.py
Normal file
@@ -0,0 +1,11 @@
|
||||
'''
|
||||
Used for pydev eclipse. Should be innocuous for everyone else.
|
||||
Created on May 8, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
#!/home/<username>/mitx_all/python/bin/python
|
||||
from django.core import management
|
||||
|
||||
if __name__ == '__main__':
|
||||
management.execute_from_command_line()
|
||||
@@ -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,69 +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) {
|
||||
$('.upload-modal h1').html(gettext('Uploading…'));
|
||||
$('.upload-modal .file-name').html($('.file-input').val().replace('C:\\fakepath\\', ''));
|
||||
$('.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');
|
||||
|
||||
@@ -42,6 +42,12 @@ CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({
|
||||
})
|
||||
});
|
||||
|
||||
CMS.Models.ConfirmAssetDeleteMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "warning"
|
||||
})
|
||||
});
|
||||
|
||||
CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
|
||||
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
|
||||
"intent": "confirmation"
|
||||
|
||||
128
cms/static/js/views/assets.js
Normal file
128
cms/static/js/views/assets.js
Normal file
@@ -0,0 +1,128 @@
|
||||
$(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.Models.ConfirmAssetDeleteMessage({
|
||||
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
|
||||
$.post(view.model.get('remove_asset_url'),
|
||||
{ 'location': view.model.get('asset_location') },
|
||||
function() {
|
||||
// show the post-commit confirmation
|
||||
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
|
||||
view.model.get('row_to_remove').remove();
|
||||
analytics.track('Deleted Asset', {
|
||||
'course': course_location_analytics,
|
||||
'id': view.model.get('asset_location')
|
||||
});
|
||||
}
|
||||
);
|
||||
view.hide();
|
||||
}
|
||||
},
|
||||
secondary: [{
|
||||
text: gettext("Cancel"),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
}
|
||||
}]
|
||||
},
|
||||
remove_asset_url: $('.asset-library').data('remove-asset-callback-url'),
|
||||
asset_location: $(this).closest('tr').data('id'),
|
||||
row_to_remove: $(this).closest('tr')
|
||||
});
|
||||
|
||||
// workaround for now. We can't spawn multiple instances of the Prompt View
|
||||
// so for now, a bit of hackery to just make sure we have a single instance
|
||||
// note: confirm_delete_prompt is in asset_index.html
|
||||
if (confirm_delete_prompt === null)
|
||||
confirm_delete_prompt = new CMS.Views.Prompt({model: msg});
|
||||
else
|
||||
{
|
||||
confirm_delete_prompt.model = msg;
|
||||
confirm_delete_prompt.show();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ body {
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
body, input {
|
||||
body, input, button {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,10 @@ body.course.uploads {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.delete-col {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.embeddable-xml-input {
|
||||
@include box-shadow(none);
|
||||
width: 100%;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%block name="bodyclass">is-signedin course uploads</%block>
|
||||
<%block name="title">Files & Uploads</%block>
|
||||
|
||||
@@ -7,6 +8,12 @@
|
||||
|
||||
<%block name="jsextra">
|
||||
<script src="${static.url('js/vendor/mustache.js')}"></script>
|
||||
<script type="text/javascript" src="${static.url('js/views/assets.js')}"></script>
|
||||
|
||||
<script type='text/javascript'>
|
||||
// we just want a singleton
|
||||
confirm_delete_prompt = null;
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
@@ -30,6 +37,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 +66,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 +74,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 +97,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 +143,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import logging
|
||||
from xmodule.util.date_utils import get_time_struct_display
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
%>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
@@ -36,11 +36,15 @@
|
||||
<div class="datepair" data-language="javascript">
|
||||
<div class="field field-start-date">
|
||||
<label for="start_date">Release Day</label>
|
||||
<input type="text" id="start_date" name="start_date" value="${get_time_struct_display(subsection.lms.start, '%m/%d/%Y')}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="start_date" name="start_date"
|
||||
value="${subsection.lms.start.strftime('%m/%d/%Y') if subsection.lms.start else ''}"
|
||||
placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field field-start-time">
|
||||
<label for="start_time">Release Time (<abbr title="Coordinated Universal Time">UTC</abbr>)</label>
|
||||
<input type="text" id="start_time" name="start_time" value="${get_time_struct_display(subsection.lms.start, '%H:%M')}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="start_time" name="start_time"
|
||||
value="${subsection.lms.start.strftime('%H:%M') if subsection.lms.start else ''}"
|
||||
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
% if subsection.lms.start != parent_item.lms.start and subsection.lms.start:
|
||||
@@ -48,7 +52,7 @@
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
|
||||
% else:
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default} –
|
||||
${get_time_struct_display(parent_item.lms.start, '%m/%d/%Y at %H:%M UTC')}.
|
||||
${get_default_time_display(parent_item.lms.start)}.
|
||||
% endif
|
||||
<a href="#" class="sync-date no-spinner">Sync to ${parent_item.display_name_with_default}.</a></p>
|
||||
% endif
|
||||
@@ -65,11 +69,15 @@
|
||||
<div class="datepair date-setter">
|
||||
<div class="field field-start-date">
|
||||
<label for="due_date">Due Day</label>
|
||||
<input type="text" id="due_date" name="due_date" value="${get_time_struct_display(subsection.lms.due, '%m/%d/%Y')}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="due_date" name="due_date"
|
||||
value="${subsection.lms.due.strftime('%m/%d/%Y') if subsection.lms.due else ''}"
|
||||
placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field field-start-time">
|
||||
<label for="due_time">Due Time (<abbr title="Coordinated Universal Time">UTC</abbr>)</label>
|
||||
<input type="text" id="due_time" name="due_time" value="${get_time_struct_display(subsection.lms.due, '%H:%M')}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="due_time" name="due_time"
|
||||
value="${subsection.lms.due.strftime('%H:%M') if subsection.lms.due else ''}"
|
||||
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</div>
|
||||
<a href="#" class="remove-date">Remove due date</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import logging
|
||||
from xmodule.util.date_utils import get_time_struct_display
|
||||
from xmodule.util import date_utils
|
||||
%>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Course Outline</%block>
|
||||
@@ -154,14 +154,19 @@
|
||||
<h3 class="section-name" data-name="${section.display_name_with_default | h}"></h3>
|
||||
<div class="section-published-date">
|
||||
<%
|
||||
start_date_str = get_time_struct_display(section.lms.start, '%m/%d/%Y')
|
||||
start_time_str = get_time_struct_display(section.lms.start, '%H:%M')
|
||||
if section.lms.start is not None:
|
||||
start_date_str = section.lms.start.strftime('%m/%d/%Y')
|
||||
start_time_str = section.lms.start.strftime('%H:%M')
|
||||
else:
|
||||
start_date_str = ''
|
||||
start_time_str = ''
|
||||
%>
|
||||
%if section.lms.start is None:
|
||||
<span class="published-status">This section has not been released.</span>
|
||||
<a href="#" class="schedule-button" data-date="" data-time="" data-id="${section.location}">Schedule</a>
|
||||
%else:
|
||||
<span class="published-status"><strong>Will Release:</strong> ${get_time_struct_display(section.lms.start, '%m/%d/%Y at %H:%M UTC')}</span>
|
||||
<span class="published-status"><strong>Will Release:</strong>
|
||||
${date_utils.get_default_time_display(section.lms.start)}</span>
|
||||
<a href="#" class="edit-button" data-date="${start_date_str}" data-time="${start_time_str}" data-id="${section.location}">Edit</a>
|
||||
%endif
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,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 +73,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>.*)$',
|
||||
|
||||
@@ -5,7 +5,6 @@ Namespace defining common fields used by Studio for all blocks
|
||||
import datetime
|
||||
|
||||
from xblock.core import Namespace, Scope, ModelType, String
|
||||
from xmodule.fields import StringyBoolean
|
||||
|
||||
|
||||
class DateTuple(ModelType):
|
||||
@@ -28,4 +27,3 @@ class CmsNamespace(Namespace):
|
||||
"""
|
||||
published_date = DateTuple(help="Date when the module was published", scope=Scope.settings)
|
||||
published_by = String(help="Id of the user who published this module", scope=Scope.settings)
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseNotModified
|
||||
from django.http import HttpResponse, HttpResponseNotModified
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG
|
||||
@@ -20,7 +17,7 @@ class StaticContentServer(object):
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
return response
|
||||
|
||||
# first look in our cache so we don't have to round-trip to the DB
|
||||
content = get_cached_content(loc)
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from mitxmako.shortcuts import marketing_link
|
||||
from mock import patch
|
||||
from nose.plugins.skip import SkipTest
|
||||
from util.testing import UrlResetMixin
|
||||
|
||||
class ShortcutsTests(TestCase):
|
||||
|
||||
class ShortcutsTests(UrlResetMixin, TestCase):
|
||||
"""
|
||||
Test the mitxmako shortcuts file
|
||||
"""
|
||||
# TODO: fix this test. It is causing intermittent test failures on
|
||||
# subsequent tests due to the way urls are loaded
|
||||
raise SkipTest()
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'})
|
||||
@override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'})
|
||||
def test_marketing_link(self):
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
import datetime
|
||||
|
||||
import json
|
||||
from pytz import UTC
|
||||
|
||||
middleware.MakoMiddleware()
|
||||
|
||||
@@ -32,7 +33,7 @@ def group_from_value(groups, v):
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
help = \
|
||||
''' Assign users to test groups. Takes a list
|
||||
of groups:
|
||||
a:0.3,b:0.4,c:0.3 file.txt "Testing something"
|
||||
@@ -75,7 +76,7 @@ Will log what happened to file.txt.
|
||||
utg = UserTestGroup()
|
||||
utg.name = group
|
||||
utg.description = json.dumps({"description": args[2]},
|
||||
{"time": datetime.datetime.utcnow().isoformat()})
|
||||
{"time": datetime.datetime.now(UTC).isoformat()})
|
||||
group_objects[group] = utg
|
||||
group_objects[group].save()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -58,7 +59,7 @@ class Command(BaseCommand):
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.utcnow()
|
||||
uploaded_at = datetime.now(UTC)
|
||||
|
||||
# if specified destination is an existing directory, then
|
||||
# create a filename for it automatically. If it doesn't exist,
|
||||
@@ -100,7 +101,7 @@ class Command(BaseCommand):
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcu in TestCenterUser.objects.order_by('id'):
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
|
||||
for csv_field, model_field
|
||||
in Command.CSV_TO_MODEL_FIELDS.items())
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -51,7 +52,7 @@ class Command(BaseCommand):
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.utcnow()
|
||||
uploaded_at = datetime.now(UTC)
|
||||
|
||||
# if specified destination is an existing directory, then
|
||||
# create a filename for it automatically. If it doesn't exist,
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
|
||||
from student.models import TestCenterUser, TestCenterRegistration
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -68,7 +69,7 @@ class Command(BaseCommand):
|
||||
Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name)
|
||||
# now update the record:
|
||||
registration.upload_status = row['Status']
|
||||
registration.upload_error_message = row['Message']
|
||||
registration.upload_error_message = row['Message']
|
||||
try:
|
||||
registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S'))
|
||||
except ValueError as ve:
|
||||
@@ -80,7 +81,7 @@ class Command(BaseCommand):
|
||||
except ValueError as ve:
|
||||
Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name)
|
||||
|
||||
registration.confirmed_at = datetime.utcnow()
|
||||
registration.confirmed_at = datetime.now(UTC)
|
||||
registration.save()
|
||||
except TestCenterRegistration.DoesNotExist:
|
||||
Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from optparse import make_option
|
||||
from time import strftime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
@@ -128,8 +127,8 @@ class Command(BaseCommand):
|
||||
exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info)
|
||||
# update option values for date_first and date_last to use YYYY-MM-DD format
|
||||
# instead of YYYY-MM-DDTHH:MM
|
||||
our_options['eligibility_appointment_date_first'] = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
|
||||
our_options['eligibility_appointment_date_last'] = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
|
||||
our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
|
||||
if exam is None:
|
||||
raise CommandError("Exam for course_id {} does not exist".format(course_id))
|
||||
|
||||
@@ -16,7 +16,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from random import randint
|
||||
from time import strftime
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
@@ -27,6 +26,7 @@ from django.dispatch import receiver
|
||||
from django.forms import ModelForm, forms
|
||||
|
||||
import comment_client as cc
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -54,7 +54,7 @@ class UserProfile(models.Model):
|
||||
class Meta:
|
||||
db_table = "auth_userprofile"
|
||||
|
||||
## CRITICAL TODO/SECURITY
|
||||
# CRITICAL TODO/SECURITY
|
||||
# Sanitize all fields.
|
||||
# This is not visible to other users, but could introduce holes later
|
||||
user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile')
|
||||
@@ -254,7 +254,7 @@ class TestCenterUserForm(ModelForm):
|
||||
def update_and_save(self):
|
||||
new_user = self.save(commit=False)
|
||||
# create additional values here:
|
||||
new_user.user_updated_at = datetime.utcnow()
|
||||
new_user.user_updated_at = datetime.now(UTC)
|
||||
new_user.upload_status = ''
|
||||
new_user.save()
|
||||
log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username))
|
||||
@@ -429,8 +429,8 @@ class TestCenterRegistration(models.Model):
|
||||
registration.course_id = exam.course_id
|
||||
registration.accommodation_request = accommodation_request.strip()
|
||||
registration.exam_series_code = exam.exam_series_code
|
||||
registration.eligibility_appointment_date_first = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
|
||||
registration.eligibility_appointment_date_last = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
|
||||
registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.client_authorization_id = cls._create_client_authorization_id()
|
||||
# accommodation_code remains blank for now, along with Pearson confirmation information
|
||||
return registration
|
||||
@@ -556,7 +556,7 @@ class TestCenterRegistrationForm(ModelForm):
|
||||
def update_and_save(self):
|
||||
registration = self.save(commit=False)
|
||||
# create additional values here:
|
||||
registration.user_updated_at = datetime.utcnow()
|
||||
registration.user_updated_at = datetime.now(UTC)
|
||||
registration.upload_status = ''
|
||||
registration.save()
|
||||
log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code))
|
||||
@@ -598,7 +598,7 @@ def unique_id_for_user(user):
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
## TODO: Should be renamed to generic UserGroup, and possibly
|
||||
# TODO: Should be renamed to generic UserGroup, and possibly
|
||||
# Given an optional field for type of group
|
||||
class UserTestGroup(models.Model):
|
||||
users = models.ManyToManyField(User, db_index=True)
|
||||
@@ -626,7 +626,6 @@ class Registration(models.Model):
|
||||
def activate(self):
|
||||
self.user.is_active = True
|
||||
self.user.save()
|
||||
#self.delete()
|
||||
|
||||
|
||||
class PendingNameChange(models.Model):
|
||||
@@ -648,7 +647,7 @@ class CourseEnrollment(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('user', 'course_id'), )
|
||||
unique_together = (('user', 'course_id'),)
|
||||
|
||||
def __unicode__(self):
|
||||
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
|
||||
@@ -667,12 +666,12 @@ class CourseEnrollmentAllowed(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('email', 'course_id'), )
|
||||
unique_together = (('email', 'course_id'),)
|
||||
|
||||
def __unicode__(self):
|
||||
return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created)
|
||||
|
||||
#cache_relation(User.profile)
|
||||
# cache_relation(User.profile)
|
||||
|
||||
#### Helper methods for use from python manage.py shell and other classes.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.contrib.auth.models import Group
|
||||
from datetime import datetime
|
||||
from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence
|
||||
from uuid import uuid4
|
||||
from pytz import UTC
|
||||
|
||||
# Factories don't have __init__ methods, and are self documenting
|
||||
# pylint: disable=W0232
|
||||
@@ -46,8 +47,8 @@ class UserFactory(DjangoModelFactory):
|
||||
is_staff = False
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
last_login = datetime(2012, 1, 1)
|
||||
date_joined = datetime(2011, 1, 1)
|
||||
last_login = datetime(2012, 1, 1, tzinfo=UTC)
|
||||
date_joined = datetime(2011, 1, 1, tzinfo=UTC)
|
||||
|
||||
@post_generation
|
||||
def profile(obj, create, extracted, **kwargs):
|
||||
|
||||
@@ -49,6 +49,7 @@ from courseware.views import get_module_for_descriptor, jump_to
|
||||
from courseware.model_data import ModelDataCache
|
||||
|
||||
from statsd import statsd
|
||||
from pytz import UTC
|
||||
|
||||
log = logging.getLogger("mitx.student")
|
||||
Article = namedtuple('Article', 'title url author image deck publication publish_date')
|
||||
@@ -77,7 +78,7 @@ def index(request, extra_context={}, user=None):
|
||||
'''
|
||||
|
||||
# The course selection work is done in courseware.courses.
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
# do explicit check, because domain=None is valid
|
||||
if domain == False:
|
||||
domain = request.META.get('HTTP_HOST')
|
||||
@@ -630,7 +631,7 @@ def create_account(request, post_override=None):
|
||||
|
||||
# Ok, looks like everything is legit. Create the account.
|
||||
ret = _do_create_account(post_vars)
|
||||
if isinstance(ret, HttpResponse): # if there was an error then return that
|
||||
if isinstance(ret, HttpResponse): # if there was an error then return that
|
||||
return ret
|
||||
(user, profile, registration) = ret
|
||||
|
||||
@@ -668,7 +669,7 @@ def create_account(request, post_override=None):
|
||||
|
||||
if DoExternalAuth:
|
||||
eamap.user = login_user
|
||||
eamap.dtsignup = datetime.datetime.now()
|
||||
eamap.dtsignup = datetime.datetime.now(UTC)
|
||||
eamap.save()
|
||||
log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -57,20 +58,28 @@ def css_find(css, wait_time=5):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click(css_selector):
|
||||
def css_click(css_selector, index=0, attempts=5):
|
||||
"""
|
||||
Perform a click on a CSS selector, retrying if it initially fails
|
||||
This function will return if the click worked (since it is try/excepting all errors)
|
||||
"""
|
||||
assert is_css_present(css_selector)
|
||||
try:
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
|
||||
except WebDriverException:
|
||||
# Occassionally, MathJax or other JavaScript can cover up
|
||||
# an element temporarily.
|
||||
# If this happens, wait a second, then try again
|
||||
world.wait(1)
|
||||
world.browser.find_by_css(css_selector).click()
|
||||
attempt = 0
|
||||
result = False
|
||||
while attempt < attempts:
|
||||
try:
|
||||
world.css_find(css_selector)[index].click()
|
||||
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
|
||||
@@ -158,3 +167,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 ''
|
||||
|
||||
49
common/djangoapps/tests.py
Normal file
49
common/djangoapps/tests.py
Normal file
@@ -0,0 +1,49 @@
|
||||
'''
|
||||
Created on Jun 6, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from student.tests.factories import AdminFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
import xmodule_modifiers
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
from xmodule.modulestore.tests import factories
|
||||
|
||||
class TestXmoduleModfiers(ModuleStoreTestCase):
|
||||
|
||||
# FIXME disabled b/c start date inheritance is not occuring and render_... in get_html is failing due
|
||||
# to middleware.lookup['main'] not being defined
|
||||
def _test_add_histogram(self):
|
||||
instructor = AdminFactory.create()
|
||||
self.client.login(username=instructor.username, password='test')
|
||||
|
||||
course = CourseFactory.create(org='test',
|
||||
number='313', display_name='histogram test')
|
||||
section = ItemFactory.create(
|
||||
parent_location=course.location, display_name='chapter hist',
|
||||
template='i4x://edx/templates/chapter/Empty')
|
||||
problem = ItemFactory.create(
|
||||
parent_location=section.location, display_name='problem hist 1',
|
||||
template='i4x://edx/templates/problem/Blank_Common_Problem')
|
||||
problem.has_score = False # don't trip trying to retrieve db data
|
||||
|
||||
late_problem = ItemFactory.create(
|
||||
parent_location=section.location, display_name='problem hist 2',
|
||||
template='i4x://edx/templates/problem/Blank_Common_Problem')
|
||||
late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
|
||||
late_problem.has_score = False
|
||||
|
||||
|
||||
problem_module = factories.get_test_xmodule_for_descriptor(problem)
|
||||
problem_module.get_html = xmodule_modifiers.add_histogram(lambda:'', problem_module, instructor)
|
||||
|
||||
self.assertRegexpMatches(
|
||||
problem_module.get_html(), r'.*<font color=\'green\'>Not yet</font>.*')
|
||||
|
||||
problem_module = factories.get_test_xmodule_for_descriptor(late_problem)
|
||||
problem_module.get_html = xmodule_modifiers.add_histogram(lambda: '', problem_module, instructor)
|
||||
|
||||
self.assertRegexpMatches(
|
||||
problem_module.get_html(), r'.*<font color=\'red\'>Yes!</font>.*')
|
||||
@@ -14,6 +14,7 @@ from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from track.models import TrackingLog
|
||||
from pytz import UTC
|
||||
|
||||
log = logging.getLogger("tracking")
|
||||
|
||||
@@ -59,7 +60,7 @@ def user_track(request):
|
||||
"event": request.GET['event'],
|
||||
"agent": agent,
|
||||
"page": request.GET['page'],
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"time": datetime.datetime.now(UTC).isoformat(),
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
log_event(event)
|
||||
@@ -85,11 +86,11 @@ def server_track(request, event_type, event, page=None):
|
||||
"event": event,
|
||||
"agent": agent,
|
||||
"page": page,
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"time": datetime.datetime.now(UTC).isoformat(),
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
return
|
||||
log_event(event)
|
||||
|
||||
|
||||
34
common/djangoapps/util/testing.py
Normal file
34
common/djangoapps/util/testing.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import clear_url_caches
|
||||
|
||||
|
||||
class UrlResetMixin(object):
|
||||
"""Mixin to reset urls.py before and after a test
|
||||
|
||||
Django memoizes the function that reads the urls module (whatever module
|
||||
urlconf names). The module itself is also stored by python in sys.modules.
|
||||
To fully reload it, we need to reload the python module, and also clear django's
|
||||
cache of the parsed urls.
|
||||
|
||||
However, the order in which we do this doesn't matter, because neither one will
|
||||
get reloaded until the next request
|
||||
|
||||
Doing this is expensive, so it should only be added to tests that modify settings
|
||||
that affect the contents of urls.py
|
||||
"""
|
||||
|
||||
def _reset_urls(self, urlconf=None):
|
||||
if urlconf is None:
|
||||
urlconf = settings.ROOT_URLCONF
|
||||
|
||||
if urlconf in sys.modules:
|
||||
reload(sys.modules[urlconf])
|
||||
clear_url_caches()
|
||||
|
||||
def setUp(self):
|
||||
"""Reset django default urlconf before tests and after tests"""
|
||||
super(UrlResetMixin, self).setUp()
|
||||
self._reset_urls()
|
||||
self.addCleanup(self._reset_urls)
|
||||
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import static_replace
|
||||
|
||||
from django.conf import settings
|
||||
@@ -9,6 +8,8 @@ from functools import wraps
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from xmodule.seq_module import SequenceModule
|
||||
from xmodule.vertical_module import VerticalModule
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
log = logging.getLogger("mitx.xmodule_modifiers")
|
||||
|
||||
@@ -83,7 +84,7 @@ def grade_histogram(module_id):
|
||||
cursor.execute(q, [module_id])
|
||||
|
||||
grades = list(cursor.fetchall())
|
||||
grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query?
|
||||
grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query?
|
||||
if len(grades) >= 1 and grades[0][0] is None:
|
||||
return []
|
||||
return grades
|
||||
@@ -101,7 +102,7 @@ def add_histogram(get_html, module, user):
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
|
||||
if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead
|
||||
if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead
|
||||
return get_html()
|
||||
|
||||
module_id = module.id
|
||||
@@ -132,7 +133,7 @@ def add_histogram(get_html, module, user):
|
||||
|
||||
# useful to indicate to staff if problem has been released or not
|
||||
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
|
||||
now = time.gmtime()
|
||||
now = datetime.datetime.now(UTC())
|
||||
is_released = "unknown"
|
||||
mstart = module.descriptor.lms.start
|
||||
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
"""
|
||||
Parser and evaluator for FormulaResponse and NumericalResponse
|
||||
|
||||
Uses pyparsing to parse. Main function as of now is evaluator().
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import operator
|
||||
import re
|
||||
|
||||
import numpy
|
||||
import numbers
|
||||
import scipy.constants
|
||||
import calcfunctions
|
||||
|
||||
from pyparsing import Word, alphas, nums, oneOf, Literal
|
||||
from pyparsing import ZeroOrMore, OneOrMore, StringStart
|
||||
from pyparsing import StringEnd, Optional, Forward
|
||||
from pyparsing import CaselessLiteral, Group, StringEnd
|
||||
from pyparsing import NoMatch, stringEnd, alphanums
|
||||
# have numpy raise errors on functions outside its domain
|
||||
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
|
||||
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
|
||||
|
||||
default_functions = {'sin': numpy.sin,
|
||||
from pyparsing import (Word, nums, Literal,
|
||||
ZeroOrMore, MatchFirst,
|
||||
Optional, Forward,
|
||||
CaselessLiteral,
|
||||
stringEnd, Suppress, Combine)
|
||||
|
||||
DEFAULT_FUNCTIONS = {'sin': numpy.sin,
|
||||
'cos': numpy.cos,
|
||||
'tan': numpy.tan,
|
||||
'sec': calcfunctions.sec,
|
||||
'csc': calcfunctions.csc,
|
||||
'cot': calcfunctions.cot,
|
||||
'sqrt': numpy.sqrt,
|
||||
'log10': numpy.log10,
|
||||
'log2': numpy.log2,
|
||||
'ln': numpy.log,
|
||||
'exp': numpy.exp,
|
||||
'arccos': numpy.arccos,
|
||||
'arcsin': numpy.arcsin,
|
||||
'arctan': numpy.arctan,
|
||||
'arcsec': calcfunctions.arcsec,
|
||||
'arccsc': calcfunctions.arccsc,
|
||||
'arccot': calcfunctions.arccot,
|
||||
'abs': numpy.abs,
|
||||
'fact': math.factorial,
|
||||
'factorial': math.factorial
|
||||
'factorial': math.factorial,
|
||||
'sinh': numpy.sinh,
|
||||
'cosh': numpy.cosh,
|
||||
'tanh': numpy.tanh,
|
||||
'sech': calcfunctions.sech,
|
||||
'csch': calcfunctions.csch,
|
||||
'coth': calcfunctions.coth,
|
||||
'arcsinh': numpy.arcsinh,
|
||||
'arccosh': numpy.arccosh,
|
||||
'arctanh': numpy.arctanh,
|
||||
'arcsech': calcfunctions.arcsech,
|
||||
'arccsch': calcfunctions.arccsch,
|
||||
'arccoth': calcfunctions.arccoth
|
||||
}
|
||||
default_variables = {'j': numpy.complex(0, 1),
|
||||
DEFAULT_VARIABLES = {'i': numpy.complex(0, 1),
|
||||
'j': numpy.complex(0, 1),
|
||||
'e': numpy.e,
|
||||
'pi': numpy.pi,
|
||||
'k': scipy.constants.k,
|
||||
@@ -37,65 +66,166 @@ default_variables = {'j': numpy.complex(0, 1),
|
||||
'q': scipy.constants.e
|
||||
}
|
||||
|
||||
log = logging.getLogger("mitx.courseware.capa")
|
||||
# We eliminated the following extreme suffixes:
|
||||
# P (1e15), E (1e18), Z (1e21), Y (1e24),
|
||||
# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
|
||||
# since they're rarely used, and potentially
|
||||
# confusing. They may also conflict with variables if we ever allow e.g.
|
||||
# 5R instead of 5*R
|
||||
SUFFIXES = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
|
||||
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12}
|
||||
|
||||
|
||||
class UndefinedVariable(Exception):
|
||||
def raiseself(self):
|
||||
''' Helper so we can use inside of a lambda '''
|
||||
raise self
|
||||
|
||||
|
||||
general_whitespace = re.compile('[^\w]+')
|
||||
"""
|
||||
Used to indicate the student input of a variable, which was unused by the
|
||||
instructor.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def check_variables(string, variables):
|
||||
'''Confirm the only variables in string are defined.
|
||||
"""
|
||||
Confirm the only variables in string are defined.
|
||||
|
||||
Pyparsing uses a left-to-right parser, which makes the more
|
||||
Otherwise, raise an UndefinedVariable containing all bad variables.
|
||||
|
||||
Pyparsing uses a left-to-right parser, which makes a more
|
||||
elegant approach pretty hopeless.
|
||||
|
||||
achar = reduce(lambda a,b:a|b ,map(Literal,alphas)) # Any alphabetic character
|
||||
undefined_variable = achar + Word(alphanums)
|
||||
undefined_variable.setParseAction(lambda x:UndefinedVariable("".join(x)).raiseself())
|
||||
varnames = varnames | undefined_variable
|
||||
'''
|
||||
possible_variables = re.split(general_whitespace, string) # List of all alnums in string
|
||||
bad_variables = list()
|
||||
for v in possible_variables:
|
||||
if len(v) == 0:
|
||||
"""
|
||||
general_whitespace = re.compile('[^\\w]+')
|
||||
# List of all alnums in string
|
||||
possible_variables = re.split(general_whitespace, string)
|
||||
bad_variables = []
|
||||
for var in possible_variables:
|
||||
if len(var) == 0:
|
||||
continue
|
||||
if v[0] <= '9' and '0' <= 'v': # Skip things that begin with numbers
|
||||
if var[0].isdigit(): # Skip things that begin with numbers
|
||||
continue
|
||||
if v not in variables:
|
||||
bad_variables.append(v)
|
||||
if var not in variables:
|
||||
bad_variables.append(var)
|
||||
if len(bad_variables) > 0:
|
||||
raise UndefinedVariable(' '.join(bad_variables))
|
||||
|
||||
|
||||
def lower_dict(input_dict):
|
||||
"""
|
||||
takes each key in the dict and makes it lowercase, still mapping to the
|
||||
same value.
|
||||
|
||||
keep in mind that it is possible (but not useful?) to define different
|
||||
variables that have the same lowercase representation. It would be hard to
|
||||
tell which is used in the final dict and which isn't.
|
||||
"""
|
||||
return {k.lower(): v for k, v in input_dict.iteritems()}
|
||||
|
||||
|
||||
# The following few functions define parse actions, which are run on lists of
|
||||
# results from each parse component. They convert the strings and (previously
|
||||
# calculated) numbers into the number that component represents.
|
||||
|
||||
def super_float(text):
|
||||
"""
|
||||
Like float, but with si extensions. 1k goes to 1000
|
||||
"""
|
||||
if text[-1] in SUFFIXES:
|
||||
return float(text[:-1]) * SUFFIXES[text[-1]]
|
||||
else:
|
||||
return float(text)
|
||||
|
||||
|
||||
def number_parse_action(parse_result):
|
||||
"""
|
||||
Create a float out of its string parts
|
||||
|
||||
e.g. [ '7', '.', '13' ] -> [ 7.13 ]
|
||||
Calls super_float above
|
||||
"""
|
||||
return super_float("".join(parse_result))
|
||||
|
||||
|
||||
def exp_parse_action(parse_result):
|
||||
"""
|
||||
Take a list of numbers and exponentiate them, right to left
|
||||
|
||||
e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561
|
||||
"""
|
||||
# pyparsing.ParseResults doesn't play well with reverse()
|
||||
parse_result = reversed(parse_result)
|
||||
# the result of an exponentiation is called a power
|
||||
power = reduce(lambda a, b: b ** a, parse_result)
|
||||
return power
|
||||
|
||||
|
||||
def parallel(parse_result):
|
||||
"""
|
||||
Compute numbers according to the parallel resistors operator
|
||||
|
||||
BTW it is commutative. Its formula is given by
|
||||
out = 1 / (1/in1 + 1/in2 + ...)
|
||||
e.g. [ 1, 2 ] => 2/3
|
||||
|
||||
Return NaN if there is a zero among the inputs
|
||||
"""
|
||||
# convert from pyparsing.ParseResults, which doesn't support '0 in parse_result'
|
||||
parse_result = parse_result.asList()
|
||||
if len(parse_result) == 1:
|
||||
return parse_result[0]
|
||||
if 0 in parse_result:
|
||||
return float('nan')
|
||||
reciprocals = [1. / e for e in parse_result]
|
||||
return 1. / sum(reciprocals)
|
||||
|
||||
|
||||
def sum_parse_action(parse_result):
|
||||
"""
|
||||
Add the inputs
|
||||
|
||||
[ 1, '+', 2, '-', 3 ] -> 0
|
||||
|
||||
Allow a leading + or -
|
||||
"""
|
||||
total = 0.0
|
||||
current_op = operator.add
|
||||
for token in parse_result:
|
||||
if token is '+':
|
||||
current_op = operator.add
|
||||
elif token is '-':
|
||||
current_op = operator.sub
|
||||
else:
|
||||
total = current_op(total, token)
|
||||
return total
|
||||
|
||||
|
||||
def prod_parse_action(parse_result):
|
||||
"""
|
||||
Multiply the inputs
|
||||
|
||||
[ 1, '*', 2, '/', 3 ] => 0.66
|
||||
"""
|
||||
prod = 1.0
|
||||
current_op = operator.mul
|
||||
for token in parse_result:
|
||||
if token is '*':
|
||||
current_op = operator.mul
|
||||
elif token is '/':
|
||||
current_op = operator.truediv
|
||||
else:
|
||||
prod = current_op(prod, token)
|
||||
return prod
|
||||
|
||||
|
||||
def evaluator(variables, functions, string, cs=False):
|
||||
'''
|
||||
"""
|
||||
Evaluate an expression. Variables are passed as a dictionary
|
||||
from string to value. Unary functions are passed as a dictionary
|
||||
from string to function. Variables must be floats.
|
||||
cs: Case sensitive
|
||||
|
||||
TODO: Fix it so we can pass integers and complex numbers in variables dict
|
||||
'''
|
||||
# log.debug("variables: {0}".format(variables))
|
||||
# log.debug("functions: {0}".format(functions))
|
||||
# log.debug("string: {0}".format(string))
|
||||
|
||||
def lower_dict(d):
|
||||
return dict([(k.lower(), d[k]) for k in d])
|
||||
|
||||
all_variables = copy.copy(default_variables)
|
||||
all_functions = copy.copy(default_functions)
|
||||
|
||||
if not cs:
|
||||
all_variables = lower_dict(all_variables)
|
||||
all_functions = lower_dict(all_functions)
|
||||
"""
|
||||
|
||||
all_variables = copy.copy(DEFAULT_VARIABLES)
|
||||
all_functions = copy.copy(DEFAULT_FUNCTIONS)
|
||||
all_variables.update(variables)
|
||||
all_functions.update(functions)
|
||||
|
||||
@@ -113,122 +243,59 @@ def evaluator(variables, functions, string, cs=False):
|
||||
if string.strip() == "":
|
||||
return float('nan')
|
||||
|
||||
ops = {"^": operator.pow,
|
||||
"*": operator.mul,
|
||||
"/": operator.truediv,
|
||||
"+": operator.add,
|
||||
"-": operator.sub,
|
||||
}
|
||||
# We eliminated extreme ones, since they're rarely used, and potentially
|
||||
# confusing. They may also conflict with variables if we ever allow e.g.
|
||||
# 5R instead of 5*R
|
||||
suffixes = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9,
|
||||
'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
|
||||
'c': 1e-2, 'm': 1e-3, 'u': 1e-6,
|
||||
'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
|
||||
|
||||
def super_float(text):
|
||||
''' Like float, but with si extensions. 1k goes to 1000'''
|
||||
if text[-1] in suffixes:
|
||||
return float(text[:-1]) * suffixes[text[-1]]
|
||||
else:
|
||||
return float(text)
|
||||
|
||||
def number_parse_action(x): # [ '7' ] -> [ 7 ]
|
||||
return [super_float("".join(x))]
|
||||
|
||||
def exp_parse_action(x): # [ 2 ^ 3 ^ 2 ] -> 512
|
||||
x = [e for e in x if isinstance(e, numbers.Number)] # Ignore ^
|
||||
x.reverse()
|
||||
x = reduce(lambda a, b: b ** a, x)
|
||||
return x
|
||||
|
||||
def parallel(x): # Parallel resistors [ 1 2 ] => 2/3
|
||||
# convert from pyparsing.ParseResults, which doesn't support '0 in x'
|
||||
x = list(x)
|
||||
if len(x) == 1:
|
||||
return x[0]
|
||||
if 0 in x:
|
||||
return float('nan')
|
||||
x = [1. / e for e in x if isinstance(e, numbers.Number)] # Ignore ||
|
||||
return 1. / sum(x)
|
||||
|
||||
def sum_parse_action(x): # [ 1 + 2 - 3 ] -> 0
|
||||
total = 0.0
|
||||
op = ops['+']
|
||||
for e in x:
|
||||
if e in set('+-'):
|
||||
op = ops[e]
|
||||
else:
|
||||
total = op(total, e)
|
||||
return total
|
||||
|
||||
def prod_parse_action(x): # [ 1 * 2 / 3 ] => 0.66
|
||||
prod = 1.0
|
||||
op = ops['*']
|
||||
for e in x:
|
||||
if e in set('*/'):
|
||||
op = ops[e]
|
||||
else:
|
||||
prod = op(prod, e)
|
||||
return prod
|
||||
|
||||
def func_parse_action(x):
|
||||
return [all_functions[x[0]](x[1])]
|
||||
|
||||
# SI suffixes and percent
|
||||
number_suffix = reduce(lambda a, b: a | b, map(Literal, suffixes.keys()), NoMatch())
|
||||
(dot, minus, plus, times, div, lpar, rpar, exp) = map(Literal, ".-+*/()^")
|
||||
number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()])
|
||||
plus_minus = Literal('+') | Literal('-')
|
||||
times_div = Literal('*') | Literal('/')
|
||||
|
||||
number_part = Word(nums)
|
||||
|
||||
# 0.33 or 7 or .34 or 16.
|
||||
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
|
||||
# by default pyparsing allows spaces between tokens--Combine prevents that
|
||||
inner_number = Combine(inner_number)
|
||||
|
||||
# 0.33k or -17
|
||||
number = (Optional(minus | plus) + inner_number
|
||||
+ Optional(CaselessLiteral("E") + Optional((plus | minus)) + number_part)
|
||||
number = (inner_number
|
||||
+ Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part)
|
||||
+ Optional(number_suffix))
|
||||
number = number.setParseAction(number_parse_action) # Convert to number
|
||||
number.setParseAction(number_parse_action) # Convert to number
|
||||
|
||||
# Predefine recursive variables
|
||||
expr = Forward()
|
||||
factor = Forward()
|
||||
|
||||
def sreduce(f, l):
|
||||
''' Same as reduce, but handle len 1 and len 0 lists sensibly '''
|
||||
if len(l) == 0:
|
||||
return NoMatch()
|
||||
if len(l) == 1:
|
||||
return l[0]
|
||||
return reduce(f, l)
|
||||
# Handle variables passed in.
|
||||
# E.g. if we have {'R':0.5}, we make the substitution.
|
||||
# We sort the list so that var names (like "e2") match before
|
||||
# mathematical constants (like "e"). This is kind of a hack.
|
||||
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
|
||||
varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys])
|
||||
varnames.setParseAction(
|
||||
lambda x: [all_variables[k] for k in x]
|
||||
)
|
||||
|
||||
# Handle variables passed in. E.g. if we have {'R':0.5}, we make the substitution.
|
||||
# Special case for no variables because of how we understand PyParsing is put together
|
||||
if len(all_variables) > 0:
|
||||
# We sort the list so that var names (like "e2") match before
|
||||
# mathematical constants (like "e"). This is kind of a hack.
|
||||
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
|
||||
varnames = sreduce(lambda x, y: x | y, map(lambda x: CasedLiteral(x), all_variables_keys))
|
||||
varnames.setParseAction(lambda x: map(lambda y: all_variables[y], x))
|
||||
else:
|
||||
varnames = NoMatch()
|
||||
# if all_variables were empty, then pyparsing wants
|
||||
# varnames = NoMatch()
|
||||
# this is not the case, as all_variables contains the defaults
|
||||
|
||||
# Same thing for functions.
|
||||
if len(all_functions) > 0:
|
||||
funcnames = sreduce(lambda x, y: x | y,
|
||||
map(lambda x: CasedLiteral(x), all_functions.keys()))
|
||||
function = funcnames + lpar.suppress() + expr + rpar.suppress()
|
||||
function.setParseAction(func_parse_action)
|
||||
else:
|
||||
function = NoMatch()
|
||||
all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True)
|
||||
funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys])
|
||||
function = funcnames + Suppress("(") + expr + Suppress(")")
|
||||
function.setParseAction(
|
||||
lambda x: [all_functions[x[0]](x[1])]
|
||||
)
|
||||
|
||||
atom = number | function | varnames | lpar + expr + rpar
|
||||
factor << (atom + ZeroOrMore(exp + atom)).setParseAction(exp_parse_action) # 7^6
|
||||
paritem = factor + ZeroOrMore(Literal('||') + factor) # 5k || 4k
|
||||
paritem = paritem.setParseAction(parallel)
|
||||
term = paritem + ZeroOrMore((times | div) + paritem) # 7 * 5 / 4 - 3
|
||||
term = term.setParseAction(prod_parse_action)
|
||||
expr << Optional((plus | minus)) + term + ZeroOrMore((plus | minus) + term) # -5 + 4 - 3
|
||||
expr = expr.setParseAction(sum_parse_action)
|
||||
atom = number | function | varnames | Suppress("(") + expr + Suppress(")")
|
||||
|
||||
# Do the following in the correct order to preserve order of operation
|
||||
pow_term = atom + ZeroOrMore(Suppress("^") + atom)
|
||||
pow_term.setParseAction(exp_parse_action) # 7^6
|
||||
par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k
|
||||
par_term.setParseAction(parallel)
|
||||
prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3
|
||||
prod_term.setParseAction(prod_parse_action)
|
||||
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
|
||||
sum_term.setParseAction(sum_parse_action)
|
||||
expr << sum_term # finish the recursion
|
||||
return (expr + stringEnd).parseString(string)[0]
|
||||
|
||||
99
common/lib/calc/calcfunctions.py
Normal file
99
common/lib/calc/calcfunctions.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Provide the mathematical functions that numpy doesn't.
|
||||
|
||||
Specifically, the secant/cosecant/cotangents and their inverses and
|
||||
hyperbolic counterparts
|
||||
"""
|
||||
import numpy
|
||||
|
||||
|
||||
# Normal Trig
|
||||
def sec(arg):
|
||||
"""
|
||||
Secant
|
||||
"""
|
||||
return 1 / numpy.cos(arg)
|
||||
|
||||
|
||||
def csc(arg):
|
||||
"""
|
||||
Cosecant
|
||||
"""
|
||||
return 1 / numpy.sin(arg)
|
||||
|
||||
|
||||
def cot(arg):
|
||||
"""
|
||||
Cotangent
|
||||
"""
|
||||
return 1 / numpy.tan(arg)
|
||||
|
||||
|
||||
# Inverse Trig
|
||||
# http://en.wikipedia.org/wiki/Inverse_trigonometric_functions#Relationships_among_the_inverse_trigonometric_functions
|
||||
def arcsec(val):
|
||||
"""
|
||||
Inverse secant
|
||||
"""
|
||||
return numpy.arccos(1. / val)
|
||||
|
||||
|
||||
def arccsc(val):
|
||||
"""
|
||||
Inverse cosecant
|
||||
"""
|
||||
return numpy.arcsin(1. / val)
|
||||
|
||||
|
||||
def arccot(val):
|
||||
"""
|
||||
Inverse cotangent
|
||||
"""
|
||||
if numpy.real(val) < 0:
|
||||
return -numpy.pi / 2 - numpy.arctan(val)
|
||||
else:
|
||||
return numpy.pi / 2 - numpy.arctan(val)
|
||||
|
||||
|
||||
# Hyperbolic Trig
|
||||
def sech(arg):
|
||||
"""
|
||||
Hyperbolic secant
|
||||
"""
|
||||
return 1 / numpy.cosh(arg)
|
||||
|
||||
|
||||
def csch(arg):
|
||||
"""
|
||||
Hyperbolic cosecant
|
||||
"""
|
||||
return 1 / numpy.sinh(arg)
|
||||
|
||||
|
||||
def coth(arg):
|
||||
"""
|
||||
Hyperbolic cotangent
|
||||
"""
|
||||
return 1 / numpy.tanh(arg)
|
||||
|
||||
|
||||
# And their inverses
|
||||
def arcsech(val):
|
||||
"""
|
||||
Inverse hyperbolic secant
|
||||
"""
|
||||
return numpy.arccosh(1. / val)
|
||||
|
||||
|
||||
def arccsch(val):
|
||||
"""
|
||||
Inverse hyperbolic cosecant
|
||||
"""
|
||||
return numpy.arcsinh(1. / val)
|
||||
|
||||
|
||||
def arccoth(val):
|
||||
"""
|
||||
Inverse hyperbolic cotangent
|
||||
"""
|
||||
return numpy.arctanh(1. / val)
|
||||
@@ -194,6 +194,105 @@ class EvaluatorTest(unittest.TestCase):
|
||||
arctan_angles = arcsin_angles
|
||||
self.assert_function_values('arctan', arctan_inputs, arctan_angles)
|
||||
|
||||
def test_reciprocal_trig_functions(self):
|
||||
"""
|
||||
Test the reciprocal trig functions provided in calc.py
|
||||
|
||||
which are: sec, csc, cot, arcsec, arccsc, arccot
|
||||
"""
|
||||
angles = ['-pi/4', 'pi/6', 'pi/5', '5*pi/4', '9*pi/4', '1 + j']
|
||||
sec_values = [1.414, 1.155, 1.236, -1.414, 1.414, 0.498 + 0.591j]
|
||||
csc_values = [-1.414, 2, 1.701, -1.414, 1.414, 0.622 - 0.304j]
|
||||
cot_values = [-1, 1.732, 1.376, 1, 1, 0.218 - 0.868j]
|
||||
|
||||
self.assert_function_values('sec', angles, sec_values)
|
||||
self.assert_function_values('csc', angles, csc_values)
|
||||
self.assert_function_values('cot', angles, cot_values)
|
||||
|
||||
arcsec_inputs = ['1.1547', '1.2361', '2', '-2', '-1.4142', '0.4983+0.5911*j']
|
||||
arcsec_angles = [0.524, 0.628, 1.047, 2.094, 2.356, 1 + 1j]
|
||||
self.assert_function_values('arcsec', arcsec_inputs, arcsec_angles)
|
||||
|
||||
arccsc_inputs = ['-1.1547', '-1.4142', '2', '1.7013', '1.1547', '0.6215-0.3039*j']
|
||||
arccsc_angles = [-1.047, -0.785, 0.524, 0.628, 1.047, 1 + 1j]
|
||||
self.assert_function_values('arccsc', arccsc_inputs, arccsc_angles)
|
||||
|
||||
# Has the same range as arccsc
|
||||
arccot_inputs = ['-0.5774', '-1', '1.7321', '1.3764', '0.5774', '(0.2176-0.868*j)']
|
||||
arccot_angles = arccsc_angles
|
||||
self.assert_function_values('arccot', arccot_inputs, arccot_angles)
|
||||
|
||||
def test_hyperbolic_functions(self):
|
||||
"""
|
||||
Test the hyperbolic functions
|
||||
|
||||
which are: sinh, cosh, tanh, sech, csch, coth
|
||||
"""
|
||||
inputs = ['0', '0.5', '1', '2', '1+j']
|
||||
neg_inputs = ['0', '-0.5', '-1', '-2', '-1-j']
|
||||
negate = lambda x: [-k for k in x]
|
||||
|
||||
# sinh is odd
|
||||
sinh_vals = [0, 0.521, 1.175, 3.627, 0.635 + 1.298j]
|
||||
self.assert_function_values('sinh', inputs, sinh_vals)
|
||||
self.assert_function_values('sinh', neg_inputs, negate(sinh_vals))
|
||||
|
||||
# cosh is even - do not negate
|
||||
cosh_vals = [1, 1.128, 1.543, 3.762, 0.834 + 0.989j]
|
||||
self.assert_function_values('cosh', inputs, cosh_vals)
|
||||
self.assert_function_values('cosh', neg_inputs, cosh_vals)
|
||||
|
||||
# tanh is odd
|
||||
tanh_vals = [0, 0.462, 0.762, 0.964, 1.084 + 0.272j]
|
||||
self.assert_function_values('tanh', inputs, tanh_vals)
|
||||
self.assert_function_values('tanh', neg_inputs, negate(tanh_vals))
|
||||
|
||||
# sech is even - do not negate
|
||||
sech_vals = [1, 0.887, 0.648, 0.266, 0.498 - 0.591j]
|
||||
self.assert_function_values('sech', inputs, sech_vals)
|
||||
self.assert_function_values('sech', neg_inputs, sech_vals)
|
||||
|
||||
# the following functions do not have 0 in their domain
|
||||
inputs = inputs[1:]
|
||||
neg_inputs = neg_inputs[1:]
|
||||
|
||||
# csch is odd
|
||||
csch_vals = [1.919, 0.851, 0.276, 0.304 - 0.622j]
|
||||
self.assert_function_values('csch', inputs, csch_vals)
|
||||
self.assert_function_values('csch', neg_inputs, negate(csch_vals))
|
||||
|
||||
# coth is odd
|
||||
coth_vals = [2.164, 1.313, 1.037, 0.868 - 0.218j]
|
||||
self.assert_function_values('coth', inputs, coth_vals)
|
||||
self.assert_function_values('coth', neg_inputs, negate(coth_vals))
|
||||
|
||||
def test_hyperbolic_inverses(self):
|
||||
"""
|
||||
Test the inverse hyperbolic functions
|
||||
|
||||
which are of the form arc[X]h
|
||||
"""
|
||||
results = [0, 0.5, 1, 2, 1 + 1j]
|
||||
|
||||
sinh_vals = ['0', '0.5211', '1.1752', '3.6269', '0.635+1.2985*j']
|
||||
self.assert_function_values('arcsinh', sinh_vals, results)
|
||||
|
||||
cosh_vals = ['1', '1.1276', '1.5431', '3.7622', '0.8337+0.9889*j']
|
||||
self.assert_function_values('arccosh', cosh_vals, results)
|
||||
|
||||
tanh_vals = ['0', '0.4621', '0.7616', '0.964', '1.0839+0.2718*j']
|
||||
self.assert_function_values('arctanh', tanh_vals, results)
|
||||
|
||||
sech_vals = ['1.0', '0.8868', '0.6481', '0.2658', '0.4983-0.5911*j']
|
||||
self.assert_function_values('arcsech', sech_vals, results)
|
||||
|
||||
results = results[1:]
|
||||
csch_vals = ['1.919', '0.8509', '0.2757', '0.3039-0.6215*j']
|
||||
self.assert_function_values('arccsch', csch_vals, results)
|
||||
|
||||
coth_vals = ['2.164', '1.313', '1.0373', '0.868-0.2176*j']
|
||||
self.assert_function_values('arccoth', coth_vals, results)
|
||||
|
||||
def test_other_functions(self):
|
||||
"""
|
||||
Test the non-trig functions provided in calc.py
|
||||
|
||||
@@ -470,6 +470,7 @@ class LoncapaProblem(object):
|
||||
python_path=python_path,
|
||||
cache=self.system.cache,
|
||||
slug=self.problem_id,
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
log.exception("Error while execing script code: " + all_code)
|
||||
|
||||
@@ -144,11 +144,11 @@ class InputTypeBase(object):
|
||||
self.tag = xml.tag
|
||||
self.system = system
|
||||
|
||||
## NOTE: ID should only come from one place. If it comes from multiple,
|
||||
## we use state first, XML second (in case the xml changed, but we have
|
||||
## existing state with an old id). Since we don't make this guarantee,
|
||||
## we can swap this around in the future if there's a more logical
|
||||
## order.
|
||||
# NOTE: ID should only come from one place. If it comes from multiple,
|
||||
# we use state first, XML second (in case the xml changed, but we have
|
||||
# existing state with an old id). Since we don't make this guarantee,
|
||||
# we can swap this around in the future if there's a more logical
|
||||
# order.
|
||||
|
||||
self.input_id = state.get('id', xml.get('id'))
|
||||
if self.input_id is None:
|
||||
@@ -769,7 +769,7 @@ class MatlabInput(CodeInput):
|
||||
|
||||
# construct xqueue headers
|
||||
qinterface = self.system.xqueue['interface']
|
||||
qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat)
|
||||
qtime = datetime.utcnow().strftime(xqueue_interface.dateformat)
|
||||
callback_url = self.system.xqueue['construct_callback']('ungraded_response')
|
||||
anonymous_student_id = self.system.anonymous_student_id
|
||||
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
|
||||
|
||||
@@ -288,7 +288,14 @@ class LoncapaResponse(object):
|
||||
}
|
||||
|
||||
try:
|
||||
safe_exec.safe_exec(code, globals_dict, python_path=self.context['python_path'], slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
code,
|
||||
globals_dict,
|
||||
python_path=self.context['python_path'],
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
msg = 'Error %s in evaluating hint function %s' % (err, hintfn)
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
@@ -973,7 +980,14 @@ class CustomResponse(LoncapaResponse):
|
||||
'ans': ans,
|
||||
}
|
||||
globals_dict.update(kwargs)
|
||||
safe_exec.safe_exec(code, globals_dict, python_path=self.context['python_path'], slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
code,
|
||||
globals_dict,
|
||||
python_path=self.context['python_path'],
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
return globals_dict['cfn_return']
|
||||
return check_function
|
||||
|
||||
@@ -1090,7 +1104,14 @@ class CustomResponse(LoncapaResponse):
|
||||
# exec the check function
|
||||
if isinstance(self.code, basestring):
|
||||
try:
|
||||
safe_exec.safe_exec(self.code, self.context, cache=self.system.cache, slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
self.code,
|
||||
self.context,
|
||||
cache=self.system.cache,
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
self._handle_exec_exception(err)
|
||||
|
||||
@@ -1717,6 +1738,7 @@ class FormulaResponse(LoncapaResponse):
|
||||
student_variables = dict()
|
||||
# ranges give numerical ranges for testing
|
||||
for var in ranges:
|
||||
# TODO: allow specified ranges (i.e. integers and complex numbers) for random variables
|
||||
value = random.uniform(*ranges[var])
|
||||
instructor_variables[str(var)] = value
|
||||
student_variables[str(var)] = value
|
||||
@@ -1814,7 +1836,14 @@ class SchematicResponse(LoncapaResponse):
|
||||
]
|
||||
self.context.update({'submission': submission})
|
||||
try:
|
||||
safe_exec.safe_exec(self.code, self.context, cache=self.system.cache, slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
self.code,
|
||||
self.context,
|
||||
cache=self.system.cache,
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
msg = 'Error %s in evaluating SchematicResponse' % err
|
||||
raise ResponseError(msg)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Capa's specialized use of codejail.safe_exec."""
|
||||
|
||||
from codejail.safe_exec import safe_exec as codejail_safe_exec
|
||||
from codejail.safe_exec import not_safe_exec as codejail_not_safe_exec
|
||||
from codejail.safe_exec import json_safe, SafeExecException
|
||||
from . import lazymod
|
||||
from statsd import statsd
|
||||
@@ -71,7 +72,7 @@ def update_hash(hasher, obj):
|
||||
|
||||
|
||||
@statsd.timed('capa.safe_exec.time')
|
||||
def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None):
|
||||
def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None, unsafely=False):
|
||||
"""
|
||||
Execute python code safely.
|
||||
|
||||
@@ -90,6 +91,8 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None
|
||||
`slug` is an arbitrary string, a description that's meaningful to the
|
||||
caller, that will be used in log messages.
|
||||
|
||||
If `unsafely` is true, then the code will actually be executed without sandboxing.
|
||||
|
||||
"""
|
||||
# Check the cache for a previous result.
|
||||
if cache:
|
||||
@@ -111,9 +114,15 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None
|
||||
# Create the complete code we'll run.
|
||||
code_prolog = CODE_PROLOG % random_seed
|
||||
|
||||
# Decide which code executor to use.
|
||||
if unsafely:
|
||||
exec_fn = codejail_not_safe_exec
|
||||
else:
|
||||
exec_fn = codejail_safe_exec
|
||||
|
||||
# Run the code! Results are side effects in globals_dict.
|
||||
try:
|
||||
codejail_safe_exec(
|
||||
exec_fn(
|
||||
code_prolog + LAZY_IMPORTS + code, globals_dict,
|
||||
python_path=python_path, slug=slug,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Test safe_exec.py"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import os.path
|
||||
import random
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from capa.safe_exec import safe_exec, update_hash
|
||||
from codejail.safe_exec import SafeExecException
|
||||
from codejail.jail_code import is_configured
|
||||
|
||||
|
||||
class TestSafeExec(unittest.TestCase):
|
||||
@@ -68,6 +72,24 @@ class TestSafeExec(unittest.TestCase):
|
||||
self.assertIn("ZeroDivisionError", cm.exception.message)
|
||||
|
||||
|
||||
class TestSafeOrNot(unittest.TestCase):
|
||||
def test_cant_do_something_forbidden(self):
|
||||
# Can't test for forbiddenness if CodeJail isn't configured for python.
|
||||
if not is_configured("python"):
|
||||
raise SkipTest
|
||||
|
||||
g = {}
|
||||
with self.assertRaises(SafeExecException) as cm:
|
||||
safe_exec("import os; files = os.listdir('/')", g)
|
||||
self.assertIn("OSError", cm.exception.message)
|
||||
self.assertIn("Permission denied", cm.exception.message)
|
||||
|
||||
def test_can_do_something_forbidden_if_run_unsafely(self):
|
||||
g = {}
|
||||
safe_exec("import os; files = os.listdir('/')", g, unsafely=True)
|
||||
self.assertEqual(g['files'], os.listdir('/'))
|
||||
|
||||
|
||||
class DictCache(object):
|
||||
"""A cache implementation over a simple dict, for testing."""
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="block block-comment">${comment}</div>
|
||||
|
||||
<div class="block">${comment_prompt}</div>
|
||||
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
|
||||
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment" aria-describedby="answer_${id}">${comment_value|h}</textarea>
|
||||
|
||||
<div class="block">${tag_prompt}</div>
|
||||
<ul class="tags">
|
||||
@@ -22,11 +22,11 @@
|
||||
<li>
|
||||
% if has_options_value:
|
||||
% if all([c == 'correct' for c in option['choice'], status]):
|
||||
<span class="tag-status correct" id="status_${id}"></span>
|
||||
<span class="tag-status correct" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Correct</span></span>
|
||||
% elif all([c == 'partially-correct' for c in option['choice'], status]):
|
||||
<span class="tag-status partially-correct" id="status_${id}"></span>
|
||||
<span class="tag-status partially-correct" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Partially Correct</span></span>
|
||||
% elif all([c == 'incorrect' for c in option['choice'], status]):
|
||||
<span class="tag-status incorrect" id="status_${id}"></span>
|
||||
<span class="tag-status incorrect" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Incorrect</span></span>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
% endif
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Unanswered</span></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Incorrect</span></span>
|
||||
% elif status == 'incorrect' and not has_options_value:
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Incorrect</span></span>
|
||||
% endif
|
||||
|
||||
<p id="answer_${id}" class="answer answer-annotation"></p>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" data-input-id="${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" data-input-id="${id}" value="${value|h}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
% if input_type == 'checkbox' or not value:
|
||||
% if status == 'unsubmitted' or show_correctness == 'never':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"><span class="sr">Status: correct</span></span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}"><span class="sr">Status: incorrect</span></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}"><span class="sr">Status: incomplete</span></span>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
% for choice_id, choice_description in choices:
|
||||
<label for="input_${id}_${choice_id}"
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
<%
|
||||
<%
|
||||
if status == 'correct':
|
||||
correctness = 'correct'
|
||||
elif status == 'incorrect':
|
||||
@@ -31,14 +31,29 @@
|
||||
% endif
|
||||
% endif
|
||||
>
|
||||
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
|
||||
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" aria-describedby="answer_${id}" value="${choice_id}"
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
checked="true"
|
||||
checked="true"
|
||||
% elif input_type != 'radio' and choice_id in value:
|
||||
checked="true"
|
||||
% endif
|
||||
|
||||
/> ${choice_description} </label>
|
||||
/> ${choice_description}
|
||||
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
<%
|
||||
if status == 'correct':
|
||||
correctness = 'correct'
|
||||
elif status == 'incorrect':
|
||||
correctness = 'incorrect'
|
||||
else:
|
||||
correctness = None
|
||||
%>
|
||||
% if correctness and not show_correctness=='never':
|
||||
<span class="sr" aria-describedby="input_${id}_${choice_id}">Status: ${correctness}</span>
|
||||
% endif
|
||||
% endif
|
||||
</label>
|
||||
% endfor
|
||||
<span id="answer_${id}"></span>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section id="textbox_${id}" class="textbox">
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
<div class="grader-status">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Unanswered</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Correct</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Incorrect</span>
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span class="processing" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
% endif
|
||||
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
|
||||
<input type="text" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
% elif status == 'incomplete':
|
||||
<div class="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
|
||||
<div id="protex_container"></div>
|
||||
<input type="hidden" name="target_shape" id="target_shape" value ="${target_shape}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
% endif
|
||||
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
|
||||
style="display:none;"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
% elif status == 'incomplete':
|
||||
<div class="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
|
||||
<div id="genex_container"></div>
|
||||
<input type="hidden" name="genex_dna_sequence" id="genex_dna_sequence" value ="${genex_dna_sequence}"></input>
|
||||
<input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
<input type="hidden" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"/>
|
||||
|
||||
<button id="reset_${id}" class="reset">Reset</button>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -5,12 +5,20 @@
|
||||
</div>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unanswered</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% endif
|
||||
</span>
|
||||
|
||||
@@ -19,13 +19,21 @@
|
||||
% endif
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unanswered</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% endif
|
||||
% if msg:
|
||||
<br/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section id="textbox_${id}" class="textbox">
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
<div class="grader-status">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"><span class="sr">Status: </span>Unanswered</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Correct</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Incorrect</span>
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span class="processing" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
$(parent_elt).find('.action').after(alert_elem);
|
||||
$(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// hook up the plot button
|
||||
var plot = function(event) {
|
||||
@@ -97,10 +97,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
var save_callback = function(response) {
|
||||
var save_callback = function(response) {
|
||||
if(response.success) {
|
||||
// send information to the problem's plot functionality
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
{'submission': submission}, plot_callback);
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<form class="option-input">
|
||||
<select name="input_${id}" id="input_${id}" >
|
||||
<select name="input_${id}" id="input_${id}" aria-describedby="answer_${id}">
|
||||
<option value="option_${id}_dummy_default"> </option>
|
||||
% for option_id, option_description in options:
|
||||
<option value="${option_id}"
|
||||
@@ -13,12 +13,20 @@
|
||||
<span id="answer_${id}"></span>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unsubmitted</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incomplete</span>
|
||||
</span>
|
||||
% endif
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<span>
|
||||
<input type="hidden" class="schematic" height="${height}" width="${width}" parts="${parts}" analyses="${analyses}" name="input_${id}" id="input_${id}" value="" initial_value=""/>
|
||||
<input type="hidden" class="schematic" height="${height}" width="${width}" parts="${parts}" analyses="${analyses}" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="" initial_value=""/>
|
||||
|
||||
<div id="value_${id}" style="display:none">${value}</div>
|
||||
<div id="initial_value_${id}" style="display:none">${initial_value}</div>
|
||||
@@ -13,13 +13,21 @@
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
% if status == 'unsubmitted':
|
||||
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unsubmitted</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incomplete</span>
|
||||
</span>
|
||||
% endif
|
||||
</span>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
|
||||
% if do_math:
|
||||
class="math"
|
||||
% endif
|
||||
@@ -33,7 +33,7 @@
|
||||
/>
|
||||
${trailing_text | h}
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
|
||||
style="display:none;"
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -640,6 +640,23 @@ class StringResponseTest(ResponseTest):
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'), "Hello??")
|
||||
|
||||
def test_hint_function_randomization(self):
|
||||
# The hint function should get the seed from the problem.
|
||||
problem = self.build_problem(
|
||||
answer="1",
|
||||
hintfn="gimme_a_random_hint",
|
||||
script=textwrap.dedent("""
|
||||
def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap):
|
||||
answer = str(random.randint(0, 1e9))
|
||||
new_cmap.set_hint_and_mode(answer_ids[0], answer, "always")
|
||||
|
||||
""")
|
||||
)
|
||||
correct_map = problem.grade_answers({'1_2_1': '2'})
|
||||
hint = correct_map.get_hint('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(hint, str(r.randint(0, 1e9)))
|
||||
|
||||
|
||||
class CodeResponseTest(ResponseTest):
|
||||
from response_xml_factory import CodeResponseXMLFactory
|
||||
@@ -948,7 +965,6 @@ class CustomResponseTest(ResponseTest):
|
||||
xml_factory_class = CustomResponseXMLFactory
|
||||
|
||||
def test_inline_code(self):
|
||||
|
||||
# For inline code, we directly modify global context variables
|
||||
# 'answers' is a list of answers provided to us
|
||||
# 'correct' is a list we fill in with True/False
|
||||
@@ -961,15 +977,14 @@ class CustomResponseTest(ResponseTest):
|
||||
self.assert_grade(problem, '0', 'incorrect')
|
||||
|
||||
def test_inline_message(self):
|
||||
|
||||
# Inline code can update the global messages list
|
||||
# to pass messages to the CorrectMap for a particular input
|
||||
# The code can also set the global overall_message (str)
|
||||
# to pass a message that applies to the whole response
|
||||
inline_script = textwrap.dedent("""
|
||||
messages[0] = "Test Message"
|
||||
overall_message = "Overall message"
|
||||
""")
|
||||
messages[0] = "Test Message"
|
||||
overall_message = "Overall message"
|
||||
""")
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
@@ -983,8 +998,19 @@ class CustomResponseTest(ResponseTest):
|
||||
overall_msg = correctmap.get_overall_message()
|
||||
self.assertEqual(overall_msg, "Overall message")
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
def test_inline_randomization(self):
|
||||
# Make sure the seed from the problem gets fed into the script execution.
|
||||
inline_script = """messages[0] = str(random.randint(0, 1e9))"""
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
correctmap = problem.grade_answers(input_dict)
|
||||
|
||||
input_msg = correctmap.get_msg('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(input_msg, str(r.randint(0, 1e9)))
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
# For function code, we pass in these arguments:
|
||||
#
|
||||
# 'expect' is the expect attribute of the <customresponse>
|
||||
@@ -1212,6 +1238,29 @@ class CustomResponseTest(ResponseTest):
|
||||
with self.assertRaises(ResponseError):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
def test_setup_randomization(self):
|
||||
# Ensure that the problem setup script gets the random seed from the problem.
|
||||
script = textwrap.dedent("""
|
||||
num = random.randint(0, 1e9)
|
||||
""")
|
||||
problem = self.build_problem(script=script)
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(r.randint(0, 1e9), problem.context['num'])
|
||||
|
||||
def test_check_function_randomization(self):
|
||||
# The check function should get random-seeded from the problem.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'ok': True, 'msg': str(random.randint(0, 1e9))}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func", expect="42")
|
||||
input_dict = {'1_2_1': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(msg, str(r.randint(0, 1e9)))
|
||||
|
||||
def test_module_imports_inline(self):
|
||||
'''
|
||||
Check that the correct modules are available to custom
|
||||
@@ -1275,7 +1324,6 @@ class SchematicResponseTest(ResponseTest):
|
||||
xml_factory_class = SchematicResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
|
||||
# Most of the schematic-specific work is handled elsewhere
|
||||
# (in client-side JavaScript)
|
||||
# The <schematicresponse> is responsible only for executing the
|
||||
@@ -1290,7 +1338,7 @@ class SchematicResponseTest(ResponseTest):
|
||||
|
||||
# The actual dictionary would contain schematic information
|
||||
# sent from the JavaScript simulation
|
||||
submission_dict = {'test': 'test'}
|
||||
submission_dict = {'test': 'the_answer'}
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
@@ -1299,8 +1347,19 @@ class SchematicResponseTest(ResponseTest):
|
||||
# is what we expect)
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
def test_script_exception(self):
|
||||
def test_check_function_randomization(self):
|
||||
# The check function should get a random seed from the problem.
|
||||
script = "correct = ['correct' if (submission[0]['num'] == random.randint(0, 1e9)) else 'incorrect']"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
r = random.Random(problem.seed)
|
||||
submission_dict = {'num': r.randint(0, 1e9)}
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
def test_script_exception(self):
|
||||
# Construct a script that will raise an exception
|
||||
script = "raise Exception('test')"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
@@ -6,7 +6,7 @@ from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.exceptions import InvalidDefinitionError
|
||||
from xblock.core import String, Scope, Object
|
||||
from xblock.core import String, Scope, Dict
|
||||
|
||||
DEFAULT = "_DEFAULT_GROUP"
|
||||
|
||||
@@ -32,9 +32,9 @@ def group_from_value(groups, v):
|
||||
|
||||
|
||||
class ABTestFields(object):
|
||||
group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
|
||||
group_assignments = Object(help="What group this user belongs to", scope=Scope.preferences, default={})
|
||||
group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
|
||||
group_portions = Dict(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
|
||||
group_assignments = Dict(help="What group this user belongs to", scope=Scope.preferences, default={})
|
||||
group_content = Dict(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
|
||||
experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content)
|
||||
has_children = True
|
||||
|
||||
|
||||
@@ -125,6 +125,5 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
|
||||
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
|
||||
module_class = AnnotatableModule
|
||||
stores_state = True
|
||||
template_dir_name = "annotatable"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
@@ -11,16 +11,16 @@ import sys
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from capa.responsetypes import StudentInputError,\
|
||||
from capa.responsetypes import StudentInputError, \
|
||||
ResponseError, LoncapaProblemError
|
||||
from capa.util import convert_files_to_filenames
|
||||
from .progress import Progress
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xblock.core import Scope, String, Boolean, Object
|
||||
from .fields import Timedelta, Date, StringyInteger, StringyFloat
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
from xblock.core import Scope, String, Boolean, Dict, Integer, Float
|
||||
from .fields import Timedelta, Date
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -65,8 +65,8 @@ class ComplexEncoder(json.JSONEncoder):
|
||||
|
||||
|
||||
class CapaFields(object):
|
||||
attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state)
|
||||
max_attempts = StringyInteger(
|
||||
attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state)
|
||||
max_attempts = Integer(
|
||||
display_name="Maximum Attempts",
|
||||
help="Defines the number of times a student can try to answer this problem. If the value is not set, infinite attempts are allowed.",
|
||||
values={"min": 0}, scope=Scope.settings
|
||||
@@ -95,12 +95,12 @@ class CapaFields(object):
|
||||
{"display_name": "Per Student", "value": "per_student"}]
|
||||
)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={})
|
||||
input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
|
||||
student_answers = Object(help="Dictionary with the current student responses", scope=Scope.user_state)
|
||||
correct_map = Dict(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={})
|
||||
input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
|
||||
student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state)
|
||||
done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state)
|
||||
seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state)
|
||||
weight = StringyFloat(
|
||||
seed = Integer(help="Random seed for this student", scope=Scope.user_state)
|
||||
weight = Float(
|
||||
display_name="Problem Weight",
|
||||
help="Defines the number of points each problem is worth. If the value is not set, each response field in the problem is worth one point.",
|
||||
values={"min": 0, "step": .1},
|
||||
@@ -117,6 +117,8 @@ class CapaModule(CapaFields, XModule):
|
||||
'''
|
||||
An XModule implementing LonCapa format problems, implemented by way of
|
||||
capa.capa_problem.LoncapaProblem
|
||||
|
||||
CapaModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
|
||||
'''
|
||||
icon_class = 'problem'
|
||||
|
||||
@@ -131,10 +133,11 @@ class CapaModule(CapaFields, XModule):
|
||||
js_module_name = "Problem"
|
||||
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, descriptor, model_data):
|
||||
XModule.__init__(self, system, location, descriptor, model_data)
|
||||
def __init__(self, *args, **kwargs):
|
||||
""" Accepts the same arguments as xmodule.x_module:XModule.__init__ """
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
|
||||
due_date = time_to_datetime(self.due)
|
||||
due_date = self.due
|
||||
|
||||
if self.graceperiod is not None and due_date:
|
||||
self.close_date = due_date + self.graceperiod
|
||||
@@ -315,7 +318,7 @@ class CapaModule(CapaFields, XModule):
|
||||
# If the user has forced the save button to display,
|
||||
# then show it as long as the problem is not closed
|
||||
# (past due / too many attempts)
|
||||
if self.force_save_button == "true":
|
||||
if self.force_save_button:
|
||||
return not self.closed()
|
||||
else:
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
@@ -502,7 +505,7 @@ class CapaModule(CapaFields, XModule):
|
||||
Is it now past this problem's due date, including grace period?
|
||||
"""
|
||||
return (self.close_date is not None and
|
||||
datetime.datetime.utcnow() > self.close_date)
|
||||
datetime.datetime.now(UTC()) > self.close_date)
|
||||
|
||||
def closed(self):
|
||||
''' Is the student still allowed to submit answers? '''
|
||||
@@ -747,7 +750,7 @@ class CapaModule(CapaFields, XModule):
|
||||
|
||||
# Problem queued. Students must wait a specified waittime before they are allowed to submit
|
||||
if self.lcp.is_queued():
|
||||
current_time = datetime.datetime.now()
|
||||
current_time = datetime.datetime.now(UTC())
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
waittime_between_requests = self.system.xqueue['waittime']
|
||||
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
@@ -902,7 +905,6 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
|
||||
module_class = CapaModule
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = 'problem'
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
|
||||
@@ -5,10 +5,10 @@ from pkg_resources import resource_string
|
||||
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from .x_module import XModule
|
||||
from xblock.core import Integer, Scope, String, List
|
||||
from xblock.core import Integer, Scope, String, List, Float, Boolean
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
from collections import namedtuple
|
||||
from .fields import Date, StringyFloat, StringyInteger, StringyBoolean
|
||||
from .fields import Date
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -53,27 +53,27 @@ class CombinedOpenEndedFields(object):
|
||||
help="This name appears in the horizontal navigation at the top of the page.",
|
||||
default="Open Ended Grading", scope=Scope.settings
|
||||
)
|
||||
current_task_number = StringyInteger(help="Current task that the student is on.", default=0, scope=Scope.user_state)
|
||||
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state)
|
||||
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state)
|
||||
state = String(help="Which step within the current task that the student is on.", default="initial",
|
||||
scope=Scope.user_state)
|
||||
student_attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0,
|
||||
student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
|
||||
scope=Scope.user_state)
|
||||
ready_to_reset = StringyBoolean(
|
||||
ready_to_reset = Boolean(
|
||||
help="If the problem is ready to be reset or not.", default=False,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
attempts = StringyInteger(
|
||||
attempts = Integer(
|
||||
display_name="Maximum Attempts",
|
||||
help="The number of times the student can try to answer this problem.", default=1,
|
||||
scope=Scope.settings, values = {"min" : 1 }
|
||||
)
|
||||
is_graded = StringyBoolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
|
||||
accept_file_upload = StringyBoolean(
|
||||
is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
|
||||
accept_file_upload = Boolean(
|
||||
display_name="Allow File Uploads",
|
||||
help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings
|
||||
)
|
||||
skip_spelling_checks = StringyBoolean(
|
||||
skip_spelling_checks = Boolean(
|
||||
display_name="Disable Quality Filter",
|
||||
help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.",
|
||||
default=False, scope=Scope.settings
|
||||
@@ -86,7 +86,7 @@ class CombinedOpenEndedFields(object):
|
||||
)
|
||||
version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
weight = StringyFloat(
|
||||
weight = Float(
|
||||
display_name="Problem Weight",
|
||||
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
|
||||
scope=Scope.settings, values = {"min" : 0 , "step": ".1"}
|
||||
@@ -116,6 +116,8 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
incorporates multiple children (tasks):
|
||||
openendedmodule
|
||||
selfassessmentmodule
|
||||
|
||||
CombinedOpenEndedModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
|
||||
"""
|
||||
STATE_VERSION = 1
|
||||
|
||||
@@ -139,8 +141,7 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, descriptor, model_data):
|
||||
XModule.__init__(self, system, location, descriptor, model_data)
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block:
|
||||
|
||||
@@ -175,9 +176,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
</combinedopenended>
|
||||
|
||||
"""
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
|
||||
self.system = system
|
||||
self.system.set('location', location)
|
||||
self.system.set('location', self.location)
|
||||
|
||||
if self.task_states is None:
|
||||
self.task_states = []
|
||||
@@ -189,13 +190,11 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
|
||||
attributes = self.student_attributes + self.settings_attributes
|
||||
|
||||
static_data = {
|
||||
'rewrite_content_links': self.rewrite_content_links,
|
||||
}
|
||||
static_data = {}
|
||||
instance_state = {k: getattr(self, k) for k in attributes}
|
||||
self.child_descriptor = version_tuple.descriptor(self.system)
|
||||
self.child_definition = version_tuple.descriptor.definition_from_xml(etree.fromstring(self.data), self.system)
|
||||
self.child_module = version_tuple.module(self.system, location, self.child_definition, self.child_descriptor,
|
||||
self.child_module = version_tuple.module(self.system, self.location, self.child_definition, self.child_descriptor,
|
||||
instance_state=instance_state, static_data=static_data,
|
||||
attributes=attributes)
|
||||
self.save_instance_data()
|
||||
@@ -239,7 +238,6 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
|
||||
mako_template = "widgets/open-ended-edit.html"
|
||||
module_class = CombinedOpenEndedModule
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
always_recalculate_grades = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
@@ -92,7 +92,7 @@ class ConditionalModule(ConditionalFields, XModule):
|
||||
if xml_value and self.required_modules:
|
||||
for module in self.required_modules:
|
||||
if not hasattr(module, attr_name):
|
||||
# We don't throw an exception here because it is possible for
|
||||
# We don't throw an exception here because it is possible for
|
||||
# the descriptor of a required module to have a property but
|
||||
# for the resulting module to be a (flavor of) ErrorModule.
|
||||
# So just log and return false.
|
||||
@@ -161,7 +161,6 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = False
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -3,7 +3,7 @@ from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
_CONTENTSTORE = None
|
||||
_CONTENTSTORE = {}
|
||||
|
||||
|
||||
def load_function(path):
|
||||
@@ -17,13 +17,16 @@ def load_function(path):
|
||||
return getattr(import_module(module_path), name)
|
||||
|
||||
|
||||
def contentstore():
|
||||
def contentstore(name='default'):
|
||||
global _CONTENTSTORE
|
||||
|
||||
if _CONTENTSTORE is None:
|
||||
if name not in _CONTENTSTORE:
|
||||
class_ = load_function(settings.CONTENTSTORE['ENGINE'])
|
||||
options = {}
|
||||
options.update(settings.CONTENTSTORE['OPTIONS'])
|
||||
_CONTENTSTORE = class_(**options)
|
||||
if 'ADDITIONAL_OPTIONS' in settings.CONTENTSTORE:
|
||||
if name in settings.CONTENTSTORE['ADDITIONAL_OPTIONS']:
|
||||
options.update(settings.CONTENTSTORE['ADDITIONAL_OPTIONS'][name])
|
||||
_CONTENTSTORE[name] = class_(**options)
|
||||
|
||||
return _CONTENTSTORE
|
||||
return _CONTENTSTORE[name]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from bson.son import SON
|
||||
from pymongo import Connection
|
||||
import gridfs
|
||||
from gridfs.errors import NoFile
|
||||
@@ -15,15 +14,16 @@ import os
|
||||
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, **kwargs):
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, bucket='fs', **kwargs):
|
||||
logging.debug('Using MongoDB for static content serving at host={0} db={1}'.format(host, db))
|
||||
_db = Connection(host=host, port=port, **kwargs)[db]
|
||||
|
||||
if user is not None and password is not None:
|
||||
_db.authenticate(user, password)
|
||||
|
||||
self.fs = gridfs.GridFS(_db)
|
||||
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
|
||||
self.fs = gridfs.GridFS(_db, bucket)
|
||||
|
||||
self.fs_files = _db[bucket + ".files"] # the underlying collection GridFS uses
|
||||
|
||||
def save(self, content):
|
||||
id = content.get_id()
|
||||
@@ -43,7 +43,7 @@ class MongoContentStore(ContentStore):
|
||||
if self.fs.exists({"_id": id}):
|
||||
self.fs.delete(id)
|
||||
|
||||
def find(self, location):
|
||||
def find(self, location, throw_on_not_found=True):
|
||||
id = StaticContent.get_id_from_location(location)
|
||||
try:
|
||||
with self.fs.get(id) as fp:
|
||||
@@ -52,7 +52,10 @@ class MongoContentStore(ContentStore):
|
||||
thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
|
||||
import_path=fp.import_path if hasattr(fp, 'import_path') else None)
|
||||
except NoFile:
|
||||
raise NotFoundError()
|
||||
if throw_on_not_found:
|
||||
raise NotFoundError()
|
||||
else:
|
||||
return None
|
||||
|
||||
def export(self, location, output_directory):
|
||||
content = self.find(location)
|
||||
|
||||
49
common/lib/xmodule/xmodule/contentstore/utils.py
Normal file
49
common/lib/xmodule/xmodule/contentstore/utils.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from .django import contentstore
|
||||
|
||||
|
||||
def empty_asset_trashcan(course_locs):
|
||||
'''
|
||||
This method will hard delete all assets (optionally within a course_id) from the trashcan
|
||||
'''
|
||||
store = contentstore('trashcan')
|
||||
|
||||
for course_loc in course_locs:
|
||||
# first delete all of the thumbnails
|
||||
thumbs = store.get_all_content_thumbnails_for_course(course_loc)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
id = StaticContent.get_id_from_location(thumb_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
store.delete(id)
|
||||
|
||||
# then delete all of the assets
|
||||
assets = store.get_all_content_for_course(course_loc)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
store.delete(id)
|
||||
|
||||
|
||||
def restore_asset_from_trashcan(location):
|
||||
'''
|
||||
This method will restore an asset which got soft deleted and put back in the original course
|
||||
'''
|
||||
trash = contentstore('trashcan')
|
||||
store = contentstore()
|
||||
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
content = trash.find(loc)
|
||||
|
||||
# ok, save the content into the courseware
|
||||
store.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 = trash.find(content.thumbnail_location)
|
||||
store.save(thumbnail_content)
|
||||
except:
|
||||
pass # OK if this is left dangling
|
||||
@@ -4,21 +4,20 @@ from math import exp
|
||||
from lxml import etree
|
||||
from path import path # NOTE (THK): Only used for detecting presence of syllabus
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.timeparse import parse_time
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
import json
|
||||
|
||||
from xblock.core import Scope, List, String, Object, Boolean
|
||||
from xblock.core import Scope, List, String, Dict, Boolean
|
||||
from .fields import Date
|
||||
from django.utils.timezone import UTC
|
||||
from xmodule.util import date_utils
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -93,7 +92,7 @@ class Textbook(object):
|
||||
# see if we already fetched this
|
||||
if toc_url in _cached_toc:
|
||||
(table_of_contents, timestamp) = _cached_toc[toc_url]
|
||||
age = datetime.now() - timestamp
|
||||
age = datetime.now(UTC) - timestamp
|
||||
# expire every 10 minutes
|
||||
if age.seconds < 600:
|
||||
return table_of_contents
|
||||
@@ -154,25 +153,25 @@ class CourseFields(object):
|
||||
start = Date(help="Start time when this module is visible", scope=Scope.settings)
|
||||
end = Date(help="Date that this class ends", scope=Scope.settings)
|
||||
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
|
||||
grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content)
|
||||
grading_policy = Dict(help="Grading policy definition for this class", scope=Scope.content)
|
||||
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
|
||||
display_name = String(help="Display name for this module", scope=Scope.settings)
|
||||
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
|
||||
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
|
||||
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
|
||||
discussion_topics = Object(
|
||||
discussion_topics = Dict(
|
||||
help="Map of topics names to ids",
|
||||
scope=Scope.settings
|
||||
)
|
||||
testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings)
|
||||
testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings)
|
||||
announcement = Date(help="Date this course is announced", scope=Scope.settings)
|
||||
cohort_config = Object(help="Dictionary defining cohort configuration", scope=Scope.settings)
|
||||
cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings)
|
||||
is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings)
|
||||
no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings)
|
||||
disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings)
|
||||
pdf_textbooks = List(help="List of dictionaries containing pdf_textbook configuration", scope=Scope.settings)
|
||||
html_textbooks = List(help="List of dictionaries containing html_textbook configuration", scope=Scope.settings)
|
||||
remote_gradebook = Object(scope=Scope.settings)
|
||||
remote_gradebook = Dict(scope=Scope.settings)
|
||||
allow_anonymous = Boolean(scope=Scope.settings, default=True)
|
||||
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
|
||||
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
|
||||
@@ -219,8 +218,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
msg = None
|
||||
if self.start is None:
|
||||
msg = "Course loaded without a valid start date. id = %s" % self.id
|
||||
# hack it -- start in 1970
|
||||
self.start = time.gmtime(0)
|
||||
self.start = datetime.now(UTC())
|
||||
log.critical(msg)
|
||||
self.system.error_tracker(msg)
|
||||
|
||||
@@ -392,7 +390,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
textbook_xml_object.set('book_url', textbook.book_url)
|
||||
|
||||
xml_object.append(textbook_xml_object)
|
||||
|
||||
|
||||
return xml_object
|
||||
|
||||
def has_ended(self):
|
||||
@@ -403,10 +401,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
if self.end is None:
|
||||
return False
|
||||
|
||||
return time.gmtime() > self.end
|
||||
return datetime.now(UTC()) > self.end
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
return datetime.now(UTC()) > self.start
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
@@ -547,14 +545,16 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
announcement = self.announcement
|
||||
if announcement is not None:
|
||||
announcement = time_to_datetime(announcement)
|
||||
announcement = announcement
|
||||
|
||||
try:
|
||||
start = dateutil.parser.parse(self.advertised_start)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=UTC())
|
||||
except (ValueError, AttributeError):
|
||||
start = time_to_datetime(self.start)
|
||||
start = self.start
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC())
|
||||
|
||||
return announcement, start, now
|
||||
|
||||
@@ -644,8 +644,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
def start_date_text(self):
|
||||
def try_parse_iso_8601(text):
|
||||
try:
|
||||
result = datetime.strptime(text, "%Y-%m-%dT%H:%M")
|
||||
result = result.strftime("%b %d, %Y")
|
||||
result = Date().from_json(text)
|
||||
if result is None:
|
||||
result = text.title()
|
||||
else:
|
||||
result = result.strftime("%b %d, %Y")
|
||||
except ValueError:
|
||||
result = text.title()
|
||||
|
||||
@@ -656,7 +659,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
elif self.advertised_start is None and self.start is None:
|
||||
return 'TBD'
|
||||
else:
|
||||
return time.strftime("%b %d, %Y", self.advertised_start or self.start)
|
||||
return (self.advertised_start or self.start).strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def end_date_text(self):
|
||||
@@ -665,15 +668,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
If the course does not have an end date set (course.end is None), an empty string will be returned.
|
||||
"""
|
||||
return '' if self.end is None else time.strftime("%b %d, %Y", self.end)
|
||||
return '' if self.end is None else self.end.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def forum_posts_allowed(self):
|
||||
date_proxy = Date()
|
||||
try:
|
||||
blackout_periods = [(parse_time(start), parse_time(end))
|
||||
blackout_periods = [(date_proxy.from_json(start),
|
||||
date_proxy.from_json(end))
|
||||
for start, end
|
||||
in self.discussion_blackouts]
|
||||
now = time.gmtime()
|
||||
now = datetime.now(UTC())
|
||||
for start, end in blackout_periods:
|
||||
if start <= now <= end:
|
||||
return False
|
||||
@@ -699,7 +704,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
if self.last_eligible_appointment_date is None:
|
||||
raise ValueError("Last appointment date must be specified")
|
||||
self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0)
|
||||
self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or
|
||||
datetime.fromtimestamp(0, UTC()))
|
||||
self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date
|
||||
# do validation within the exam info:
|
||||
if self.registration_start_date > self.registration_end_date:
|
||||
@@ -718,39 +724,39 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
"""
|
||||
if key in self.exam_info:
|
||||
try:
|
||||
return parse_time(self.exam_info[key])
|
||||
return Date().from_json(self.exam_info[key])
|
||||
except ValueError as e:
|
||||
msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e)
|
||||
log.warning(msg)
|
||||
return None
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.first_eligible_appointment_date
|
||||
return datetime.now(UTC()) > self.first_eligible_appointment_date
|
||||
|
||||
def has_ended(self):
|
||||
return time.gmtime() > self.last_eligible_appointment_date
|
||||
return datetime.now(UTC()) > self.last_eligible_appointment_date
|
||||
|
||||
def has_started_registration(self):
|
||||
return time.gmtime() > self.registration_start_date
|
||||
return datetime.now(UTC()) > self.registration_start_date
|
||||
|
||||
def has_ended_registration(self):
|
||||
return time.gmtime() > self.registration_end_date
|
||||
return datetime.now(UTC()) > self.registration_end_date
|
||||
|
||||
def is_registering(self):
|
||||
now = time.gmtime()
|
||||
now = datetime.now(UTC())
|
||||
return now >= self.registration_start_date and now <= self.registration_end_date
|
||||
|
||||
@property
|
||||
def first_eligible_appointment_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.first_eligible_appointment_date)
|
||||
return self.first_eligible_appointment_date.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def last_eligible_appointment_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.last_eligible_appointment_date)
|
||||
return self.last_eligible_appointment_date.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def registration_end_date_text(self):
|
||||
return time.strftime("%b %d, %Y at %H:%M UTC", self.registration_end_date)
|
||||
return date_utils.get_default_time_display(self.registration_end_date)
|
||||
|
||||
@property
|
||||
def current_test_center_exam(self):
|
||||
|
||||
@@ -551,10 +551,24 @@ section.problem {
|
||||
section.action {
|
||||
margin-top: 20px;
|
||||
|
||||
input.save {
|
||||
.save, .check, .show {
|
||||
height: ($baseline*2);
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.save {
|
||||
@extend .blue-button;
|
||||
}
|
||||
|
||||
.show {
|
||||
|
||||
.show-label {
|
||||
font-size: 1.0em;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.submission_feedback {
|
||||
// background: #F3F3F3;
|
||||
// border: 1px solid #ddd;
|
||||
@@ -811,13 +825,13 @@ section.problem {
|
||||
}
|
||||
.selected-grade {
|
||||
background: #666;
|
||||
color: white;
|
||||
color: white;
|
||||
}
|
||||
input[type=radio]:checked + label {
|
||||
background: #666;
|
||||
color: white; }
|
||||
input[class='score-selection'] {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,11 +892,11 @@ section.problem {
|
||||
.tag-status, .tag { padding: .25em .5em; }
|
||||
}
|
||||
}
|
||||
textarea.comment {
|
||||
textarea.comment {
|
||||
$num-lines-to-show: 5;
|
||||
$line-height: 1.4em;
|
||||
$padding: .2em;
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
padding: $padding (2 * $padding);
|
||||
line-height: $line-height;
|
||||
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
|
||||
|
||||
@@ -94,11 +94,11 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
|
||||
model_data = {
|
||||
'error_msg': str(error_msg),
|
||||
'contents': contents,
|
||||
'display_name': 'Error: ' + location.name
|
||||
'display_name': 'Error: ' + location.name,
|
||||
'location': location,
|
||||
}
|
||||
return cls(
|
||||
system,
|
||||
location,
|
||||
model_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,20 +2,41 @@ import time
|
||||
import logging
|
||||
import re
|
||||
|
||||
from datetime import timedelta
|
||||
from xblock.core import ModelType
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
|
||||
from xblock.core import Integer, Float, Boolean
|
||||
from pytz import UTC
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Date(ModelType):
|
||||
'''
|
||||
Date fields know how to parse and produce json (iso) compatible formats.
|
||||
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes.
|
||||
'''
|
||||
# See note below about not defaulting these
|
||||
CURRENT_YEAR = datetime.datetime.now(UTC).year
|
||||
PREVENT_DEFAULT_DAY_MON_SEED1 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC)
|
||||
PREVENT_DEFAULT_DAY_MON_SEED2 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC)
|
||||
|
||||
def _parse_date_wo_default_month_day(self, field):
|
||||
"""
|
||||
Parse the field as an iso string but prevent dateutils from defaulting the day or month while
|
||||
allowing it to default the other fields.
|
||||
"""
|
||||
# It's not trivial to replace dateutil b/c parsing timezones as Z, +03:30, -400 is hard in python
|
||||
# however, we don't want dateutil to default the month or day (but some tests at least expect
|
||||
# us to default year); so, we'll see if dateutil uses the defaults for these the hard way
|
||||
result = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED1)
|
||||
result_other = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED2)
|
||||
if result != result_other:
|
||||
log.warning("Field {0} is missing month or day".format(self._name, field))
|
||||
return None
|
||||
if result.tzinfo is None:
|
||||
result = result.replace(tzinfo=UTC)
|
||||
return result
|
||||
|
||||
def from_json(self, field):
|
||||
"""
|
||||
Parse an optional metadata key containing a time: if present, complain
|
||||
@@ -27,11 +48,12 @@ class Date(ModelType):
|
||||
elif field is "":
|
||||
return None
|
||||
elif isinstance(field, basestring):
|
||||
d = dateutil.parser.parse(field)
|
||||
return d.utctimetuple()
|
||||
return self._parse_date_wo_default_month_day(field)
|
||||
elif isinstance(field, (int, long, float)):
|
||||
return time.gmtime(field / 1000)
|
||||
return datetime.datetime.fromtimestamp(field / 1000, UTC)
|
||||
elif isinstance(field, time.struct_time):
|
||||
return datetime.datetime.fromtimestamp(time.mktime(field), UTC)
|
||||
elif isinstance(field, datetime.datetime):
|
||||
return field
|
||||
else:
|
||||
msg = "Field {0} has bad value '{1}'".format(
|
||||
@@ -49,7 +71,11 @@ class Date(ModelType):
|
||||
# struct_times are always utc
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%SZ', value)
|
||||
elif isinstance(value, datetime.datetime):
|
||||
return value.isoformat() + 'Z'
|
||||
if value.tzinfo is None or value.utcoffset().total_seconds() == 0:
|
||||
# isoformat adds +00:00 rather than Z
|
||||
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
else:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
@@ -66,6 +92,8 @@ class Timedelta(ModelType):
|
||||
|
||||
Returns a datetime.timedelta parsed from the string
|
||||
"""
|
||||
if time_str is None:
|
||||
return None
|
||||
parts = TIMEDELTA_REGEX.match(time_str)
|
||||
if not parts:
|
||||
return
|
||||
@@ -74,7 +102,7 @@ class Timedelta(ModelType):
|
||||
for (name, param) in parts.iteritems():
|
||||
if param:
|
||||
time_params[name] = int(param)
|
||||
return timedelta(**time_params)
|
||||
return datetime.timedelta(**time_params)
|
||||
|
||||
def to_json(self, value):
|
||||
values = []
|
||||
@@ -83,42 +111,3 @@ class Timedelta(ModelType):
|
||||
if cur_value > 0:
|
||||
values.append("%d %s" % (cur_value, attr))
|
||||
return ' '.join(values)
|
||||
|
||||
|
||||
class StringyInteger(Integer):
|
||||
"""
|
||||
A model type that converts from strings to integers when reading from json.
|
||||
If value does not parse as an int, returns None.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
try:
|
||||
return int(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
class StringyFloat(Float):
|
||||
"""
|
||||
A model type that converts from string to floats when reading from json.
|
||||
If value does not parse as a float, returns None.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
try:
|
||||
return float(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
class StringyBoolean(Boolean):
|
||||
"""
|
||||
Reads strings from JSON as booleans.
|
||||
|
||||
If the string is 'true' (case insensitive), then return True,
|
||||
otherwise False.
|
||||
|
||||
JSON values that aren't strings are returned as-is.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
if isinstance(value, basestring):
|
||||
return value.lower() == 'true'
|
||||
return value
|
||||
|
||||
@@ -8,7 +8,6 @@ from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xblock.core import Scope, Integer, String
|
||||
from .fields import Date
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -31,9 +30,7 @@ class FolditModule(FolditFields, XModule):
|
||||
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
"""
|
||||
|
||||
Example:
|
||||
<foldit show_basic_score="true"
|
||||
required_level="4"
|
||||
@@ -42,8 +39,8 @@ class FolditModule(FolditFields, XModule):
|
||||
required_sublevel_half_credit="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
|
||||
self.due_time = time_to_datetime(self.due)
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
self.due_time = self.due
|
||||
|
||||
def is_complete(self):
|
||||
"""
|
||||
@@ -102,7 +99,7 @@ class FolditModule(FolditFields, XModule):
|
||||
from foldit.models import Score
|
||||
|
||||
leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
leaders.sort(key=lambda x: -x[1])
|
||||
leaders.sort(key=lambda x:-x[1])
|
||||
|
||||
return leaders
|
||||
|
||||
@@ -186,7 +183,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
|
||||
module_class = FolditModule
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "foldit"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<input class="check" type="button" value="Check">
|
||||
<input class="reset" type="button" value="Reset">
|
||||
<input class="save" type="button" value="Save">
|
||||
<input class="show" type="button" value="Show Answer">
|
||||
<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>
|
||||
<a href="/courseware/6.002_Spring_2012/${ explain }" class="new-page">Explanation</a>
|
||||
<section class="submission_feedback"></section>
|
||||
</section>
|
||||
|
||||
23
common/lib/xmodule/xmodule/js/fixtures/videoalpha.html
Normal file
23
common/lib/xmodule/xmodule/js/fixtures/videoalpha.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="course-content">
|
||||
<div id="video_example">
|
||||
<div id="example">
|
||||
<div
|
||||
id="video_id"
|
||||
class="video"
|
||||
data-streams="0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId"
|
||||
data-show-captions="true"
|
||||
data-start=""
|
||||
data-end=""
|
||||
data-caption-asset-path="/static/subs/">
|
||||
<div class="tc-wrapper">
|
||||
<article class="video-wrapper">
|
||||
<section class="video-player">
|
||||
<div id="id"></div>
|
||||
</section>
|
||||
<section class="video-controls"></section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
27
common/lib/xmodule/xmodule/js/fixtures/videoalpha_html5.html
Normal file
27
common/lib/xmodule/xmodule/js/fixtures/videoalpha_html5.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="course-content">
|
||||
<div id="video_example">
|
||||
<div id="example">
|
||||
<div
|
||||
id="video_id"
|
||||
class="video"
|
||||
data-show-captions="true"
|
||||
data-start=""
|
||||
data-end=""
|
||||
data-caption-asset-path="/static/subs/"
|
||||
data-sub="test_name_of_the_subtitles"
|
||||
data-mp4-source="test.mp4"
|
||||
data-webm-source="test.webm"
|
||||
data-ogg-source="test.ogv"
|
||||
>
|
||||
<div class="tc-wrapper">
|
||||
<article class="video-wrapper">
|
||||
<section class="video-player">
|
||||
<div id="id"></div>
|
||||
</section>
|
||||
<section class="video-controls"></section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@ describe 'Problem', ->
|
||||
|
||||
# note that the fixturesPath is set in spec/helper.coffee
|
||||
loadFixtures 'problem.html'
|
||||
|
||||
|
||||
spyOn Logger, 'log'
|
||||
spyOn($.fn, 'load').andCallFake (url, callback) ->
|
||||
$(@).html readFixtures('problem_content.html')
|
||||
@@ -27,13 +27,13 @@ describe 'Problem', ->
|
||||
it 'set the element from html', ->
|
||||
@problem999 = new Problem ("
|
||||
<section class='xmodule_display xmodule_CapaModule' data-type='Problem'>
|
||||
<section id='problem_999'
|
||||
class='problems-wrapper'
|
||||
data-problem-id='i4x://edX/999/problem/Quiz'
|
||||
<section id='problem_999'
|
||||
class='problems-wrapper'
|
||||
data-problem-id='i4x://edX/999/problem/Quiz'
|
||||
data-url='/problem/quiz/'>
|
||||
</section>
|
||||
</section>
|
||||
")
|
||||
")
|
||||
expect(@problem999.element_id).toBe 'problem_999'
|
||||
|
||||
it 'set the element from loadFixtures', ->
|
||||
@@ -62,7 +62,7 @@ describe 'Problem', ->
|
||||
expect($('section.action input.reset')).toHandleWith 'click', @problem.reset
|
||||
|
||||
it 'bind the show button', ->
|
||||
expect($('section.action input.show')).toHandleWith 'click', @problem.show
|
||||
expect($('section.action button.show')).toHandleWith 'click', @problem.show
|
||||
|
||||
it 'bind the save button', ->
|
||||
expect($('section.action input.save')).toHandleWith 'click', @problem.save
|
||||
@@ -126,14 +126,14 @@ describe 'Problem', ->
|
||||
|
||||
describe 'when the response is correct', ->
|
||||
it 'call render with returned content', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
callback(success: 'correct', contents: 'Correct!')
|
||||
@problem.check()
|
||||
expect(@problem.el.html()).toEqual 'Correct!'
|
||||
|
||||
describe 'when the response is incorrect', ->
|
||||
it 'call render with returned content', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
callback(success: 'incorrect', contents: 'Incorrect!')
|
||||
@problem.check()
|
||||
expect(@problem.el.html()).toEqual 'Incorrect!'
|
||||
@@ -159,7 +159,7 @@ describe 'Problem', ->
|
||||
it 'POST to the problem reset page', ->
|
||||
spyOn $, 'postWithPrefix'
|
||||
@problem.reset()
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset',
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset',
|
||||
{ id: 'i4x://edX/101/problem/Problem1' }, jasmine.any(Function)
|
||||
|
||||
it 'render the returned content', ->
|
||||
@@ -179,7 +179,7 @@ describe 'Problem', ->
|
||||
|
||||
it 'log the problem_show event', ->
|
||||
@problem.show()
|
||||
expect(Logger.log).toHaveBeenCalledWith 'problem_show',
|
||||
expect(Logger.log).toHaveBeenCalledWith 'problem_show',
|
||||
problem: 'i4x://edX/101/problem/Problem1'
|
||||
|
||||
it 'fetch the answers', ->
|
||||
@@ -198,7 +198,7 @@ describe 'Problem', ->
|
||||
it 'toggle the show answer button', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {})
|
||||
@problem.show()
|
||||
expect($('.show')).toHaveValue 'Hide Answer'
|
||||
expect($('.show .show-label')).toHaveText 'Hide Answer(s)'
|
||||
|
||||
it 'add the showed class to element', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {})
|
||||
@@ -223,7 +223,7 @@ describe 'Problem', ->
|
||||
expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true'
|
||||
expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true'
|
||||
|
||||
describe 'when the answers are alreay shown', ->
|
||||
describe 'when the answers are already shown', ->
|
||||
beforeEach ->
|
||||
@problem.el.addClass 'showed'
|
||||
@problem.el.prepend '''
|
||||
@@ -243,7 +243,7 @@ describe 'Problem', ->
|
||||
|
||||
it 'toggle the show answer button', ->
|
||||
@problem.show()
|
||||
expect($('.show')).toHaveValue 'Show Answer'
|
||||
expect($('.show .show-label')).toHaveText 'Show Answer(s)'
|
||||
|
||||
it 'remove the showed class from element', ->
|
||||
@problem.show()
|
||||
@@ -261,7 +261,7 @@ describe 'Problem', ->
|
||||
it 'POST to save problem', ->
|
||||
spyOn $, 'postWithPrefix'
|
||||
@problem.save()
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
|
||||
'foo=1&bar=2', jasmine.any(Function)
|
||||
|
||||
# TODO: figure out why failing
|
||||
|
||||
@@ -20,10 +20,25 @@ jasmine.stubbedMetadata =
|
||||
bogus:
|
||||
duration: 100
|
||||
|
||||
jasmine.fireEvent = (el, eventName) ->
|
||||
if document.createEvent
|
||||
event = document.createEvent "HTMLEvents"
|
||||
event.initEvent eventName, true, true
|
||||
else
|
||||
event = document.createEventObject()
|
||||
event.eventType = eventName
|
||||
event.eventName = eventName
|
||||
if document.createEvent
|
||||
el.dispatchEvent(event)
|
||||
else
|
||||
el.fireEvent("on" + event.eventType, event)
|
||||
|
||||
jasmine.stubbedCaption =
|
||||
start: [0, 10000, 20000, 30000]
|
||||
text: ['Caption at 0', 'Caption at 10000', 'Caption at 20000', 'Caption at 30000']
|
||||
|
||||
jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50']
|
||||
|
||||
jasmine.stubRequests = ->
|
||||
spyOn($, 'ajax').andCallFake (settings) ->
|
||||
if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/
|
||||
@@ -41,9 +56,12 @@ jasmine.stubRequests = ->
|
||||
throw "External request attempted for #{settings.url}, which is not defined."
|
||||
|
||||
jasmine.stubYoutubePlayer = ->
|
||||
YT.Player = -> jasmine.createSpyObj 'YT.Player', ['cueVideoById', 'getVideoEmbedCode',
|
||||
YT.Player = ->
|
||||
obj = jasmine.createSpyObj 'YT.Player', ['cueVideoById', 'getVideoEmbedCode',
|
||||
'getCurrentTime', 'getPlayerState', 'getVolume', 'setVolume', 'loadVideoById',
|
||||
'playVideo', 'pauseVideo', 'seekTo']
|
||||
'playVideo', 'pauseVideo', 'seekTo', 'getDuration', 'getAvailablePlaybackRates', 'setPlaybackRate']
|
||||
obj['getAvailablePlaybackRates'] = jasmine.createSpy('getAvailablePlaybackRates').andReturn [0.75, 1.0, 1.25, 1.5]
|
||||
obj
|
||||
|
||||
jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
enableParts = [enableParts] unless $.isArray(enableParts)
|
||||
@@ -60,6 +78,21 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
if createPlayer
|
||||
return new VideoPlayer(video: context.video)
|
||||
|
||||
jasmine.stubVideoPlayerAlpha = (context, enableParts, createPlayer=true, html5=false) ->
|
||||
suite = context.suite
|
||||
currentPartName = suite.description while suite = suite.parentSuite
|
||||
if html5 == false
|
||||
loadFixtures 'videoalpha.html'
|
||||
else
|
||||
loadFixtures 'videoalpha_html5.html'
|
||||
jasmine.stubRequests()
|
||||
YT.Player = undefined
|
||||
window.OldVideoPlayerAlpha = undefined
|
||||
context.video = new VideoAlpha '#example', '.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
jasmine.stubYoutubePlayer()
|
||||
if createPlayer
|
||||
return new VideoPlayerAlpha(video: context.video)
|
||||
|
||||
|
||||
# Stub jQuery.cookie
|
||||
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
describe 'VideoAlpha HTML5Video', ->
|
||||
playbackRates = [0.75, 1.0, 1.25, 1.5]
|
||||
STATUS = window.YT.PlayerState
|
||||
playerVars =
|
||||
controls: 0
|
||||
wmode: 'transparent'
|
||||
rel: 0
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
html5: 1
|
||||
file = window.location.href.replace(/\/common(.*)$/, '') + '/test_root/data/videoalpha/gizmo'
|
||||
html5Sources =
|
||||
mp4: "#{file}.mp4"
|
||||
webm: "#{file}.webm"
|
||||
ogg: "#{file}.ogv"
|
||||
onReady = jasmine.createSpy 'onReady'
|
||||
onStateChange = jasmine.createSpy 'onStateChange'
|
||||
|
||||
beforeEach ->
|
||||
loadFixtures 'videoalpha_html5.html'
|
||||
@el = $('#example').find('.video')
|
||||
@player = new window.HTML5Video.Player @el,
|
||||
playerVars: playerVars,
|
||||
videoSources: html5Sources,
|
||||
events:
|
||||
onReady: onReady
|
||||
onStateChange: onStateChange
|
||||
|
||||
@videoEl = @el.find('.video-player video').get(0)
|
||||
|
||||
it 'PlayerState', ->
|
||||
expect(HTML5Video.PlayerState).toEqual STATUS
|
||||
|
||||
describe 'constructor', ->
|
||||
it 'create an html5 video element', ->
|
||||
expect(@el.find('.video-player div')).toContain 'video'
|
||||
|
||||
it 'check if sources are created in correct way', ->
|
||||
sources = $(@videoEl).find('source')
|
||||
videoTypes = []
|
||||
videoSources = []
|
||||
$.each html5Sources, (index, source) ->
|
||||
videoTypes.push index
|
||||
videoSources.push source
|
||||
$.each sources, (index, source) ->
|
||||
s = $(source)
|
||||
expect($.inArray(s.attr('src'), videoSources)).not.toEqual -1
|
||||
expect($.inArray(s.attr('type').replace('video/', ''), videoTypes))
|
||||
.not.toEqual -1
|
||||
|
||||
it 'check if click event is handled on the player', ->
|
||||
expect(@videoEl).toHandle 'click'
|
||||
|
||||
# NOTE: According to
|
||||
#
|
||||
# https://github.com/ariya/phantomjs/wiki/Supported-Web-Standards#unsupported-features
|
||||
#
|
||||
# Video and Audio (due to the nature of PhantomJS) are not supported. After discussion
|
||||
# with William Daly, some tests are disabled (Jenkins uses phantomjs for running tests
|
||||
# and those tests fail).
|
||||
#
|
||||
# During code review, please enable the test below (change "xdescribe" to "describe"
|
||||
# to enable the test).
|
||||
xdescribe 'events:', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(@player, 'callStateChangeCallback').andCallThrough()
|
||||
|
||||
describe 'click', ->
|
||||
describe 'when player is paused', ->
|
||||
beforeEach ->
|
||||
spyOn(@videoEl, 'play').andCallThrough()
|
||||
@player.playerState = STATUS.PAUSED
|
||||
$(@videoEl).trigger('click')
|
||||
|
||||
it 'native play event was called', ->
|
||||
expect(@videoEl.play).toHaveBeenCalled()
|
||||
|
||||
it 'player state was changed', ->
|
||||
expect(@player.playerState).toBe STATUS.PLAYING
|
||||
|
||||
it 'callback was called', ->
|
||||
expect(@player.callStateChangeCallback).toHaveBeenCalled()
|
||||
|
||||
describe 'when player is played', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(@videoEl, 'pause').andCallThrough()
|
||||
@player.playerState = STATUS.PLAYING
|
||||
$(@videoEl).trigger('click')
|
||||
|
||||
it 'native pause event was called', ->
|
||||
expect(@videoEl.pause).toHaveBeenCalled()
|
||||
|
||||
it 'player state was changed', ->
|
||||
expect(@player.playerState).toBe STATUS.PAUSED
|
||||
|
||||
it 'callback was called', ->
|
||||
expect(@player.callStateChangeCallback).toHaveBeenCalled()
|
||||
|
||||
describe 'play', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(@videoEl, 'play').andCallThrough()
|
||||
@player.playerState = STATUS.PAUSED
|
||||
@videoEl.play()
|
||||
|
||||
it 'native event was called', ->
|
||||
expect(@videoEl.play).toHaveBeenCalled()
|
||||
|
||||
it 'player state was changed', ->
|
||||
waitsFor ( ->
|
||||
@player.playerState != HTML5Video.PlayerState.PAUSED
|
||||
), 'Player state should be changed', 1000
|
||||
|
||||
runs ->
|
||||
expect(@player.playerState).toBe STATUS.PLAYING
|
||||
|
||||
it 'callback was called', ->
|
||||
waitsFor ( ->
|
||||
@player.playerState != STATUS.PAUSED
|
||||
), 'Player state should be changed', 1000
|
||||
|
||||
runs ->
|
||||
expect(@player.callStateChangeCallback).toHaveBeenCalled()
|
||||
|
||||
describe 'pause', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(@videoEl, 'pause').andCallThrough()
|
||||
@videoEl.play()
|
||||
@videoEl.pause()
|
||||
|
||||
it 'native event was called', ->
|
||||
expect(@videoEl.pause).toHaveBeenCalled()
|
||||
|
||||
it 'player state was changed', ->
|
||||
waitsFor ( ->
|
||||
@player.playerState != STATUS.UNSTARTED
|
||||
), 'Player state should be changed', 1000
|
||||
|
||||
runs ->
|
||||
expect(@player.playerState).toBe STATUS.PAUSED
|
||||
|
||||
it 'callback was called', ->
|
||||
waitsFor ( ->
|
||||
@player.playerState != HTML5Video.PlayerState.UNSTARTED
|
||||
), 'Player state should be changed', 1000
|
||||
|
||||
runs ->
|
||||
expect(@player.callStateChangeCallback).toHaveBeenCalled()
|
||||
|
||||
describe 'canplay', ->
|
||||
|
||||
beforeEach ->
|
||||
waitsFor ( ->
|
||||
@player.playerState != STATUS.UNSTARTED
|
||||
), 'Video cannot be played', 1000
|
||||
|
||||
it 'player state was changed', ->
|
||||
runs ->
|
||||
expect(@player.playerState).toBe STATUS.PAUSED
|
||||
|
||||
it 'end property was defined', ->
|
||||
runs ->
|
||||
expect(@player.end).not.toBeNull()
|
||||
|
||||
it 'start position was defined', ->
|
||||
runs ->
|
||||
expect(@videoEl.currentTime).toBe(@player.start)
|
||||
|
||||
it 'callback was called', ->
|
||||
runs ->
|
||||
expect(@player.config.events.onReady).toHaveBeenCalled()
|
||||
|
||||
describe 'ended', ->
|
||||
beforeEach ->
|
||||
waitsFor ( ->
|
||||
@player.playerState != STATUS.UNSTARTED
|
||||
), 'Video cannot be played', 1000
|
||||
|
||||
it 'player state was changed', ->
|
||||
runs ->
|
||||
jasmine.fireEvent @videoEl, "ended"
|
||||
expect(@player.playerState).toBe STATUS.ENDED
|
||||
|
||||
it 'callback was called', ->
|
||||
jasmine.fireEvent @videoEl, "ended"
|
||||
expect(@player.callStateChangeCallback).toHaveBeenCalled()
|
||||
|
||||
describe 'timeupdate', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(@videoEl, 'pause').andCallThrough()
|
||||
waitsFor ( ->
|
||||
@player.playerState != STATUS.UNSTARTED
|
||||
), 'Video cannot be played', 1000
|
||||
|
||||
it 'player should be paused', ->
|
||||
runs ->
|
||||
@player.end = 3
|
||||
@videoEl.currentTime = 5
|
||||
jasmine.fireEvent @videoEl, "timeupdate"
|
||||
expect(@videoEl.pause).toHaveBeenCalled()
|
||||
|
||||
it 'end param should be re-defined', ->
|
||||
runs ->
|
||||
@player.end = 3
|
||||
@videoEl.currentTime = 5
|
||||
jasmine.fireEvent @videoEl, "timeupdate"
|
||||
expect(@player.end).toBe @videoEl.duration
|
||||
|
||||
# NOTE: According to
|
||||
#
|
||||
# https://github.com/ariya/phantomjs/wiki/Supported-Web-Standards#unsupported-features
|
||||
#
|
||||
# Video and Audio (due to the nature of PhantomJS) are not supported. After discussion
|
||||
# with William Daly, some tests are disabled (Jenkins uses phantomjs for running tests
|
||||
# and those tests fail).
|
||||
#
|
||||
# During code review, please enable the test below (change "xdescribe" to "describe"
|
||||
# to enable the test).
|
||||
xdescribe 'methods:', ->
|
||||
|
||||
beforeEach ->
|
||||
waitsFor ( ->
|
||||
@volume = @videoEl.volume
|
||||
@seek = @videoEl.currentTime
|
||||
@player.playerState == STATUS.PAUSED
|
||||
), 'Video cannot be played', 1000
|
||||
|
||||
|
||||
it 'pauseVideo', ->
|
||||
spyOn(@videoEl, 'pause').andCallThrough()
|
||||
@player.pauseVideo()
|
||||
expect(@videoEl.pause).toHaveBeenCalled()
|
||||
|
||||
describe 'seekTo', ->
|
||||
|
||||
it 'set new correct value', ->
|
||||
runs ->
|
||||
@player.seekTo(2)
|
||||
expect(@videoEl.currentTime).toBe 2
|
||||
|
||||
it 'set new inccorrect values', ->
|
||||
runs ->
|
||||
@player.seekTo(-50)
|
||||
expect(@videoEl.currentTime).toBe @seek
|
||||
@player.seekTo('5')
|
||||
expect(@videoEl.currentTime).toBe @seek
|
||||
@player.seekTo(500000)
|
||||
expect(@videoEl.currentTime).toBe @seek
|
||||
|
||||
describe 'setVolume', ->
|
||||
|
||||
it 'set new correct value', ->
|
||||
runs ->
|
||||
@player.setVolume(50)
|
||||
expect(@videoEl.volume).toBe 50*0.01
|
||||
|
||||
it 'set new inccorrect values', ->
|
||||
runs ->
|
||||
@player.setVolume(-50)
|
||||
expect(@videoEl.volume).toBe @volume
|
||||
@player.setVolume('5')
|
||||
expect(@videoEl.volume).toBe @volume
|
||||
@player.setVolume(500000)
|
||||
expect(@videoEl.volume).toBe @volume
|
||||
|
||||
it 'getCurrentTime', ->
|
||||
runs ->
|
||||
@videoEl.currentTime = 3
|
||||
expect(@player.getCurrentTime()).toBe @videoEl.currentTime
|
||||
|
||||
it 'playVideo', ->
|
||||
runs ->
|
||||
spyOn(@videoEl, 'play').andCallThrough()
|
||||
@player.playVideo()
|
||||
expect(@videoEl.play).toHaveBeenCalled()
|
||||
|
||||
it 'getPlayerState', ->
|
||||
runs ->
|
||||
@player.playerState = STATUS.PLAYING
|
||||
expect(@player.getPlayerState()).toBe STATUS.PLAYING
|
||||
@player.playerState = STATUS.ENDED
|
||||
expect(@player.getPlayerState()).toBe STATUS.ENDED
|
||||
|
||||
it 'getVolume', ->
|
||||
runs ->
|
||||
@volume = @videoEl.volume = 0.5
|
||||
expect(@player.getVolume()).toBe @volume
|
||||
|
||||
it 'getDuration', ->
|
||||
runs ->
|
||||
@duration = @videoEl.duration
|
||||
expect(@player.getDuration()).toBe @duration
|
||||
|
||||
describe 'setPlaybackRate', ->
|
||||
it 'set a correct value', ->
|
||||
@playbackRate = 1.5
|
||||
@player.setPlaybackRate @playbackRate
|
||||
expect(@videoEl.playbackRate).toBe @playbackRate
|
||||
|
||||
it 'set NaN value', ->
|
||||
@playbackRate = NaN
|
||||
@player.setPlaybackRate @playbackRate
|
||||
expect(@videoEl.playbackRate).toBe 1.0
|
||||
|
||||
it 'getAvailablePlaybackRates', ->
|
||||
expect(@player.getAvailablePlaybackRates()).toEqual playbackRates
|
||||
@@ -0,0 +1,373 @@
|
||||
describe 'VideoCaptionAlpha', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(VideoCaptionAlpha.prototype, 'fetchCaption').andCallThrough()
|
||||
spyOn($, 'ajaxWithPrefix').andCallThrough()
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
|
||||
afterEach ->
|
||||
YT.Player = undefined
|
||||
$.fn.scrollTo.reset()
|
||||
$('.subtitles').remove()
|
||||
|
||||
describe 'constructor', ->
|
||||
|
||||
describe 'always', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'set the youtube id', ->
|
||||
expect(@caption.youtubeId).toEqual 'normalSpeedYoutubeId'
|
||||
|
||||
it 'create the caption element', ->
|
||||
expect($('.video')).toContain 'ol.subtitles'
|
||||
|
||||
it 'add caption control to video player', ->
|
||||
expect($('.video')).toContain 'a.hide-subtitles'
|
||||
|
||||
it 'fetch the caption', ->
|
||||
expect(@caption.loaded).toBeTruthy()
|
||||
expect(@caption.fetchCaption).toHaveBeenCalled()
|
||||
expect($.ajaxWithPrefix).toHaveBeenCalledWith
|
||||
url: @caption.captionURL()
|
||||
notifyOnError: false
|
||||
success: jasmine.any(Function)
|
||||
|
||||
it 'bind window resize event', ->
|
||||
expect($(window)).toHandleWith 'resize', @caption.resize
|
||||
|
||||
it 'bind the hide caption button', ->
|
||||
expect($('.hide-subtitles')).toHandleWith 'click', @caption.toggle
|
||||
|
||||
it 'bind the mouse movement', ->
|
||||
expect($('.subtitles')).toHandleWith 'mouseover', @caption.onMouseEnter
|
||||
expect($('.subtitles')).toHandleWith 'mouseout', @caption.onMouseLeave
|
||||
expect($('.subtitles')).toHandleWith 'mousemove', @caption.onMovement
|
||||
expect($('.subtitles')).toHandleWith 'mousewheel', @caption.onMovement
|
||||
expect($('.subtitles')).toHandleWith 'DOMMouseScroll', @caption.onMovement
|
||||
|
||||
describe 'when on a non touch-based device', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'render the caption', ->
|
||||
captionsData = jasmine.stubbedCaption
|
||||
$('.subtitles li[data-index]').each (index, link) =>
|
||||
expect($(link)).toHaveData 'index', index
|
||||
expect($(link)).toHaveData 'start', captionsData.start[index]
|
||||
expect($(link)).toHaveText captionsData.text[index]
|
||||
|
||||
it 'add a padding element to caption', ->
|
||||
expect($('.subtitles li:first')).toBe '.spacing'
|
||||
expect($('.subtitles li:last')).toBe '.spacing'
|
||||
|
||||
it 'bind all the caption link', ->
|
||||
$('.subtitles li[data-index]').each (index, link) =>
|
||||
expect($(link)).toHandleWith 'click', @caption.seekPlayer
|
||||
|
||||
it 'set rendered to true', ->
|
||||
expect(@caption.rendered).toBeTruthy()
|
||||
|
||||
describe 'when on a touch-based device', ->
|
||||
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'show explaination message', ->
|
||||
expect($('.subtitles li')).toHaveHtml "Caption will be displayed when you start playing the video."
|
||||
|
||||
it 'does not set rendered to true', ->
|
||||
expect(@caption.rendered).toBeFalsy()
|
||||
|
||||
describe 'mouse movement', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
window.setTimeout.andReturn(100)
|
||||
spyOn window, 'clearTimeout'
|
||||
|
||||
describe 'when cursor is outside of the caption box', ->
|
||||
|
||||
beforeEach ->
|
||||
$(window).trigger jQuery.Event 'mousemove'
|
||||
|
||||
it 'does not set freezing timeout', ->
|
||||
expect(@caption.frozen).toBeFalsy()
|
||||
|
||||
describe 'when cursor is in the caption box', ->
|
||||
|
||||
beforeEach ->
|
||||
$('.subtitles').trigger jQuery.Event 'mouseenter'
|
||||
|
||||
it 'set the freezing timeout', ->
|
||||
expect(@caption.frozen).toEqual 100
|
||||
|
||||
describe 'when the cursor is moving', ->
|
||||
beforeEach ->
|
||||
$('.subtitles').trigger jQuery.Event 'mousemove'
|
||||
|
||||
it 'reset the freezing timeout', ->
|
||||
expect(window.clearTimeout).toHaveBeenCalledWith 100
|
||||
|
||||
describe 'when the mouse is scrolling', ->
|
||||
beforeEach ->
|
||||
$('.subtitles').trigger jQuery.Event 'mousewheel'
|
||||
|
||||
it 'reset the freezing timeout', ->
|
||||
expect(window.clearTimeout).toHaveBeenCalledWith 100
|
||||
|
||||
describe 'when cursor is moving out of the caption box', ->
|
||||
beforeEach ->
|
||||
@caption.frozen = 100
|
||||
$.fn.scrollTo.reset()
|
||||
|
||||
describe 'always', ->
|
||||
beforeEach ->
|
||||
$('.subtitles').trigger jQuery.Event 'mouseout'
|
||||
|
||||
it 'reset the freezing timeout', ->
|
||||
expect(window.clearTimeout).toHaveBeenCalledWith 100
|
||||
|
||||
it 'unfreeze the caption', ->
|
||||
expect(@caption.frozen).toBeNull()
|
||||
|
||||
describe 'when the player is playing', ->
|
||||
beforeEach ->
|
||||
@caption.playing = true
|
||||
$('.subtitles li[data-index]:first').addClass 'current'
|
||||
$('.subtitles').trigger jQuery.Event 'mouseout'
|
||||
|
||||
it 'scroll the caption', ->
|
||||
expect($.fn.scrollTo).toHaveBeenCalled()
|
||||
|
||||
describe 'when the player is not playing', ->
|
||||
beforeEach ->
|
||||
@caption.playing = false
|
||||
$('.subtitles').trigger jQuery.Event 'mouseout'
|
||||
|
||||
it 'does not scroll the caption', ->
|
||||
expect($.fn.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
describe 'search', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'return a correct caption index', ->
|
||||
expect(@caption.search(0)).toEqual 0
|
||||
expect(@caption.search(9999)).toEqual 0
|
||||
expect(@caption.search(10000)).toEqual 1
|
||||
expect(@caption.search(15000)).toEqual 1
|
||||
expect(@caption.search(30000)).toEqual 3
|
||||
expect(@caption.search(30001)).toEqual 3
|
||||
|
||||
describe 'play', ->
|
||||
describe 'when the caption was not rendered', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
@caption.play()
|
||||
|
||||
it 'render the caption', ->
|
||||
captionsData = jasmine.stubbedCaption
|
||||
$('.subtitles li[data-index]').each (index, link) =>
|
||||
expect($(link)).toHaveData 'index', index
|
||||
expect($(link)).toHaveData 'start', captionsData.start[index]
|
||||
expect($(link)).toHaveText captionsData.text[index]
|
||||
|
||||
it 'add a padding element to caption', ->
|
||||
expect($('.subtitles li:first')).toBe '.spacing'
|
||||
expect($('.subtitles li:last')).toBe '.spacing'
|
||||
|
||||
it 'bind all the caption link', ->
|
||||
$('.subtitles li[data-index]').each (index, link) =>
|
||||
expect($(link)).toHandleWith 'click', @caption.seekPlayer
|
||||
|
||||
it 'set rendered to true', ->
|
||||
expect(@caption.rendered).toBeTruthy()
|
||||
|
||||
it 'set playing to true', ->
|
||||
expect(@caption.playing).toBeTruthy()
|
||||
|
||||
describe 'pause', ->
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
@caption.playing = true
|
||||
@caption.pause()
|
||||
|
||||
it 'set playing to false', ->
|
||||
expect(@caption.playing).toBeFalsy()
|
||||
|
||||
describe 'updatePlayTime', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
|
||||
describe 'when the video speed is 1.0x', ->
|
||||
beforeEach ->
|
||||
@caption.currentSpeed = '1.0'
|
||||
@caption.updatePlayTime 25.000
|
||||
|
||||
it 'search the caption based on time', ->
|
||||
expect(@caption.currentIndex).toEqual 2
|
||||
|
||||
describe 'when the video speed is not 1.0x', ->
|
||||
beforeEach ->
|
||||
@caption.currentSpeed = '0.75'
|
||||
@caption.updatePlayTime 25.000
|
||||
|
||||
it 'search the caption based on 1.0x speed', ->
|
||||
expect(@caption.currentIndex).toEqual 1
|
||||
|
||||
describe 'when the index is not the same', ->
|
||||
beforeEach ->
|
||||
@caption.currentIndex = 1
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
@caption.updatePlayTime 25.000
|
||||
|
||||
it 'deactivate the previous caption', ->
|
||||
expect($('.subtitles li[data-index=1]')).not.toHaveClass 'current'
|
||||
|
||||
it 'activate new caption', ->
|
||||
expect($('.subtitles li[data-index=2]')).toHaveClass 'current'
|
||||
|
||||
it 'save new index', ->
|
||||
expect(@caption.currentIndex).toEqual 2
|
||||
|
||||
it 'scroll caption to new position', ->
|
||||
expect($.fn.scrollTo).toHaveBeenCalled()
|
||||
|
||||
describe 'when the index is the same', ->
|
||||
beforeEach ->
|
||||
@caption.currentIndex = 1
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
@caption.updatePlayTime 15.000
|
||||
|
||||
it 'does not change current subtitle', ->
|
||||
expect($('.subtitles li[data-index=1]')).toHaveClass 'current'
|
||||
|
||||
describe 'resize', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
@caption.resize()
|
||||
|
||||
it 'set the height of caption container', ->
|
||||
expect(parseInt($('.subtitles').css('maxHeight'))).toBeCloseTo $('.video-wrapper').height(), 2
|
||||
|
||||
it 'set the height of caption spacing', ->
|
||||
firstSpacing = Math.abs(parseInt($('.subtitles .spacing:first').css('height')))
|
||||
lastSpacing = Math.abs(parseInt($('.subtitles .spacing:last').css('height')))
|
||||
|
||||
expect(firstSpacing - @caption.topSpacingHeight()).toBeLessThan 1
|
||||
expect(lastSpacing - @caption.bottomSpacingHeight()).toBeLessThan 1
|
||||
|
||||
it 'scroll caption to new position', ->
|
||||
expect($.fn.scrollTo).toHaveBeenCalled()
|
||||
|
||||
describe 'scrollCaption', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
|
||||
describe 'when frozen', ->
|
||||
beforeEach ->
|
||||
@caption.frozen = true
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
@caption.scrollCaption()
|
||||
|
||||
it 'does not scroll the caption', ->
|
||||
expect($.fn.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
describe 'when not frozen', ->
|
||||
beforeEach ->
|
||||
@caption.frozen = false
|
||||
|
||||
describe 'when there is no current caption', ->
|
||||
beforeEach ->
|
||||
@caption.scrollCaption()
|
||||
|
||||
it 'does not scroll the caption', ->
|
||||
expect($.fn.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
describe 'when there is a current caption', ->
|
||||
beforeEach ->
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
@caption.scrollCaption()
|
||||
|
||||
it 'scroll to current caption', ->
|
||||
offset = -0.5 * ($('.video-wrapper').height() - $('.subtitles .current:first').height())
|
||||
|
||||
expect($.fn.scrollTo).toHaveBeenCalledWith $('.subtitles .current:first', @caption.el),
|
||||
offset: offset
|
||||
|
||||
describe 'seekPlayer', ->
|
||||
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
@caption = @player.caption
|
||||
$(@caption).bind 'seek', (event, time) => @time = time
|
||||
|
||||
describe 'when the video speed is 1.0x', ->
|
||||
beforeEach ->
|
||||
@caption.currentSpeed = '1.0'
|
||||
$('.subtitles li[data-start="30000"]').trigger('click')
|
||||
|
||||
it 'trigger seek event with the correct time', ->
|
||||
expect(@player.currentTime).toEqual 30.000
|
||||
|
||||
describe 'when the video speed is not 1.0x', ->
|
||||
beforeEach ->
|
||||
@caption.currentSpeed = '0.75'
|
||||
$('.subtitles li[data-start="30000"]').trigger('click')
|
||||
|
||||
it 'trigger seek event with the correct time', ->
|
||||
expect(@player.currentTime).toEqual 40.000
|
||||
|
||||
describe 'toggle', ->
|
||||
beforeEach ->
|
||||
@player = jasmine.stubVideoPlayerAlpha @
|
||||
spyOn @video, 'log'
|
||||
@caption = @player.caption
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
|
||||
describe 'when the caption is visible', ->
|
||||
beforeEach ->
|
||||
@caption.el.removeClass 'closed'
|
||||
@caption.toggle jQuery.Event('click')
|
||||
|
||||
it 'log the hide_transcript event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'hide_transcript',
|
||||
currentTime: @player.currentTime
|
||||
|
||||
it 'hide the caption', ->
|
||||
expect(@caption.el).toHaveClass 'closed'
|
||||
|
||||
describe 'when the caption is hidden', ->
|
||||
beforeEach ->
|
||||
@caption.el.addClass 'closed'
|
||||
@caption.toggle jQuery.Event('click')
|
||||
|
||||
it 'log the show_transcript event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'show_transcript',
|
||||
currentTime: @player.currentTime
|
||||
|
||||
it 'show the caption', ->
|
||||
expect(@caption.el).not.toHaveClass 'closed'
|
||||
|
||||
it 'scroll the caption', ->
|
||||
expect($.fn.scrollTo).toHaveBeenCalled()
|
||||
@@ -0,0 +1,103 @@
|
||||
describe 'VideoControlAlpha', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
loadFixtures 'videoalpha.html'
|
||||
$('.video-controls').html ''
|
||||
|
||||
describe 'constructor', ->
|
||||
|
||||
it 'render the video controls', ->
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
expect($('.video-controls')).toContain
|
||||
['.slider', 'ul.vcr', 'a.play', '.vidtime', '.add-fullscreen'].join(',')
|
||||
expect($('.video-controls').find('.vidtime')).toHaveText '0:00 / 0:00'
|
||||
|
||||
it 'bind the playback button', ->
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
expect($('.video_control')).toHandleWith 'click', @control.togglePlayback
|
||||
|
||||
describe 'when on a touch based device', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
|
||||
it 'does not add the play class to video control', ->
|
||||
expect($('.video_control')).not.toHaveClass 'play'
|
||||
expect($('.video_control')).not.toHaveHtml 'Play'
|
||||
|
||||
|
||||
describe 'when on a non-touch based device', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
|
||||
it 'add the play class to video control', ->
|
||||
expect($('.video_control')).toHaveClass 'play'
|
||||
expect($('.video_control')).toHaveHtml 'Play'
|
||||
|
||||
describe 'play', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
@control.play()
|
||||
|
||||
it 'switch playback button to play state', ->
|
||||
expect($('.video_control')).not.toHaveClass 'play'
|
||||
expect($('.video_control')).toHaveClass 'pause'
|
||||
expect($('.video_control')).toHaveHtml 'Pause'
|
||||
|
||||
describe 'pause', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
@control.pause()
|
||||
|
||||
it 'switch playback button to pause state', ->
|
||||
expect($('.video_control')).not.toHaveClass 'pause'
|
||||
expect($('.video_control')).toHaveClass 'play'
|
||||
expect($('.video_control')).toHaveHtml 'Play'
|
||||
|
||||
describe 'togglePlayback', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new window.VideoControlAlpha(el: $('.video-controls'))
|
||||
|
||||
describe 'when the control does not have play or pause class', ->
|
||||
beforeEach ->
|
||||
$('.video_control').removeClass('play').removeClass('pause')
|
||||
|
||||
describe 'when the video is playing', ->
|
||||
beforeEach ->
|
||||
$('.video_control').addClass('play')
|
||||
spyOnEvent @control, 'pause'
|
||||
@control.togglePlayback jQuery.Event('click')
|
||||
|
||||
it 'does not trigger the pause event', ->
|
||||
expect('pause').not.toHaveBeenTriggeredOn @control
|
||||
|
||||
describe 'when the video is paused', ->
|
||||
beforeEach ->
|
||||
$('.video_control').addClass('pause')
|
||||
spyOnEvent @control, 'play'
|
||||
@control.togglePlayback jQuery.Event('click')
|
||||
|
||||
it 'does not trigger the play event', ->
|
||||
expect('play').not.toHaveBeenTriggeredOn @control
|
||||
|
||||
describe 'when the video is playing', ->
|
||||
beforeEach ->
|
||||
spyOnEvent @control, 'pause'
|
||||
$('.video_control').addClass 'pause'
|
||||
@control.togglePlayback jQuery.Event('click')
|
||||
|
||||
it 'trigger the pause event', ->
|
||||
expect('pause').toHaveBeenTriggeredOn @control
|
||||
|
||||
describe 'when the video is paused', ->
|
||||
beforeEach ->
|
||||
spyOnEvent @control, 'play'
|
||||
$('.video_control').addClass 'play'
|
||||
@control.togglePlayback jQuery.Event('click')
|
||||
|
||||
it 'trigger the play event', ->
|
||||
expect('play').toHaveBeenTriggeredOn @control
|
||||
@@ -0,0 +1,561 @@
|
||||
describe 'VideoPlayerAlpha', ->
|
||||
playerVars =
|
||||
controls: 0
|
||||
wmode: 'transparent'
|
||||
rel: 0
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
html5: 1
|
||||
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
# It tries to call methods of VideoProgressSlider on Spy
|
||||
for part in ['VideoCaptionAlpha', 'VideoSpeedControlAlpha', 'VideoVolumeControlAlpha', 'VideoProgressSliderAlpha', 'VideoControlAlpha']
|
||||
spyOn(window[part].prototype, 'initialize').andCallThrough()
|
||||
|
||||
|
||||
afterEach ->
|
||||
YT.Player = undefined
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
$.fn.qtip.andCallFake ->
|
||||
$(this).data('qtip', true)
|
||||
|
||||
describe 'always', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
|
||||
it 'instanticate current time to zero', ->
|
||||
expect(@player.currentTime).toEqual 0
|
||||
|
||||
it 'set the element', ->
|
||||
expect(@player.el).toHaveId 'video_id'
|
||||
|
||||
it 'create video control', ->
|
||||
expect(window.VideoControlAlpha.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.control).toBeDefined()
|
||||
expect(@player.control.el).toBe $('.video-controls', @player.el)
|
||||
|
||||
it 'create video caption', ->
|
||||
expect(window.VideoCaptionAlpha.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.caption).toBeDefined()
|
||||
expect(@player.caption.el).toBe @player.el
|
||||
expect(@player.caption.youtubeId).toEqual 'normalSpeedYoutubeId'
|
||||
expect(@player.caption.currentSpeed).toEqual '1.0'
|
||||
expect(@player.caption.captionAssetPath).toEqual '/static/subs/'
|
||||
|
||||
it 'create video speed control', ->
|
||||
expect(window.VideoSpeedControlAlpha.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.speedControl).toBeDefined()
|
||||
expect(@player.speedControl.el).toBe $('.secondary-controls', @player.el)
|
||||
expect(@player.speedControl.speeds).toEqual ['0.75', '1.0']
|
||||
expect(@player.speedControl.currentSpeed).toEqual '1.0'
|
||||
|
||||
it 'create video progress slider', ->
|
||||
expect(window.VideoSpeedControlAlpha.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.progressSlider).toBeDefined()
|
||||
expect(@player.progressSlider.el).toBe $('.slider', @player.el)
|
||||
|
||||
it 'bind to video control play event', ->
|
||||
expect($(@player.control)).toHandleWith 'play', @player.play
|
||||
|
||||
it 'bind to video control pause event', ->
|
||||
expect($(@player.control)).toHandleWith 'pause', @player.pause
|
||||
|
||||
it 'bind to video caption seek event', ->
|
||||
expect($(@player.caption)).toHandleWith 'caption_seek', @player.onSeek
|
||||
|
||||
it 'bind to video speed control speedChange event', ->
|
||||
expect($(@player.speedControl)).toHandleWith 'speedChange', @player.onSpeedChange
|
||||
|
||||
it 'bind to video progress slider seek event', ->
|
||||
expect($(@player.progressSlider)).toHandleWith 'slide_seek', @player.onSeek
|
||||
|
||||
it 'bind to video volume control volumeChange event', ->
|
||||
expect($(@player.volumeControl)).toHandleWith 'volumeChange', @player.onVolumeChange
|
||||
|
||||
it 'bind to key press', ->
|
||||
expect($(document.documentElement)).toHandleWith 'keyup', @player.bindExitFullScreen
|
||||
|
||||
it 'bind to fullscreen switching button', ->
|
||||
expect($('.add-fullscreen')).toHandleWith 'click', @player.toggleFullScreen
|
||||
|
||||
it 'create Youtube player', ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
spyOn YT, 'Player'
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
expect(YT.Player).toHaveBeenCalledWith('id', {
|
||||
playerVars: playerVars
|
||||
videoId: 'normalSpeedYoutubeId'
|
||||
events:
|
||||
onReady: @player.onReady
|
||||
onStateChange: @player.onStateChange
|
||||
onPlaybackQualityChange: @player.onPlaybackQualityChange
|
||||
})
|
||||
|
||||
it 'create HTML5 player', ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false, true
|
||||
spyOn HTML5Video, 'Player'
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
expect(HTML5Video.Player).toHaveBeenCalledWith @video.el,
|
||||
playerVars: playerVars
|
||||
videoSources: @video.html5Sources
|
||||
events:
|
||||
onReady: @player.onReady
|
||||
onStateChange: @player.onStateChange
|
||||
|
||||
describe 'when not on a touch based device', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
$('.add-fullscreen, .hide-subtitles').removeData 'qtip'
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
|
||||
it 'add the tooltip to fullscreen and subtitle button', ->
|
||||
expect($('.add-fullscreen')).toHaveData 'qtip'
|
||||
expect($('.hide-subtitles')).toHaveData 'qtip'
|
||||
|
||||
it 'create video volume control', ->
|
||||
expect(window.VideoVolumeControlAlpha.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.volumeControl).toBeDefined()
|
||||
expect(@player.volumeControl.el).toBe $('.secondary-controls', @player.el)
|
||||
|
||||
describe 'when on a touch based device', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
$('.add-fullscreen, .hide-subtitles').removeData 'qtip'
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
|
||||
it 'does not add the tooltip to fullscreen and subtitle button', ->
|
||||
expect($('.add-fullscreen')).not.toHaveData 'qtip'
|
||||
expect($('.hide-subtitles')).not.toHaveData 'qtip'
|
||||
|
||||
it 'does not create video volume control', ->
|
||||
expect(window.VideoVolumeControlAlpha.prototype.initialize).not.toHaveBeenCalled()
|
||||
expect(@player.volumeControl).not.toBeDefined()
|
||||
|
||||
describe 'onReady', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
spyOn @video, 'log'
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@video.embed()
|
||||
@player = @video.player
|
||||
spyOnEvent @player, 'ready'
|
||||
spyOnEvent @player, 'updatePlayTime'
|
||||
@player.onReady()
|
||||
|
||||
it 'log the load_video event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'load_video'
|
||||
|
||||
describe 'when not on a touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn @player, 'play'
|
||||
@player.onReady()
|
||||
|
||||
it 'autoplay the first video', ->
|
||||
expect(@player.play).toHaveBeenCalled()
|
||||
|
||||
describe 'when on a touch based device', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
spyOn @player, 'play'
|
||||
@player.onReady()
|
||||
|
||||
it 'does not autoplay the first video', ->
|
||||
expect(@player.play).not.toHaveBeenCalled()
|
||||
|
||||
describe 'onStateChange', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
|
||||
describe 'when the video is unstarted', ->
|
||||
beforeEach ->
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn @player.control, 'pause'
|
||||
@player.caption.pause = jasmine.createSpy('VideoCaptionAlpha.pause')
|
||||
@player.onStateChange data: YT.PlayerState.UNSTARTED
|
||||
|
||||
it 'pause the video control', ->
|
||||
expect(@player.control.pause).toHaveBeenCalled()
|
||||
|
||||
it 'pause the video caption', ->
|
||||
expect(@player.caption.pause).toHaveBeenCalled()
|
||||
|
||||
describe 'when the video is playing', ->
|
||||
beforeEach ->
|
||||
@anotherPlayer = jasmine.createSpyObj 'AnotherPlayer', ['onPause']
|
||||
window.OldVideoPlayerAlpha = @anotherPlayer
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn @video, 'log'
|
||||
spyOn(window, 'setInterval').andReturn 100
|
||||
spyOn @player.control, 'play'
|
||||
@player.caption.play = jasmine.createSpy('VideoCaptionAlpha.play')
|
||||
@player.progressSlider.play = jasmine.createSpy('VideoProgressSliderAlpha.play')
|
||||
@player.player.getVideoEmbedCode.andReturn 'embedCode'
|
||||
@player.onStateChange data: YT.PlayerState.PLAYING
|
||||
|
||||
it 'log the play_video event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'play_video', {currentTime: 0}
|
||||
|
||||
it 'pause other video player', ->
|
||||
expect(@anotherPlayer.onPause).toHaveBeenCalled()
|
||||
|
||||
it 'set current video player as active player', ->
|
||||
expect(window.OldVideoPlayerAlpha).toEqual @player
|
||||
|
||||
it 'set update interval', ->
|
||||
expect(window.setInterval).toHaveBeenCalledWith @player.update, 200
|
||||
expect(@player.player.interval).toEqual 100
|
||||
|
||||
it 'play the video control', ->
|
||||
expect(@player.control.play).toHaveBeenCalled()
|
||||
|
||||
it 'play the video caption', ->
|
||||
expect(@player.caption.play).toHaveBeenCalled()
|
||||
|
||||
it 'play the video progress slider', ->
|
||||
expect(@player.progressSlider.play).toHaveBeenCalled()
|
||||
|
||||
describe 'when the video is paused', ->
|
||||
beforeEach ->
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn @video, 'log'
|
||||
spyOn window, 'clearInterval'
|
||||
spyOn @player.control, 'pause'
|
||||
@player.caption.pause = jasmine.createSpy('VideoCaptionAlpha.pause')
|
||||
@player.player.interval = 100
|
||||
@player.player.getVideoEmbedCode.andReturn 'embedCode'
|
||||
@player.onStateChange data: YT.PlayerState.PAUSED
|
||||
|
||||
it 'log the pause_video event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'pause_video', {currentTime: 0}
|
||||
|
||||
it 'clear update interval', ->
|
||||
expect(window.clearInterval).toHaveBeenCalledWith 100
|
||||
expect(@player.player.interval).toBeNull()
|
||||
|
||||
it 'pause the video control', ->
|
||||
expect(@player.control.pause).toHaveBeenCalled()
|
||||
|
||||
it 'pause the video caption', ->
|
||||
expect(@player.caption.pause).toHaveBeenCalled()
|
||||
|
||||
describe 'when the video is ended', ->
|
||||
beforeEach ->
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn @player.control, 'pause'
|
||||
@player.caption.pause = jasmine.createSpy('VideoCaptionAlpha.pause')
|
||||
@player.onStateChange data: YT.PlayerState.ENDED
|
||||
|
||||
it 'pause the video control', ->
|
||||
expect(@player.control.pause).toHaveBeenCalled()
|
||||
|
||||
it 'pause the video caption', ->
|
||||
expect(@player.caption.pause).toHaveBeenCalled()
|
||||
|
||||
describe 'onSeek', ->
|
||||
conf = [{
|
||||
desc : 'check if seek_video is logged with slide_seek type',
|
||||
type: 'slide_seek',
|
||||
obj: 'progressSlider'
|
||||
},{
|
||||
desc : 'check if seek_video is logged with caption_seek type',
|
||||
type: 'caption_seek',
|
||||
obj: 'caption'
|
||||
}]
|
||||
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn window, 'clearInterval'
|
||||
@player.player.interval = 100
|
||||
spyOn @player, 'updatePlayTime'
|
||||
spyOn @video, 'log'
|
||||
|
||||
$.each conf, (key, value) ->
|
||||
it value.desc, ->
|
||||
type = value.type
|
||||
old_time = 0
|
||||
new_time = 60
|
||||
$(@player[value.obj]).trigger value.type, new_time
|
||||
expect(@video.log).toHaveBeenCalledWith 'seek_video',
|
||||
old_time: old_time
|
||||
new_time: new_time
|
||||
type: value.type
|
||||
|
||||
it 'seek the player', ->
|
||||
$(@player.progressSlider).trigger 'slide_seek', 60
|
||||
expect(@player.player.seekTo).toHaveBeenCalledWith 60, true
|
||||
|
||||
it 'call updatePlayTime on player', ->
|
||||
$(@player.progressSlider).trigger 'slide_seek', 60
|
||||
expect(@player.updatePlayTime).toHaveBeenCalledWith 60
|
||||
|
||||
describe 'when the player is playing', ->
|
||||
beforeEach ->
|
||||
$(@player.progressSlider).trigger 'slide_seek', 60
|
||||
@player.player.getPlayerState.andReturn YT.PlayerState.PLAYING
|
||||
@player.onSeek {}, 60
|
||||
|
||||
it 'reset the update interval', ->
|
||||
expect(window.clearInterval).toHaveBeenCalledWith 100
|
||||
|
||||
describe 'when the player is not playing', ->
|
||||
beforeEach ->
|
||||
$(@player.progressSlider).trigger 'slide_seek', 60
|
||||
@player.player.getPlayerState.andReturn YT.PlayerState.PAUSED
|
||||
@player.onSeek {}, 60
|
||||
|
||||
it 'set the current time', ->
|
||||
expect(@player.currentTime).toEqual 60
|
||||
|
||||
describe 'onSpeedChange', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
@player.currentTime = 60
|
||||
spyOn @player, 'updatePlayTime'
|
||||
spyOn(@video, 'setSpeed').andCallThrough()
|
||||
spyOn(@video, 'log')
|
||||
|
||||
describe 'always', ->
|
||||
beforeEach ->
|
||||
@player.onSpeedChange {}, '0.75', false
|
||||
|
||||
it 'check if speed_change_video is logged', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'speed_change_video',
|
||||
currentTime: @player.currentTime
|
||||
old_speed: '1.0'
|
||||
new_speed: '0.75'
|
||||
|
||||
it 'convert the current time to the new speed', ->
|
||||
expect(@player.currentTime).toEqual '80.000'
|
||||
|
||||
it 'set video speed to the new speed', ->
|
||||
expect(@video.setSpeed).toHaveBeenCalledWith '0.75', false
|
||||
|
||||
it 'tell video caption that the speed has changed', ->
|
||||
expect(@player.caption.currentSpeed).toEqual '0.75'
|
||||
|
||||
describe 'when the video is playing', ->
|
||||
beforeEach ->
|
||||
@player.player.getPlayerState.andReturn YT.PlayerState.PLAYING
|
||||
@player.onSpeedChange {}, '0.75'
|
||||
|
||||
it 'load the video', ->
|
||||
expect(@player.player.loadVideoById).toHaveBeenCalledWith 'slowerSpeedYoutubeId', '80.000'
|
||||
|
||||
it 'trigger updatePlayTime event', ->
|
||||
expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000'
|
||||
|
||||
describe 'when the video is not playing', ->
|
||||
beforeEach ->
|
||||
@player.player.getPlayerState.andReturn YT.PlayerState.PAUSED
|
||||
@player.onSpeedChange {}, '0.75'
|
||||
|
||||
it 'cue the video', ->
|
||||
expect(@player.player.cueVideoById).toHaveBeenCalledWith 'slowerSpeedYoutubeId', '80.000'
|
||||
|
||||
it 'trigger updatePlayTime event', ->
|
||||
expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000'
|
||||
|
||||
describe 'onVolumeChange', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
@player.onVolumeChange undefined, 60
|
||||
|
||||
it 'set the volume on player', ->
|
||||
expect(@player.player.setVolume).toHaveBeenCalledWith 60
|
||||
|
||||
describe 'update', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn @player, 'updatePlayTime'
|
||||
|
||||
describe 'when the current time is unavailable from the player', ->
|
||||
beforeEach ->
|
||||
@player.player.getCurrentTime.andReturn undefined
|
||||
@player.update()
|
||||
|
||||
it 'does not trigger updatePlayTime event', ->
|
||||
expect(@player.updatePlayTime).not.toHaveBeenCalled()
|
||||
|
||||
describe 'when the current time is available from the player', ->
|
||||
beforeEach ->
|
||||
@player.player.getCurrentTime.andReturn 60
|
||||
@player.update()
|
||||
|
||||
it 'trigger updatePlayTime event', ->
|
||||
expect(@player.updatePlayTime).toHaveBeenCalledWith(60)
|
||||
|
||||
describe 'updatePlayTime', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn(@video, 'getDuration').andReturn 1800
|
||||
@player.caption.updatePlayTime = jasmine.createSpy('VideoCaptionAlpha.updatePlayTime')
|
||||
@player.progressSlider.updatePlayTime = jasmine.createSpy('VideoProgressSliderAlpha.updatePlayTime')
|
||||
@player.updatePlayTime 60
|
||||
|
||||
it 'update the video playback time', ->
|
||||
expect($('.vidtime')).toHaveHtml '1:00 / 30:00'
|
||||
|
||||
it 'update the playback time on caption', ->
|
||||
expect(@player.caption.updatePlayTime).toHaveBeenCalledWith 60
|
||||
|
||||
it 'update the playback time on progress slider', ->
|
||||
expect(@player.progressSlider.updatePlayTime).toHaveBeenCalledWith 60, 1800
|
||||
|
||||
describe 'toggleFullScreen', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
@player.caption.resize = jasmine.createSpy('VideoCaptionAlpha.resize')
|
||||
|
||||
describe 'when the video player is not full screen', ->
|
||||
beforeEach ->
|
||||
spyOn @video, 'log'
|
||||
@player.el.removeClass 'fullscreen'
|
||||
@player.toggleFullScreen(jQuery.Event("click"))
|
||||
|
||||
it 'log the fullscreen event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'fullscreen',
|
||||
currentTime: @player.currentTime
|
||||
|
||||
it 'replace the full screen button tooltip', ->
|
||||
expect($('.add-fullscreen')).toHaveAttr 'title', 'Exit fill browser'
|
||||
|
||||
it 'add the fullscreen class', ->
|
||||
expect(@player.el).toHaveClass 'fullscreen'
|
||||
|
||||
it 'tell VideoCaption to resize', ->
|
||||
expect(@player.caption.resize).toHaveBeenCalled()
|
||||
|
||||
describe 'when the video player already full screen', ->
|
||||
beforeEach ->
|
||||
spyOn @video, 'log'
|
||||
@player.el.addClass 'fullscreen'
|
||||
@player.toggleFullScreen(jQuery.Event("click"))
|
||||
|
||||
it 'log the not_fullscreen event', ->
|
||||
expect(@video.log).toHaveBeenCalledWith 'not_fullscreen',
|
||||
currentTime: @player.currentTime
|
||||
|
||||
it 'replace the full screen button tooltip', ->
|
||||
expect($('.add-fullscreen')).toHaveAttr 'title', 'Fill browser'
|
||||
|
||||
it 'remove exit full screen button', ->
|
||||
expect(@player.el).not.toContain 'a.exit'
|
||||
|
||||
it 'remove the fullscreen class', ->
|
||||
expect(@player.el).not.toHaveClass 'fullscreen'
|
||||
|
||||
it 'tell VideoCaption to resize', ->
|
||||
expect(@player.caption.resize).toHaveBeenCalled()
|
||||
|
||||
describe 'play', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
|
||||
describe 'when the player is not ready', ->
|
||||
beforeEach ->
|
||||
@player.player.playVideo = undefined
|
||||
@player.play()
|
||||
|
||||
it 'does nothing', ->
|
||||
expect(@player.player.playVideo).toBeUndefined()
|
||||
|
||||
describe 'when the player is ready', ->
|
||||
beforeEach ->
|
||||
@player.player.playVideo.andReturn true
|
||||
@player.play()
|
||||
|
||||
it 'delegate to the Youtube player', ->
|
||||
expect(@player.player.playVideo).toHaveBeenCalled()
|
||||
|
||||
describe 'isPlaying', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
|
||||
describe 'when the video is playing', ->
|
||||
beforeEach ->
|
||||
@player.player.getPlayerState.andReturn YT.PlayerState.PLAYING
|
||||
|
||||
it 'return true', ->
|
||||
expect(@player.isPlaying()).toBeTruthy()
|
||||
|
||||
describe 'when the video is not playing', ->
|
||||
beforeEach ->
|
||||
@player.player.getPlayerState.andReturn YT.PlayerState.PAUSED
|
||||
|
||||
it 'return false', ->
|
||||
expect(@player.isPlaying()).toBeFalsy()
|
||||
|
||||
describe 'pause', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
@player.pause()
|
||||
|
||||
it 'delegate to the Youtube player', ->
|
||||
expect(@player.player.pauseVideo).toHaveBeenCalled()
|
||||
|
||||
describe 'duration', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
spyOn @video, 'getDuration'
|
||||
@player.duration()
|
||||
|
||||
it 'delegate to the video', ->
|
||||
expect(@video.getDuration).toHaveBeenCalled()
|
||||
|
||||
describe 'currentSpeed', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
@video.speed = '3.0'
|
||||
|
||||
it 'delegate to the video', ->
|
||||
expect(@player.currentSpeed()).toEqual '3.0'
|
||||
|
||||
describe 'volume', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayerAlpha @, [], false
|
||||
$('.video').append $('<div class="add-fullscreen" /><div class="hide-subtitles" />')
|
||||
@player = new VideoPlayerAlpha video: @video
|
||||
@player.player.getVolume.andReturn 42
|
||||
|
||||
describe 'without value', ->
|
||||
it 'return current volume', ->
|
||||
expect(@player.volume()).toEqual 42
|
||||
|
||||
describe 'with value', ->
|
||||
it 'set player volume', ->
|
||||
@player.volume(60)
|
||||
expect(@player.player.setVolume).toHaveBeenCalledWith(60)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user