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:
lapentab
2013-06-18 13:41:53 -04:00
206 changed files with 5544 additions and 1415 deletions

View File

@@ -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
View 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.

View File

@@ -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]"`.

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

View File

@@ -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])

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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')

View File

@@ -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")

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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']

View File

@@ -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
},

View File

@@ -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
#####################################################################

View File

@@ -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
View 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()

View File

@@ -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", {

View File

@@ -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');

View File

@@ -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"

View 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
});
}

View File

@@ -14,7 +14,7 @@ body {
color: $gray-d2;
}
body, input {
body, input, button {
font-family: 'Open Sans', sans-serif;
}

View File

@@ -76,6 +76,10 @@ body.course.uploads {
width: 250px;
}
.delete-col {
width: 20px;
}
.embeddable-xml-input {
@include box-shadow(none);
width: 100%;

View File

@@ -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 &amp; 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>.*)$',

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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())

View File

@@ -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,

View File

@@ -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)

View File

@@ -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))

View File

@@ -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.

View File

@@ -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):

View File

@@ -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))

View File

@@ -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 ''

View 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>.*')

View File

@@ -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)

View 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)

View File

@@ -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

View File

@@ -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]

View 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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 +

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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."""

View File

@@ -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>

View File

@@ -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':

View File

@@ -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>

View File

@@ -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

View File

@@ -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':

View File

@@ -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':

View File

@@ -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':

View File

@@ -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':

View File

@@ -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':

View File

@@ -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>

View File

@@ -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/>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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':

View File

@@ -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':

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View 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

View File

@@ -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):

View File

@@ -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);

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View 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>

View 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>

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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