Commit cd064bc0 by Jason Bau

Merge commit '7b074424' into edx-west/release-candidate-20130624-b

Conflicts:
	lms/djangoapps/django_comment_client/utils.py
	lms/envs/common.py
parents 8568fac3 7b074424
...@@ -75,3 +75,4 @@ Frances Botsford <frances@edx.org> ...@@ -75,3 +75,4 @@ Frances Botsford <frances@edx.org>
Jonah Stanley <Jonah_Stanley@brown.edu> Jonah Stanley <Jonah_Stanley@brown.edu>
Slater Victoroff <slater.r.victoroff@gmail.com> Slater Victoroff <slater.r.victoroff@gmail.com>
Peter Fogg <peter.p.fogg@gmail.com> Peter Fogg <peter.p.fogg@gmail.com>
Renzo Lucioni <renzolucioni@gmail.com>
\ No newline at end of file
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: Problem rescoring. Added options on the Grades tab of the
Instructor Dashboard to allow all students' submissions for a
particular problem to be rescored. Also supports resetting all
students' number of attempts to zero. Provides a list of background
tasks that are currently running for the course, and an option to
see a history of background tasks for a given problem.
LMS: Forums. Added handling for case where discussion module can get `None` as
value of lms.start in `lms/djangoapps/django_comment_client/utils.py`
Studio, LMS: Make ModelTypes more strict about their expected content (for
instance, Boolean, Integer, String), but also allow them to hold either the
typed value, or a String that can be converted to their typed value. For example,
an Integer can contain 3 or '3'. This changed an update to the xblock library.
LMS: Courses whose id matches a regex in the COURSES_WITH_UNSAFE_CODE Django
setting now run entirely outside the Python sandbox.
Blades: Added tests for Video Alpha player.
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
captions.
CMS: Allow editors to delete uploaded files/assets
XModules: `XModuleDescriptor.__init__` and `XModule.__init__` dropped the
`location` parameter (and added it as a field), and renamed `system` to `runtime`,
to accord more closely to `XBlock.__init__`
LMS: Some errors handling Non-ASCII data in XML courses have been fixed.
LMS: Add page-load tracking using segment-io (if SEGMENT_IO_LMS_KEY and
SEGMENT_IO_LMS feature flag is on)
Blades: Simplify calc.py (which is used for the Numerical/Formula responses); add trig/other functions.
LMS: Background colors on login, register, and courseware have been corrected
back to white.
LMS: Accessibility improvements have been made to several courseware and
navigation elements.
LMS: Small design/presentation changes to login and register views.
LMS: Functionality added to instructor enrollment tab in LMS such that invited
student can be auto-enrolled in course or when activating if not current
student.
Blades: Staff debug info is now accessible for Graphical Slider Tool problems.
Blades: For Video Alpha the events ready, play, pause, seek, and speed change
are logged on the server (in the logs).
Common: all dates and times are not time zone aware datetimes. No code should create or use struct_times nor naive
datetimes.
Common: Developers can now have private Django settings files.
Common: Safety code added to prevent anything above the vertical level in the
course tree from being marked as version='draft'. It will raise an exception if
the code tries to so mark a node. We need the backtraces to figure out where
this very infrequent intermittent marking was occurring. It was making courses
look different in Studio than in LMS.
Deploy: MKTG_URLS is now read from env.json.
Common: Theming makes it possible to change the look of the site, from
Stanford.
Common: Accessibility UI fixes.
Common: The "duplicate email" error message is more informative.
Studio: Component metadata settings editor.
Studio: Autoplay for Video Alpha is disabled (only in Studio).
Studio: Single-click creation for video and discussion components.
Studio: fixed a bad link in the activation page.
LMS: Changed the help button text.
LMS: Fixed failing numeric response (decimal but no trailing digits).
LMS: XML Error module no longer shows students a stack trace.
Blades: Videoalpha.
XModules: Added partial credit for foldit module.
XModules: Added "randomize" XModule to list of XModule types.
XModules: Show errors with full descriptors.
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
dropped suddenly.
XQueue: Upload file submissions to a specially named bucket in S3.
Common: Removed request debugger.
Common: Updated Django to version 1.4.5.
Common: Updated CodeJail.
Common: Allow setting of authentication session cookie name.
...@@ -115,7 +115,7 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run: ...@@ -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, If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
zsh will assume that you are doing 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`, 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 and fail. To fix this, just surround the argument with quotation marks, so that
you're running `rake "django-admin[syncdb]"`. you're running `rake "django-admin[syncdb]"`.
......
Instructions
============
For each pull request, add one or more lines to the bottom of the change list. When
code is released to production, change the `Upcoming` entry to todays date, and add
a new block at the bottom of the file.
Upcoming
--------
Change log entries should be targeted at end users. A good place to start is the
user story that instigated the pull request.
Changes
=======
Upcoming
--------
* Fix: Deleting last component in a unit does not work
* Fix: Unit name is editable when a unit is public
* Fix: Visual feedback inconsistent when saving a unit name change
...@@ -28,11 +28,18 @@ Feature: Advanced (manual) course policy ...@@ -28,11 +28,18 @@ Feature: Advanced (manual) course policy
Scenario: Test how multi-line input appears Scenario: Test how multi-line input appears
Given I am on the Advanced Course Settings page in Studio 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 Then it is displayed as formatted
And I reload the page And I reload the page
Then it is displayed as formatted 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 Scenario: Test automatic quoting of non-JSON values
Given I am on the Advanced Course Settings page in Studio Given I am on the Advanced Course Settings page in Studio
When I create a non-JSON value not in quotes When I create a non-JSON value not in quotes
......
...@@ -2,13 +2,8 @@ ...@@ -2,13 +2,8 @@
#pylint: disable=W0621 #pylint: disable=W0621
from lettuce import world, step from lettuce import world, step
from common import * from nose.tools import assert_false, assert_equal, assert_regexp_matches
from nose.tools import assert_false, assert_equal from common import type_in_codemirror
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
"""
from selenium.webdriver.common.keys import Keys
KEY_CSS = '.key input.policy-key' KEY_CSS = '.key input.policy-key'
VALUE_CSS = 'textarea.json' VALUE_CSS = 'textarea.json'
...@@ -38,13 +33,7 @@ def press_the_notification_button(step, name): ...@@ -38,13 +33,7 @@ def press_the_notification_button(step, name):
@step(u'I edit the value of a policy key$') @step(u'I edit the value of a policy key$')
def edit_the_value_of_a_policy_key(step): def edit_the_value_of_a_policy_key(step):
""" type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X')
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click()
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
g._element.send_keys(Keys.ARROW_LEFT, ' ', 'X')
@step(u'I edit the value of a policy key and save$') @step(u'I edit the value of a policy key and save$')
...@@ -52,9 +41,9 @@ def edit_the_value_of_a_policy_key_and_save(step): ...@@ -52,9 +41,9 @@ def edit_the_value_of_a_policy_key_and_save(step):
change_display_name_value(step, '"foo"') change_display_name_value(step, '"foo"')
@step('I create a JSON object as a value$') @step('I create a JSON object as a value for "(.*)"$')
def create_JSON_object(step): def create_JSON_object(step, key):
change_display_name_value(step, '{"key": "value", "key_2": "value_2"}') change_value(step, key, '{"key": "value", "key_2": "value_2"}')
@step('I create a non-JSON value not in quotes$') @step('I create a non-JSON value not in quotes$')
...@@ -82,7 +71,12 @@ def they_are_alphabetized(step): ...@@ -82,7 +71,12 @@ def they_are_alphabetized(step):
@step('it is displayed as formatted$') @step('it is displayed as formatted$')
def it_is_formatted(step): 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') @step('it is displayed as a string')
...@@ -124,12 +118,9 @@ def get_display_name_value(): ...@@ -124,12 +118,9 @@ def get_display_name_value():
def change_display_name_value(step, new_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() def change_value(step, key, new_value):
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") type_in_codemirror(get_index_of(key), new_value)
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)
press_the_notification_button(step, "Save") press_the_notification_button(step, "Save")
...@@ -169,3 +169,14 @@ def open_new_unit(step): ...@@ -169,3 +169,14 @@ def open_new_unit(step):
step.given('I have added a new subsection') step.given('I have added a new subsection')
step.given('I expand the first section') step.given('I expand the first section')
world.css_click('a.new-unit-item') world.css_click('a.new-unit-item')
def type_in_codemirror(index, text):
world.css_click(".CodeMirror", index=index)
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
if world.is_mac():
g._element.send_keys(Keys.COMMAND + 'a')
else:
g._element.send_keys(Keys.CONTROL + 'a')
g._element.send_keys(Keys.DELETE)
g._element.send_keys(text)
...@@ -3,65 +3,71 @@ Feature: Problem Editor ...@@ -3,65 +3,71 @@ Feature: Problem Editor
Scenario: User can view metadata Scenario: User can view metadata
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I see five alphabetized settings and their expected values Then I see five alphabetized settings and their expected values
And Edit High Level Source is not visible And Edit High Level Source is not visible
Scenario: User can modify String values Scenario: User can modify String values
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I can modify the display name Then I can modify the display name
And my display name change is persisted on save And my display name change is persisted on save
Scenario: User can specify special characters in String values Scenario: User can specify special characters in String values
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I can specify special characters in the display name Then I can specify special characters in the display name
And my special characters and persisted on save And my special characters and persisted on save
Scenario: User can revert display name to unset Scenario: User can revert display name to unset
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I can revert the display name to unset Then I can revert the display name to unset
And my display name is unset on save And my display name is unset on save
Scenario: User can select values in a Select Scenario: User can select values in a Select
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I can select Per Student for Randomization Then I can select Per Student for Randomization
And my change to randomization is persisted And my change to randomization is persisted
And I can revert to the default value for randomization And I can revert to the default value for randomization
Scenario: User can modify float input values Scenario: User can modify float input values
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I can set the weight to "3.5" Then I can set the weight to "3.5"
And my change to weight is persisted And my change to weight is persisted
And I can revert to the default value of unset for weight And I can revert to the default value of unset for weight
Scenario: User cannot type letters in float number field Scenario: User cannot type letters in float number field
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then if I set the weight to "abc", it remains unset Then if I set the weight to "abc", it remains unset
Scenario: User cannot type decimal values integer number field Scenario: User cannot type decimal values integer number field
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234"
Scenario: User cannot type out of range values in an integer number field Scenario: User cannot type out of range values in an integer number field
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0" Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
Scenario: Settings changes are not saved on Cancel Scenario: Settings changes are not saved on Cancel
Given I have created a Blank Common Problem Given I have created a Blank Common Problem
And I edit and select Settings When I edit and select Settings
Then I can set the weight to "3.5" Then I can set the weight to "3.5"
And I can modify the display name And I can modify the display name
Then If I press Cancel my changes are not persisted Then If I press Cancel my changes are not persisted
Scenario: Edit High Level source is available for LaTeX problem Scenario: Edit High Level source is available for LaTeX problem
Given I have created a LaTeX Problem Given I have created a LaTeX Problem
And I edit and select Settings When I edit and select Settings
Then Edit High Level Source is visible Then Edit High Level Source is visible
Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
Given I have created a LaTeX Problem
When I edit and compile the High Level Source
Then my change to the High Level Source is persisted
And when I view the High Level Source I see my changes
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_equal from nose.tools import assert_equal
from common import type_in_codemirror
DISPLAY_NAME = "Display Name" DISPLAY_NAME = "Display Name"
MAXIMUM_ATTEMPTS = "Maximum Attempts" MAXIMUM_ATTEMPTS = "Maximum Attempts"
...@@ -41,7 +42,9 @@ def i_see_five_settings_with_values(step): ...@@ -41,7 +42,9 @@ def i_see_five_settings_with_values(step):
@step('I can modify the display name') @step('I can modify the display name')
def i_can_modify_the_display_name(step): 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() verify_modified_display_name()
...@@ -133,12 +136,12 @@ def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_att ...@@ -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') @step('Edit High Level Source is not visible')
def edit_high_level_source_not_visible(step): def edit_high_level_source_not_visible(step):
verify_high_level_source(step, False) verify_high_level_source_links(step, False)
@step('Edit High Level Source is visible') @step('Edit High Level Source is visible')
def edit_high_level_source_visible(step): def edit_high_level_source_links_visible(step):
verify_high_level_source(step, True) verify_high_level_source_links(step, True)
@step('If I press Cancel my changes are not persisted') @step('If I press Cancel my changes are not persisted')
...@@ -151,13 +154,33 @@ def cancel_does_not_save_changes(step): ...@@ -151,13 +154,33 @@ def cancel_does_not_save_changes(step):
@step('I have created a LaTeX Problem') @step('I have created a LaTeX Problem')
def create_latex_problem(step): def create_latex_problem(step):
world.click_new_component_button(step, '.large-problem-icon') world.click_new_component_button(step, '.large-problem-icon')
# Go to advanced tab (waiting for the tab to be visible) # Go to advanced tab.
world.css_find('#ui-id-2')
world.css_click('#ui-id-2') world.css_click('#ui-id-2')
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
def verify_high_level_source(step, visible): @step('I edit and compile the High Level Source')
def edit_latex_source(step):
open_high_level_source()
type_in_codemirror(1, "hi")
world.css_click('.hls-compile')
@step('my change to the High Level Source is persisted')
def high_level_source_persisted(step):
def verify_text(driver):
return world.css_find('.problem').text == 'hi'
world.wait_for(verify_text)
@step('I view the High Level Source I see my changes')
def high_level_source_in_editor(step):
open_high_level_source()
assert_equal('hi', world.css_find('.source-edit-box').value)
def verify_high_level_source_links(step, visible):
assert_equal(visible, world.is_css_present('.launch-latex-compiler')) assert_equal(visible, world.is_css_present('.launch-latex-compiler'))
world.cancel_component(step) world.cancel_component(step)
assert_equal(visible, world.is_css_present('.upload-button')) assert_equal(visible, world.is_css_present('.upload-button'))
...@@ -172,7 +195,7 @@ def verify_modified_randomization(): ...@@ -172,7 +195,7 @@ def verify_modified_randomization():
def verify_modified_display_name(): 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(): def verify_modified_display_name_with_special_chars():
...@@ -185,3 +208,8 @@ def verify_unset_display_name(): ...@@ -185,3 +208,8 @@ def verify_unset_display_name():
def set_weight(weight): def set_weight(weight):
world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight)
def open_high_level_source():
world.css_click('a.edit-button')
world.css_click('.launch-latex-compiler > a')
...@@ -8,3 +8,8 @@ Feature: Video Component ...@@ -8,3 +8,8 @@ Feature: Video Component
Scenario: Creating a video takes a single click Scenario: Creating a video takes a single click
Given I have clicked the new unit button Given I have clicked the new unit button
Then creating a video takes a single click Then creating a video takes a single click
Scenario: Captions are shown correctly
Given I have created a Video component
And I have hidden captions
Then when I view the video it does not show the captions
...@@ -16,3 +16,13 @@ def video_takes_a_single_click(step): ...@@ -16,3 +16,13 @@ def video_takes_a_single_click(step):
assert(not world.is_css_present('.xmodule_VideoModule')) assert(not world.is_css_present('.xmodule_VideoModule'))
world.css_click("a[data-location='i4x://edx/templates/video/default']") world.css_click("a[data-location='i4x://edx/templates/video/default']")
assert(world.is_css_present('.xmodule_VideoModule')) assert(world.is_css_present('.xmodule_VideoModule'))
@step('I have hidden captions')
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')
from django.core.management.base import BaseCommand, CommandError
from xmodule.course_module import CourseDescriptor
from xmodule.contentstore.utils import empty_asset_trashcan
from xmodule.modulestore.django import modulestore
from .prompt import query_yes_no
class Command(BaseCommand):
help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.'''
def handle(self, *args, **options):
if len(args) != 1 and len(args) != 0:
raise CommandError("empty_asset_trashcan requires one or no arguments: |<location>|")
locs = []
if len(args) == 1:
locs.append(CourseDescriptor.id_to_location(args[0]))
else:
courses = modulestore('direct').get_courses()
for course in courses:
locs.append(course.location)
if query_yes_no("Emptying trashcan. Confirm?", default="no"):
empty_asset_trashcan(locs)
from django.core.management.base import BaseCommand, CommandError
from xmodule.contentstore.utils import restore_asset_from_trashcan
class Command(BaseCommand):
help = '''Restore a deleted asset from the trashcan back to it's original course'''
def handle(self, *args, **options):
if len(args) != 1 and len(args) != 0:
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
restore_asset_from_trashcan(args[0])
...@@ -19,6 +19,24 @@ class ChecklistTestCase(CourseTestCase): ...@@ -19,6 +19,24 @@ class ChecklistTestCase(CourseTestCase):
modulestore = get_modulestore(self.course.location) modulestore = get_modulestore(self.course.location)
return modulestore.get_item(self.course.location).checklists return modulestore.get_item(self.course.location).checklists
def compare_checklists(self, persisted, request):
"""
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): def test_get_checklists(self):
""" Tests the get checklists method. """ """ Tests the get checklists method. """
checklists_url = get_url_reverse('Checklists', self.course) checklists_url = get_url_reverse('Checklists', self.course)
...@@ -31,9 +49,9 @@ class ChecklistTestCase(CourseTestCase): ...@@ -31,9 +49,9 @@ class ChecklistTestCase(CourseTestCase):
self.course.checklists = None self.course.checklists = None
modulestore = get_modulestore(self.course.location) modulestore = get_modulestore(self.course.location)
modulestore.update_metadata(self.course.location, own_metadata(self.course)) 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) response = self.client.get(checklists_url)
self.assertEquals(payload, response.content) self.assertEqual(payload, response.content)
def test_update_checklists_no_index(self): def test_update_checklists_no_index(self):
""" No checklist index, should return all of them. """ """ No checklist index, should return all of them. """
...@@ -43,7 +61,8 @@ class ChecklistTestCase(CourseTestCase): ...@@ -43,7 +61,8 @@ class ChecklistTestCase(CourseTestCase):
'name': self.course.location.name}) 'name': self.course.location.name})
returned_checklists = json.loads(self.client.get(update_url).content) 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): def test_update_checklists_index_ignored_on_get(self):
""" Checklist index ignored on get. """ """ Checklist index ignored on get. """
...@@ -53,7 +72,8 @@ class ChecklistTestCase(CourseTestCase): ...@@ -53,7 +72,8 @@ class ChecklistTestCase(CourseTestCase):
'checklist_index': 1}) 'checklist_index': 1})
returned_checklists = json.loads(self.client.get(update_url).content) 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): def test_update_checklists_post_no_index(self):
""" No checklist index, will error on post. """ """ No checklist index, will error on post. """
...@@ -78,13 +98,18 @@ class ChecklistTestCase(CourseTestCase): ...@@ -78,13 +98,18 @@ class ChecklistTestCase(CourseTestCase):
'course': self.course.location.course, 'course': self.course.location.course,
'name': self.course.location.name, 'name': self.course.location.name,
'checklist_index': 2}) 'checklist_index': 2})
def get_first_item(checklist):
return checklist['items'][0]
payload = self.course.checklists[2] payload = self.course.checklists[2]
self.assertFalse(payload.get('is_checked')) self.assertFalse(get_first_item(payload).get('is_checked'))
payload['is_checked'] = True get_first_item(payload)['is_checked'] = True
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content) returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
self.assertTrue(returned_checklist.get('is_checked')) self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
self.assertEqual(self.get_persisted_checklists()[2], returned_checklist) pers = self.get_persisted_checklists()
self.compare_checklists(pers[2], returned_checklist)
def test_update_checklists_delete_unsupported(self): def test_update_checklists_delete_unsupported(self):
""" Delete operation is not supported. """ """ Delete operation is not supported. """
...@@ -93,4 +118,4 @@ class ChecklistTestCase(CourseTestCase): ...@@ -93,4 +118,4 @@ class ChecklistTestCase(CourseTestCase):
'name': self.course.location.name, 'name': self.course.location.name,
'checklist_index': 100}) 'checklist_index': 100})
response = self.client.delete(update_url) response = self.client.delete(update_url)
self.assertContains(response, 'Unsupported request', status_code=400) self.assertContains(response, 'Unsupported request', status_code=400)
\ No newline at end of file
...@@ -28,6 +28,8 @@ from xmodule.templates import update_templates ...@@ -28,6 +28,8 @@ from xmodule.templates import update_templates
from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
from xmodule.modulestore.inheritance import own_metadata 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.capa_module import CapaDescriptor
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
...@@ -35,6 +37,7 @@ from xmodule.seq_module import SequenceDescriptor ...@@ -35,6 +37,7 @@ from xmodule.seq_module import SequenceDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from contentstore.views.component import ADVANCED_COMPONENT_TYPES from contentstore.views.component import ADVANCED_COMPONENT_TYPES
from xmodule.exceptions import NotFoundError
from django_comment_common.utils import are_permissions_roles_seeded from django_comment_common.utils import are_permissions_roles_seeded
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
...@@ -382,6 +385,159 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -382,6 +385,159 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
course = module_store.get_item(source_location) course = module_store.get_item(source_location)
self.assertFalse(course.hide_progress_tab) 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): def test_clone_course(self):
course_data = { course_data = {
......
...@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse ...@@ -3,6 +3,10 @@ from django.core.urlresolvers import reverse
from .utils import parse_json, user, registration from .utils import parse_json, user, registration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.test_course_settings import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
import datetime
from pytz import UTC
class ContentStoreTestCase(ModuleStoreTestCase): class ContentStoreTestCase(ModuleStoreTestCase):
...@@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -162,3 +166,21 @@ class AuthTestCase(ContentStoreTestCase):
self.assertEqual(resp.status_code, 302) self.assertEqual(resp.status_code, 302)
# Logged in should work. # Logged in should work.
class ForumTestCase(CourseTestCase):
def setUp(self):
""" Creates the test course. """
super(ForumTestCase, self).setUp()
self.course = CourseFactory.create(org='testX', number='727', display_name='Forum Course')
def test_blackouts(self):
now = datetime.datetime.now(UTC)
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
[(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)),
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
self.assertTrue(self.course.forum_posts_allowed)
self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in
[(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)),
(now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]]
self.assertFalse(self.course.forum_posts_allowed)
...@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore ...@@ -25,6 +25,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.util.date_utils import get_default_time_display from xmodule.util.date_utils import get_default_time_display
from xmodule.modulestore import InvalidLocationError
from xmodule.exceptions import NotFoundError
from ..utils import get_url_reverse from ..utils import get_url_reverse
from .access import get_location_and_verify_access from .access import get_location_and_verify_access
...@@ -78,10 +80,17 @@ def asset_index(request, org, course, name): ...@@ -78,10 +80,17 @@ def asset_index(request, org, course, name):
'active_tab': 'assets', 'active_tab': 'assets',
'context_course': course_module, 'context_course': course_module,
'assets': asset_display, 'assets': asset_display,
'upload_asset_callback_url': upload_asset_callback_url 'upload_asset_callback_url': upload_asset_callback_url,
'remove_asset_callback_url': reverse('remove_asset', kwargs={
'org': org,
'course': course,
'name': name
})
}) })
@login_required
@ensure_csrf_cookie
def upload_asset(request, org, course, coursename): def upload_asset(request, org, course, coursename):
''' '''
cdodge: this method allows for POST uploading of files into the course asset library, which will cdodge: this method allows for POST uploading of files into the course asset library, which will
...@@ -147,6 +156,57 @@ def upload_asset(request, org, course, coursename): ...@@ -147,6 +156,57 @@ def upload_asset(request, org, course, coursename):
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
def remove_asset(request, org, course, name):
'''
This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from
the main GridFS collection and into a Trashcan
'''
get_location_and_verify_access(request, org, course, name)
location = request.POST['location']
# make sure the location is valid
try:
loc = StaticContent.get_location_from_path(location)
except InvalidLocationError:
# return a 'Bad Request' to browser as we have a malformed Location
response = HttpResponse()
response.status_code = 400
return response
# also make sure the item to delete actually exists
try:
content = contentstore().find(loc)
except NotFoundError:
response = HttpResponse()
response.status_code = 404
return response
# ok, save the content into the trashcan
contentstore('trashcan').save(content)
# see if there is a thumbnail as well, if so move that as well
if content.thumbnail_location is not None:
try:
thumbnail_content = contentstore().find(content.thumbnail_location)
contentstore('trashcan').save(thumbnail_content)
# hard delete thumbnail from origin
contentstore().delete(thumbnail_content.get_id())
# remove from any caching
del_cached_content(thumbnail_content.location)
except:
pass # OK if this is left dangling
# delete the original
contentstore().delete(content.get_id())
# remove from cache
del_cached_content(content.location)
return HttpResponse()
@ensure_csrf_cookie
@login_required
def import_course(request, org, course, name): def import_course(request, org, course, name):
location = get_location_and_verify_access(request, org, course, name) location = get_location_and_verify_access(request, org, course, name)
......
...@@ -402,8 +402,11 @@ def course_advanced_updates(request, org, course, name): ...@@ -402,8 +402,11 @@ def course_advanced_updates(request, org, course, name):
request_body.update({'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 filter_tabs = False
try:
response_json = json.dumps(CourseMetadata.update_from_json(location, response_json = json.dumps(CourseMetadata.update_from_json(location,
request_body, request_body,
filter_tabs=filter_tabs)) 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") return HttpResponse(response_json, mimetype="application/json")
...@@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) ...@@ -112,9 +112,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value MITX_FEATURES[feature] = value
# load segment.io key, provide a dummy if it does not exist
SEGMENT_IO_KEY = ENV_TOKENS.get('SEGMENT_IO_KEY', '***REMOVED***')
LOGGING = get_logger_config(LOG_DIR, LOGGING = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'], logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
...@@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR, ...@@ -126,6 +123,13 @@ LOGGING = get_logger_config(LOG_DIR,
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
AUTH_TOKENS = json.load(auth_file) AUTH_TOKENS = json.load(auth_file)
# If Segment.io key specified, load it and turn on Segment.io if the feature flag is set
# Note that this is the Studio key. There is a separate key for the LMS.
SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False)
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES'] DATABASES = AUTH_TOKENS['DATABASES']
......
...@@ -32,13 +32,23 @@ from path import path ...@@ -32,13 +32,23 @@ from path import path
MITX_FEATURES = { MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True, 'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False, 'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False, 'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES': False, 'AUTH_USE_MIT_CERTIFICATES': False,
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
'STAFF_EMAIL': '', # email address for staff (eg to request course creation) # do not display video when running automated acceptance tests
'STUB_VIDEO_FOR_TESTING': False,
# email address for staff (eg to request course creation)
'STAFF_EMAIL': '',
'STUDIO_NPS_SURVEY': True, 'STUDIO_NPS_SURVEY': True,
'SEGMENT_IO': True,
# Segment.io - must explicitly turn it on for production
'SEGMENT_IO': False,
# Enable URL that shows information about the status of various services # Enable URL that shows information about the status of various services
'ENABLE_SERVICE_STATUS': False, 'ENABLE_SERVICE_STATUS': False,
...@@ -228,7 +238,8 @@ PIPELINE_JS = { ...@@ -228,7 +238,8 @@ PIPELINE_JS = {
) + ['js/hesitate.js', 'js/base.js', ) + ['js/hesitate.js', 'js/base.js',
'js/models/feedback.js', 'js/views/feedback.js', 'js/models/feedback.js', 'js/views/feedback.js',
'js/models/section.js', 'js/views/section.js', 'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js'], 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/views/assets.js'],
'output_filename': 'js/cms-application.js', 'output_filename': 'js/cms-application.js',
'test_order': 0 'test_order': 0
}, },
......
...@@ -43,10 +43,15 @@ CONTENTSTORE = { ...@@ -43,10 +43,15 @@ CONTENTSTORE = {
'OPTIONS': { 'OPTIONS': {
'host': 'localhost', 'host': 'localhost',
'db': 'xcontent', 'db': 'xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
} }
} }
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
...@@ -163,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False ...@@ -163,8 +168,14 @@ MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
# Enable URL that shows information about the status of variuous services # Enable URL that shows information about the status of variuous services
MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
# segment-io key for dev ############################# SEGMENT-IO ##################################
SEGMENT_IO_KEY = 'mty8edrrsg'
# If there's an environment variable set, grab it and turn on Segment.io
# Note that this is the Studio key. There is a separate key for the LMS.
import os
SEGMENT_IO_KEY = os.environ.get('SEGMENT_IO_KEY')
if SEGMENT_IO_KEY:
MITX_FEATURES['SEGMENT_IO'] = True
##################################################################### #####################################################################
......
...@@ -70,7 +70,13 @@ CONTENTSTORE = { ...@@ -70,7 +70,13 @@ CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': { 'OPTIONS': {
'host': 'localhost', 'host': 'localhost',
'db': 'xcontent', 'db': 'test_xmodule',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
} }
} }
......
...@@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View ...@@ -44,8 +44,17 @@ class CMS.Views.ModuleEdit extends Backbone.View
[@metadataEditor.getDisplayName()]) [@metadataEditor.getDisplayName()])
@$el.find('.component-name').html(title) @$el.find('.component-name').html(title)
customMetadata: ->
# Hack to support metadata fields that aren't part of the metadata editor (ie, LaTeX high level source).
# Walk through the set of elements which have the 'data-metadata_name' attribute and
# build up an object to pass back to the server on the subsequent POST.
# Note that these values will always be sent back on POST, even if they did not actually change.
_metadata = {}
_metadata[$(el).data("metadata-name")] = el.value for el in $('[data-metadata-name]', @$component_editor())
return _metadata
changedMetadata: -> changedMetadata: ->
return @metadataEditor.getModifiedMetadataValues() return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
cloneTemplate: (parent, template) -> cloneTemplate: (parent, template) ->
$.post("/clone_item", { $.post("/clone_item", {
......
...@@ -32,8 +32,6 @@ $(document).ready(function() { ...@@ -32,8 +32,6 @@ $(document).ready(function() {
$modal.bind('click', hideModal); $modal.bind('click', hideModal);
$modalCover.bind('click', hideModal); $modalCover.bind('click', hideModal);
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$body.on('click', '.embeddable-xml-input', function() { $body.on('click', '.embeddable-xml-input', function() {
$(this).select(); $(this).select();
...@@ -145,8 +143,6 @@ $(document).ready(function() { ...@@ -145,8 +143,6 @@ $(document).ready(function() {
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate); $('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
$('.edit-section-start-save').bind('click', saveSetSectionScheduleDate); $('.edit-section-start-save').bind('click', saveSetSectionScheduleDate);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
$body.on('click', '.section-published-date .edit-button', editSectionPublishDate); $body.on('click', '.section-published-date .edit-button', editSectionPublishDate);
$body.on('click', '.section-published-date .schedule-button', editSectionPublishDate); $body.on('click', '.section-published-date .schedule-button', editSectionPublishDate);
$body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate); $body.on('click', '.edit-subsection-publish-settings .save-button', saveSetSectionScheduleDate);
...@@ -398,73 +394,6 @@ function _deleteItem($el) { ...@@ -398,73 +394,6 @@ function _deleteItem($el) {
}); });
} }
function showUploadModal(e) {
e.preventDefault();
$modal = $('.upload-modal').show();
$('.file-input').bind('change', startUpload);
$modalCover.show();
}
function showFileSelectionMenu(e) {
e.preventDefault();
$('.file-input').click();
}
function startUpload(e) {
var files = $('.file-input').get(0).files;
if (files.length === 0)
return;
$('.upload-modal h1').html(gettext('Uploading…'));
$('.upload-modal .file-name').html(files[0].name);
$('.upload-modal .file-chooser').ajaxSubmit({
beforeSend: resetUploadBar,
uploadProgress: showUploadFeedback,
complete: displayFinishedUpload
});
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
}
function resetUploadBar() {
var percentVal = '0%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function showUploadFeedback(event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function displayFinishedUpload(xhr) {
if (xhr.status = 200) {
markAsLoaded();
}
var resp = JSON.parse(xhr.responseText);
$('.upload-modal .embeddable-xml-input').val(xhr.getResponseHeader('asset_url'));
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
$('.upload-modal .progress-fill').html(resp.msg);
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show();
$('.upload-modal .progress-fill').width('100%');
// see if this id already exists, if so, then user must have updated an existing piece of content
$("tr[data-id='" + resp.url + "']").remove();
var template = $('#new-asset-element').html();
var html = Mustache.to_html(template, resp);
$('table > tbody').prepend(html);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': resp.url
});
}
function markAsLoaded() { function markAsLoaded() {
$('.upload-modal .copy-button').css('display', 'inline-block'); $('.upload-modal .copy-button').css('display', 'inline-block');
$('.upload-modal .progress-bar').addClass('loaded'); $('.upload-modal .progress-bar').addClass('loaded');
......
...@@ -42,6 +42,12 @@ CMS.Models.ErrorMessage = CMS.Models.SystemFeedback.extend({ ...@@ -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({ CMS.Models.ConfirmationMessage = CMS.Models.SystemFeedback.extend({
defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, { defaults: $.extend({}, CMS.Models.SystemFeedback.prototype.defaults, {
"intent": "confirmation" "intent": "confirmation"
......
$(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
});
}
\ No newline at end of file
...@@ -76,6 +76,10 @@ body.course.uploads { ...@@ -76,6 +76,10 @@ body.course.uploads {
width: 250px; width: 250px;
} }
.delete-col {
width: 20px;
}
.embeddable-xml-input { .embeddable-xml-input {
@include box-shadow(none); @include box-shadow(none);
width: 100%; width: 100%;
......
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%block name="bodyclass">is-signedin course uploads</%block> <%block name="bodyclass">is-signedin course uploads</%block>
<%block name="title">Files &amp; Uploads</%block> <%block name="title">Files &amp; Uploads</%block>
...@@ -7,6 +8,11 @@ ...@@ -7,6 +8,11 @@
<%block name="jsextra"> <%block name="jsextra">
<script src="${static.url('js/vendor/mustache.js')}"></script> <script src="${static.url('js/vendor/mustache.js')}"></script>
<script type='text/javascript'>
// we just want a singleton
confirm_delete_prompt = null;
</script>
</%block> </%block>
<%block name="content"> <%block name="content">
...@@ -30,6 +36,9 @@ ...@@ -30,6 +36,9 @@
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value='{{url}}' readonly> <input type="text" class="embeddable-xml-input" value='{{url}}' readonly>
</td> </td>
<td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td>
</tr> </tr>
</script> </script>
...@@ -56,7 +65,7 @@ ...@@ -56,7 +65,7 @@
<div class="page-actions"> <div class="page-actions">
<input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/> <input type="text" class="asset-search-input search wip-box" placeholder="search assets" style="display:none"/>
</div> </div>
<article class="asset-library"> <article class="asset-library" data-remove-asset-callback-url='${remove_asset_callback_url}'>
<table> <table>
<thead> <thead>
<tr> <tr>
...@@ -64,6 +73,7 @@ ...@@ -64,6 +73,7 @@
<th class="name-col">Name</th> <th class="name-col">Name</th>
<th class="date-col">Date Added</th> <th class="date-col">Date Added</th>
<th class="embed-col">URL</th> <th class="embed-col">URL</th>
<th class="delete-col"></th>
</tr> </tr>
</thead> </thead>
<tbody id="asset_table_body"> <tbody id="asset_table_body">
...@@ -86,6 +96,9 @@ ...@@ -86,6 +96,9 @@
<td class="embed-col"> <td class="embed-col">
<input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly> <input type="text" class="embeddable-xml-input" value="${asset['url']}" readonly>
</td> </td>
<td class="delete-col">
<a href="#" data-tooltip="${_('Delete this asset')}" class="remove-asset-button"><span class="delete-icon"></span></a>
</td>
</tr> </tr>
% endfor % endfor
</tbody> </tbody>
...@@ -129,3 +142,21 @@ ...@@ -129,3 +142,21 @@
</%block> </%block>
<%block name="view_alerts">
<!-- alert: save confirmed with close -->
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
<div class="alert confirmation">
<i class="icon-ok"></i>
<div class="copy">
<h2 class="title title-3">${_('Your file has been deleted.')}</h2>
</div>
<a href="" rel="view" class="action action-alert-close">
<i class="icon-remove-sign"></i>
<span class="label">${_('close alert')}</span>
</a>
</div>
</div>
</%block>
...@@ -35,6 +35,8 @@ urlpatterns = ('', # nopep8 ...@@ -35,6 +35,8 @@ urlpatterns = ('', # nopep8
'contentstore.views.preview_dispatch', name='preview_dispatch'), 'contentstore.views.preview_dispatch', name='preview_dispatch'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)/upload_asset$',
'contentstore.views.upload_asset', name='upload_asset'), 'contentstore.views.upload_asset', name='upload_asset'),
url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'), url(r'^manage_users/(?P<location>.*?)$', 'contentstore.views.manage_users', name='manage_users'),
url(r'^add_user/(?P<location>.*?)$', url(r'^add_user/(?P<location>.*?)$',
'contentstore.views.add_user', name='add_user'), 'contentstore.views.add_user', name='add_user'),
...@@ -71,8 +73,11 @@ urlpatterns = ('', # nopep8 ...@@ -71,8 +73,11 @@ urlpatterns = ('', # nopep8
'contentstore.views.edit_static', name='edit_static'), 'contentstore.views.edit_static', name='edit_static'),
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$',
'contentstore.views.edit_tabs', name='edit_tabs'), 'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$',
'contentstore.views.asset_index', name='asset_index'), 'contentstore.views.asset_index', name='asset_index'),
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)/remove$',
'contentstore.views.assets.remove_asset', name='remove_asset'),
# this is a generic method to return the data/metadata associated with a xmodule # this is a generic method to return the data/metadata associated with a xmodule
url(r'^module_info/(?P<module_location>.*)$', url(r'^module_info/(?P<module_location>.*)$',
......
...@@ -5,7 +5,6 @@ Namespace defining common fields used by Studio for all blocks ...@@ -5,7 +5,6 @@ Namespace defining common fields used by Studio for all blocks
import datetime import datetime
from xblock.core import Namespace, Scope, ModelType, String from xblock.core import Namespace, Scope, ModelType, String
from xmodule.fields import StringyBoolean
class DateTuple(ModelType): class DateTuple(ModelType):
...@@ -28,4 +27,3 @@ class CmsNamespace(Namespace): ...@@ -28,4 +27,3 @@ class CmsNamespace(Namespace):
""" """
published_date = DateTuple(help="Date when the module was published", scope=Scope.settings) 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) published_by = String(help="Id of the user who published this module", scope=Scope.settings)
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings
from mitxmako.shortcuts import marketing_link from mitxmako.shortcuts import marketing_link
from mock import patch 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 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_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'})
@override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'}) @override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'})
def test_marketing_link(self): def test_marketing_link(self):
......
...@@ -5,6 +5,7 @@ from django.contrib.auth.models import Group ...@@ -5,6 +5,7 @@ from django.contrib.auth.models import Group
from datetime import datetime from datetime import datetime
from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence
from uuid import uuid4 from uuid import uuid4
from pytz import UTC
# Factories don't have __init__ methods, and are self documenting # Factories don't have __init__ methods, and are self documenting
# pylint: disable=W0232 # pylint: disable=W0232
...@@ -46,8 +47,8 @@ class UserFactory(DjangoModelFactory): ...@@ -46,8 +47,8 @@ class UserFactory(DjangoModelFactory):
is_staff = False is_staff = False
is_active = True is_active = True
is_superuser = False is_superuser = False
last_login = datetime(2012, 1, 1) last_login = datetime(2012, 1, 1, tzinfo=UTC)
date_joined = datetime(2011, 1, 1) date_joined = datetime(2011, 1, 1, tzinfo=UTC)
@post_generation @post_generation
def profile(obj, create, extracted, **kwargs): def profile(obj, create, extracted, **kwargs):
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from lettuce import world from lettuce import world
import time import time
import platform
from urllib import quote_plus from urllib import quote_plus
from selenium.common.exceptions import WebDriverException, StaleElementReferenceException from selenium.common.exceptions import WebDriverException, StaleElementReferenceException
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
...@@ -57,20 +58,28 @@ def css_find(css, wait_time=5): ...@@ -57,20 +58,28 @@ def css_find(css, wait_time=5):
@world.absorb @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 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) assert is_css_present(css_selector)
try: attempt = 0
world.browser.find_by_css(css_selector).click() result = False
while attempt < attempts:
except WebDriverException: try:
# Occassionally, MathJax or other JavaScript can cover up world.css_find(css_selector)[index].click()
# an element temporarily. result = True
# If this happens, wait a second, then try again break
world.wait(1) except WebDriverException:
world.browser.find_by_css(css_selector).click() # Occasionally, MathJax or other JavaScript can cover up
# an element temporarily.
# If this happens, wait a second, then try again
world.wait(1)
attempt += 1
except:
attempt += 1
return result
@world.absorb @world.absorb
...@@ -158,3 +167,8 @@ def click_tools(): ...@@ -158,3 +167,8 @@ def click_tools():
tools_css = 'li.nav-course-tools' tools_css = 'li.nav-course-tools'
if world.browser.is_element_present_by_css(tools_css): if world.browser.is_element_present_by_css(tools_css):
world.css_click(tools_css) world.css_click(tools_css)
@world.absorb
def is_mac():
return platform.mac_ver()[0] is not ''
import json import json
import logging import logging
import os
import pytz import pytz
import datetime import datetime
import dateutil.parser import dateutil.parser
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
from django.http import Http404
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf import settings from django.conf import settings
from mitxmako.shortcuts import render_to_response from mitxmako.shortcuts import render_to_response
...@@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', ' ...@@ -22,6 +20,7 @@ LOGFIELDS = ['username', 'ip', 'event_source', 'event_type', 'event', 'agent', '
def log_event(event): def log_event(event):
"""Write tracking event to log file, and optionally to TrackingLog model."""
event_str = json.dumps(event) event_str = json.dumps(event)
log.info(event_str[:settings.TRACK_MAX_EVENT]) log.info(event_str[:settings.TRACK_MAX_EVENT])
if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
...@@ -34,6 +33,11 @@ def log_event(event): ...@@ -34,6 +33,11 @@ def log_event(event):
def user_track(request): def user_track(request):
"""
Log when GET call to "event" URL is made by a user.
GET call should provide "event_type", "event", and "page" arguments.
"""
try: # TODO: Do the same for many of the optional META parameters try: # TODO: Do the same for many of the optional META parameters
username = request.user.username username = request.user.username
except: except:
...@@ -50,7 +54,6 @@ def user_track(request): ...@@ -50,7 +54,6 @@ def user_track(request):
except: except:
agent = '' agent = ''
# TODO: Move a bunch of this into log_event
event = { event = {
"username": username, "username": username,
"session": scookie, "session": scookie,
...@@ -68,6 +71,7 @@ def user_track(request): ...@@ -68,6 +71,7 @@ def user_track(request):
def server_track(request, event_type, event, page=None): def server_track(request, event_type, event, page=None):
"""Log events related to server requests."""
try: try:
username = request.user.username username = request.user.username
except: except:
...@@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None): ...@@ -95,9 +99,52 @@ def server_track(request, event_type, event, page=None):
log_event(event) log_event(event)
def task_track(request_info, task_info, event_type, event, page=None):
"""
Logs tracking information for events occuring within celery tasks.
The `event_type` is a string naming the particular event being logged,
while `event` is a dict containing whatever additional contextual information
is desired.
The `request_info` is a dict containing information about the original
task request. Relevant keys are `username`, `ip`, `agent`, and `host`.
While the dict is required, the values in it are not, so that {} can be
passed in.
In addition, a `task_info` dict provides more information about the current
task, to be stored with the `event` dict. This may also be an empty dict.
The `page` parameter is optional, and allows the name of the page to
be provided.
"""
# supplement event information with additional information
# about the task in which it is running.
full_event = dict(event, **task_info)
# All fields must be specified, in case the tracking information is
# also saved to the TrackingLog model. Get values from the task-level
# information, or just add placeholder values.
event = {
"username": request_info.get('username', 'unknown'),
"ip": request_info.get('ip', 'unknown'),
"event_source": "task",
"event_type": event_type,
"event": full_event,
"agent": request_info.get('agent', 'unknown'),
"page": page,
"time": datetime.datetime.utcnow().isoformat(),
"host": request_info.get('host', 'unknown')
}
log_event(event)
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
def view_tracking_log(request, args=''): def view_tracking_log(request, args=''):
"""View to output contents of TrackingLog model. For staff use only."""
if not request.user.is_staff: if not request.user.is_staff:
return redirect('/') return redirect('/')
nlen = 100 nlen = 100
......
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)
"""
Provide the mathematical functions that numpy doesn't.
Specifically, the secant/cosecant/cotangents and their inverses and
hyperbolic counterparts
"""
import numpy
# Normal Trig
def sec(arg):
"""
Secant
"""
return 1 / numpy.cos(arg)
def csc(arg):
"""
Cosecant
"""
return 1 / numpy.sin(arg)
def cot(arg):
"""
Cotangent
"""
return 1 / numpy.tan(arg)
# Inverse Trig
# http://en.wikipedia.org/wiki/Inverse_trigonometric_functions#Relationships_among_the_inverse_trigonometric_functions
def arcsec(val):
"""
Inverse secant
"""
return numpy.arccos(1. / val)
def arccsc(val):
"""
Inverse cosecant
"""
return numpy.arcsin(1. / val)
def arccot(val):
"""
Inverse cotangent
"""
if numpy.real(val) < 0:
return -numpy.pi / 2 - numpy.arctan(val)
else:
return numpy.pi / 2 - numpy.arctan(val)
# Hyperbolic Trig
def sech(arg):
"""
Hyperbolic secant
"""
return 1 / numpy.cosh(arg)
def csch(arg):
"""
Hyperbolic cosecant
"""
return 1 / numpy.sinh(arg)
def coth(arg):
"""
Hyperbolic cotangent
"""
return 1 / numpy.tanh(arg)
# And their inverses
def arcsech(val):
"""
Inverse hyperbolic secant
"""
return numpy.arccosh(1. / val)
def arccsch(val):
"""
Inverse hyperbolic cosecant
"""
return numpy.arcsinh(1. / val)
def arccoth(val):
"""
Inverse hyperbolic cotangent
"""
return numpy.arctanh(1. / val)
...@@ -194,6 +194,105 @@ class EvaluatorTest(unittest.TestCase): ...@@ -194,6 +194,105 @@ class EvaluatorTest(unittest.TestCase):
arctan_angles = arcsin_angles arctan_angles = arcsin_angles
self.assert_function_values('arctan', arctan_inputs, arctan_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): def test_other_functions(self):
""" """
Test the non-trig functions provided in calc.py Test the non-trig functions provided in calc.py
......
...@@ -1738,6 +1738,7 @@ class FormulaResponse(LoncapaResponse): ...@@ -1738,6 +1738,7 @@ class FormulaResponse(LoncapaResponse):
student_variables = dict() student_variables = dict()
# ranges give numerical ranges for testing # ranges give numerical ranges for testing
for var in ranges: for var in ranges:
# TODO: allow specified ranges (i.e. integers and complex numbers) for random variables
value = random.uniform(*ranges[var]) value = random.uniform(*ranges[var])
instructor_variables[str(var)] = value instructor_variables[str(var)] = value
student_variables[str(var)] = value student_variables[str(var)] = value
......
...@@ -6,7 +6,7 @@ from xmodule.x_module import XModule ...@@ -6,7 +6,7 @@ from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from xmodule.xml_module import XmlDescriptor from xmodule.xml_module import XmlDescriptor
from xmodule.exceptions import InvalidDefinitionError from xmodule.exceptions import InvalidDefinitionError
from xblock.core import String, Scope, Object from xblock.core import String, Scope, Dict
DEFAULT = "_DEFAULT_GROUP" DEFAULT = "_DEFAULT_GROUP"
...@@ -32,9 +32,9 @@ def group_from_value(groups, v): ...@@ -32,9 +32,9 @@ def group_from_value(groups, v):
class ABTestFields(object): class ABTestFields(object):
group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content) group_portions = Dict(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_assignments = Dict(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_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) experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content)
has_children = True has_children = True
......
...@@ -125,6 +125,5 @@ class AnnotatableModule(AnnotatableFields, XModule): ...@@ -125,6 +125,5 @@ class AnnotatableModule(AnnotatableFields, XModule):
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor): class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
module_class = AnnotatableModule module_class = AnnotatableModule
stores_state = True
template_dir_name = "annotatable" template_dir_name = "annotatable"
mako_template = "widgets/raw-edit.html" mako_template = "widgets/raw-edit.html"
...@@ -5,10 +5,10 @@ from pkg_resources import resource_string ...@@ -5,10 +5,10 @@ from pkg_resources import resource_string
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from .x_module import XModule 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 xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
from collections import namedtuple from collections import namedtuple
from .fields import Date, StringyFloat, StringyInteger, StringyBoolean from .fields import Date
log = logging.getLogger("mitx.courseware") log = logging.getLogger("mitx.courseware")
...@@ -53,27 +53,27 @@ class CombinedOpenEndedFields(object): ...@@ -53,27 +53,27 @@ class CombinedOpenEndedFields(object):
help="This name appears in the horizontal navigation at the top of the page.", help="This name appears in the horizontal navigation at the top of the page.",
default="Open Ended Grading", scope=Scope.settings 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) 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", state = String(help="Which step within the current task that the student is on.", default="initial",
scope=Scope.user_state) 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) 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, help="If the problem is ready to be reset or not.", default=False,
scope=Scope.user_state scope=Scope.user_state
) )
attempts = StringyInteger( attempts = Integer(
display_name="Maximum Attempts", display_name="Maximum Attempts",
help="The number of times the student can try to answer this problem.", default=1, help="The number of times the student can try to answer this problem.", default=1,
scope=Scope.settings, values = {"min" : 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) is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
accept_file_upload = StringyBoolean( accept_file_upload = Boolean(
display_name="Allow File Uploads", display_name="Allow File Uploads",
help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings 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", 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.", 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 default=False, scope=Scope.settings
...@@ -86,7 +86,7 @@ class CombinedOpenEndedFields(object): ...@@ -86,7 +86,7 @@ class CombinedOpenEndedFields(object):
) )
version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings) version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content) data = String(help="XML data for the problem", scope=Scope.content)
weight = StringyFloat( weight = Float(
display_name="Problem Weight", 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.", 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"} scope=Scope.settings, values = {"min" : 0 , "step": ".1"}
...@@ -116,6 +116,8 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): ...@@ -116,6 +116,8 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
incorporates multiple children (tasks): incorporates multiple children (tasks):
openendedmodule openendedmodule
selfassessmentmodule selfassessmentmodule
CombinedOpenEndedModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
""" """
STATE_VERSION = 1 STATE_VERSION = 1
...@@ -139,8 +141,7 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): ...@@ -139,8 +141,7 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
def __init__(self, system, location, descriptor, model_data): def __init__(self, *args, **kwargs):
XModule.__init__(self, system, location, descriptor, model_data)
""" """
Definition file should have one or many task blocks, a rubric block, and a prompt block: Definition file should have one or many task blocks, a rubric block, and a prompt block:
...@@ -175,9 +176,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): ...@@ -175,9 +176,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
</combinedopenended> </combinedopenended>
""" """
XModule.__init__(self, *args, **kwargs)
self.system = system self.system.set('location', self.location)
self.system.set('location', location)
if self.task_states is None: if self.task_states is None:
self.task_states = [] self.task_states = []
...@@ -189,13 +190,11 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): ...@@ -189,13 +190,11 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
attributes = self.student_attributes + self.settings_attributes attributes = self.student_attributes + self.settings_attributes
static_data = { static_data = {}
'rewrite_content_links': self.rewrite_content_links,
}
instance_state = {k: getattr(self, k) for k in attributes} instance_state = {k: getattr(self, k) for k in attributes}
self.child_descriptor = version_tuple.descriptor(self.system) 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_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, instance_state=instance_state, static_data=static_data,
attributes=attributes) attributes=attributes)
self.save_instance_data() self.save_instance_data()
...@@ -239,7 +238,6 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor): ...@@ -239,7 +238,6 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
mako_template = "widgets/open-ended-edit.html" mako_template = "widgets/open-ended-edit.html"
module_class = CombinedOpenEndedModule module_class = CombinedOpenEndedModule
stores_state = True
has_score = True has_score = True
always_recalculate_grades = True always_recalculate_grades = True
template_dir_name = "combinedopenended" template_dir_name = "combinedopenended"
......
...@@ -92,7 +92,7 @@ class ConditionalModule(ConditionalFields, XModule): ...@@ -92,7 +92,7 @@ class ConditionalModule(ConditionalFields, XModule):
if xml_value and self.required_modules: if xml_value and self.required_modules:
for module in self.required_modules: for module in self.required_modules:
if not hasattr(module, attr_name): 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 # the descriptor of a required module to have a property but
# for the resulting module to be a (flavor of) ErrorModule. # for the resulting module to be a (flavor of) ErrorModule.
# So just log and return false. # So just log and return false.
...@@ -161,7 +161,6 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor): ...@@ -161,7 +161,6 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
filename_extension = "xml" filename_extension = "xml"
stores_state = True
has_score = False has_score = False
@staticmethod @staticmethod
......
...@@ -3,7 +3,7 @@ from importlib import import_module ...@@ -3,7 +3,7 @@ from importlib import import_module
from django.conf import settings from django.conf import settings
_CONTENTSTORE = None _CONTENTSTORE = {}
def load_function(path): def load_function(path):
...@@ -17,13 +17,16 @@ def load_function(path): ...@@ -17,13 +17,16 @@ def load_function(path):
return getattr(import_module(module_path), name) return getattr(import_module(module_path), name)
def contentstore(): def contentstore(name='default'):
global _CONTENTSTORE global _CONTENTSTORE
if _CONTENTSTORE is None: if name not in _CONTENTSTORE:
class_ = load_function(settings.CONTENTSTORE['ENGINE']) class_ = load_function(settings.CONTENTSTORE['ENGINE'])
options = {} options = {}
options.update(settings.CONTENTSTORE['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]
from bson.son import SON
from pymongo import Connection from pymongo import Connection
import gridfs import gridfs
from gridfs.errors import NoFile from gridfs.errors import NoFile
...@@ -15,15 +14,16 @@ import os ...@@ -15,15 +14,16 @@ import os
class MongoContentStore(ContentStore): 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)) logging.debug('Using MongoDB for static content serving at host={0} db={1}'.format(host, db))
_db = Connection(host=host, port=port, **kwargs)[db] _db = Connection(host=host, port=port, **kwargs)[db]
if user is not None and password is not None: if user is not None and password is not None:
_db.authenticate(user, password) _db.authenticate(user, password)
self.fs = gridfs.GridFS(_db) self.fs = gridfs.GridFS(_db, bucket)
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
self.fs_files = _db[bucket + ".files"] # the underlying collection GridFS uses
def save(self, content): def save(self, content):
id = content.get_id() id = content.get_id()
...@@ -43,7 +43,7 @@ class MongoContentStore(ContentStore): ...@@ -43,7 +43,7 @@ class MongoContentStore(ContentStore):
if self.fs.exists({"_id": id}): if self.fs.exists({"_id": id}):
self.fs.delete(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) id = StaticContent.get_id_from_location(location)
try: try:
with self.fs.get(id) as fp: with self.fs.get(id) as fp:
...@@ -52,7 +52,10 @@ class MongoContentStore(ContentStore): ...@@ -52,7 +52,10 @@ class MongoContentStore(ContentStore):
thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None, thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
import_path=fp.import_path if hasattr(fp, 'import_path') else None) import_path=fp.import_path if hasattr(fp, 'import_path') else None)
except NoFile: except NoFile:
raise NotFoundError() if throw_on_not_found:
raise NotFoundError()
else:
return None
def export(self, location, output_directory): def export(self, location, output_directory):
content = self.find(location) content = self.find(location)
......
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
...@@ -10,12 +10,11 @@ import dateutil.parser ...@@ -10,12 +10,11 @@ import dateutil.parser
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.timeparse import parse_time
from xmodule.util.decorators import lazyproperty from xmodule.util.decorators import lazyproperty
from xmodule.graders import grader_from_conf from xmodule.graders import grader_from_conf
import json 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 .fields import Date
from django.utils.timezone import UTC from django.utils.timezone import UTC
from xmodule.util import date_utils from xmodule.util import date_utils
...@@ -154,26 +153,26 @@ class CourseFields(object): ...@@ -154,26 +153,26 @@ class CourseFields(object):
start = Date(help="Start time when this module is visible", scope=Scope.settings) start = Date(help="Start time when this module is visible", scope=Scope.settings)
end = Date(help="Date that this class ends", 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) 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) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings)
display_name = String(help="Display name for this module", 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) 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) 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_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", help="Map of topics names to ids",
scope=Scope.settings 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) 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) 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) 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) 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) 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) 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 = Boolean(scope=Scope.settings, default=True)
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False) allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings) advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
...@@ -648,8 +647,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -648,8 +647,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
def start_date_text(self): def start_date_text(self):
def try_parse_iso_8601(text): def try_parse_iso_8601(text):
try: try:
result = datetime.strptime(text, "%Y-%m-%dT%H:%M") result = Date().from_json(text)
result = result.strftime("%b %d, %Y") if result is None:
result = text.title()
else:
result = result.strftime("%b %d, %Y")
except ValueError: except ValueError:
result = text.title() result = text.title()
...@@ -673,8 +675,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -673,8 +675,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
@property @property
def forum_posts_allowed(self): def forum_posts_allowed(self):
date_proxy = Date()
try: try:
blackout_periods = [(parse_time(start), parse_time(end)) blackout_periods = [(date_proxy.from_json(start),
date_proxy.from_json(end))
for start, end for start, end
in self.discussion_blackouts] in self.discussion_blackouts]
now = datetime.now(UTC()) now = datetime.now(UTC())
...@@ -704,7 +708,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -704,7 +708,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
if self.last_eligible_appointment_date is None: if self.last_eligible_appointment_date is None:
raise ValueError("Last appointment date must be specified") raise ValueError("Last appointment date must be specified")
self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or
datetime.utcfromtimestamp(0)) datetime.fromtimestamp(0, UTC()))
self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date
# do validation within the exam info: # do validation within the exam info:
if self.registration_start_date > self.registration_end_date: if self.registration_start_date > self.registration_end_date:
...@@ -723,7 +727,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -723,7 +727,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
""" """
if key in self.exam_info: if key in self.exam_info:
try: try:
return parse_time(self.exam_info[key]) return Date().from_json(self.exam_info[key])
except ValueError as e: 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) 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) log.warning(msg)
......
...@@ -94,11 +94,11 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor): ...@@ -94,11 +94,11 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
model_data = { model_data = {
'error_msg': str(error_msg), 'error_msg': str(error_msg),
'contents': contents, 'contents': contents,
'display_name': 'Error: ' + location.name 'display_name': 'Error: ' + location.name,
'location': location,
} }
return cls( return cls(
system, system,
location,
model_data, model_data,
) )
......
...@@ -6,8 +6,7 @@ from xblock.core import ModelType ...@@ -6,8 +6,7 @@ from xblock.core import ModelType
import datetime import datetime
import dateutil.parser import dateutil.parser
from xblock.core import Integer, Float, Boolean from pytz import UTC
from django.utils.timezone import UTC
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -16,6 +15,28 @@ class Date(ModelType): ...@@ -16,6 +15,28 @@ class Date(ModelType):
''' '''
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes. 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): def from_json(self, field):
""" """
Parse an optional metadata key containing a time: if present, complain Parse an optional metadata key containing a time: if present, complain
...@@ -27,14 +48,11 @@ class Date(ModelType): ...@@ -27,14 +48,11 @@ class Date(ModelType):
elif field is "": elif field is "":
return None return None
elif isinstance(field, basestring): elif isinstance(field, basestring):
result = dateutil.parser.parse(field) return self._parse_date_wo_default_month_day(field)
if result.tzinfo is None:
result = result.replace(tzinfo=UTC())
return result
elif isinstance(field, (int, long, float)): elif isinstance(field, (int, long, float)):
return datetime.datetime.fromtimestamp(field / 1000, UTC()) return datetime.datetime.fromtimestamp(field / 1000, UTC)
elif isinstance(field, time.struct_time): elif isinstance(field, time.struct_time):
return datetime.datetime.fromtimestamp(time.mktime(field), UTC()) return datetime.datetime.fromtimestamp(time.mktime(field), UTC)
elif isinstance(field, datetime.datetime): elif isinstance(field, datetime.datetime):
return field return field
else: else:
...@@ -93,42 +111,3 @@ class Timedelta(ModelType): ...@@ -93,42 +111,3 @@ class Timedelta(ModelType):
if cur_value > 0: if cur_value > 0:
values.append("%d %s" % (cur_value, attr)) values.append("%d %s" % (cur_value, attr))
return ' '.join(values) 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 Exception:
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
...@@ -183,7 +183,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor): ...@@ -183,7 +183,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
module_class = FolditModule module_class = FolditModule
filename_extension = "xml" filename_extension = "xml"
stores_state = True
has_score = True has_score = True
template_dir_name = "foldit" template_dir_name = "foldit"
......
<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>
\ No newline at end of file
<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>
\ No newline at end of file
...@@ -20,10 +20,25 @@ jasmine.stubbedMetadata = ...@@ -20,10 +20,25 @@ jasmine.stubbedMetadata =
bogus: bogus:
duration: 100 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 = jasmine.stubbedCaption =
start: [0, 10000, 20000, 30000] start: [0, 10000, 20000, 30000]
text: ['Caption at 0', 'Caption at 10000', 'Caption at 20000', 'Caption at 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 = -> jasmine.stubRequests = ->
spyOn($, 'ajax').andCallFake (settings) -> spyOn($, 'ajax').andCallFake (settings) ->
if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/ if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/
...@@ -41,9 +56,12 @@ jasmine.stubRequests = -> ...@@ -41,9 +56,12 @@ jasmine.stubRequests = ->
throw "External request attempted for #{settings.url}, which is not defined." throw "External request attempted for #{settings.url}, which is not defined."
jasmine.stubYoutubePlayer = -> jasmine.stubYoutubePlayer = ->
YT.Player = -> jasmine.createSpyObj 'YT.Player', ['cueVideoById', 'getVideoEmbedCode', YT.Player = ->
obj = jasmine.createSpyObj 'YT.Player', ['cueVideoById', 'getVideoEmbedCode',
'getCurrentTime', 'getPlayerState', 'getVolume', 'setVolume', 'loadVideoById', '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) -> jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
enableParts = [enableParts] unless $.isArray(enableParts) enableParts = [enableParts] unless $.isArray(enableParts)
...@@ -60,6 +78,21 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) -> ...@@ -60,6 +78,21 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
if createPlayer if createPlayer
return new VideoPlayer(video: context.video) 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 # Stub jQuery.cookie
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0' $.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'
......
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
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
describe 'VideoProgressSliderAlpha', ->
beforeEach ->
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
describe 'constructor', ->
describe 'on a non-touch based device', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
it 'build the slider', ->
expect(@progressSlider.slider).toBe '.slider'
expect($.fn.slider).toHaveBeenCalledWith
range: 'min'
change: @progressSlider.onChange
slide: @progressSlider.onSlide
stop: @progressSlider.onStop
it 'build the seek handle', ->
expect(@progressSlider.handle).toBe '.slider .ui-slider-handle'
expect($.fn.qtip).toHaveBeenCalledWith
content: "0:00"
position:
my: 'bottom center'
at: 'top center'
container: @progressSlider.handle
hide:
delay: 700
style:
classes: 'ui-tooltip-slider'
widget: true
describe 'on a touch-based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
spyOn($.fn, 'slider').andCallThrough()
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
it 'does not build the slider', ->
expect(@progressSlider.slider).toBeUndefined
expect($.fn.slider).not.toHaveBeenCalled()
describe 'play', ->
beforeEach ->
spyOn(VideoProgressSliderAlpha.prototype, 'buildSlider').andCallThrough()
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
describe 'when the slider was already built', ->
beforeEach ->
@progressSlider.play()
it 'does not build the slider', ->
expect(@progressSlider.buildSlider.calls.length).toEqual 1
describe 'when the slider was not already built', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@progressSlider.slider = null
@progressSlider.play()
it 'build the slider', ->
expect(@progressSlider.slider).toBe '.slider'
expect($.fn.slider).toHaveBeenCalledWith
range: 'min'
change: @progressSlider.onChange
slide: @progressSlider.onSlide
stop: @progressSlider.onStop
it 'build the seek handle', ->
expect(@progressSlider.handle).toBe '.ui-slider-handle'
expect($.fn.qtip).toHaveBeenCalledWith
content: "0:00"
position:
my: 'bottom center'
at: 'top center'
container: @progressSlider.handle
hide:
delay: 700
style:
classes: 'ui-tooltip-slider'
widget: true
describe 'updatePlayTime', ->
beforeEach ->
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
describe 'when frozen', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@progressSlider.frozen = true
@progressSlider.updatePlayTime 20, 120
it 'does not update the slider', ->
expect($.fn.slider).not.toHaveBeenCalled()
describe 'when not frozen', ->
beforeEach ->
spyOn($.fn, 'slider').andCallThrough()
@progressSlider.frozen = false
@progressSlider.updatePlayTime 20, 120
it 'update the max value of the slider', ->
expect($.fn.slider).toHaveBeenCalledWith 'option', 'max', 120
it 'update current value of the slider', ->
expect($.fn.slider).toHaveBeenCalledWith 'value', 20
describe 'onSlide', ->
beforeEach ->
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
spyOnEvent @progressSlider, 'slide_seek'
@progressSlider.onSlide {}, value: 20
it 'freeze the slider', ->
expect(@progressSlider.frozen).toBeTruthy()
it 'update the tooltip', ->
expect($.fn.qtip).toHaveBeenCalled()
it 'trigger seek event', ->
expect('slide_seek').toHaveBeenTriggeredOn @progressSlider
expect(@player.currentTime).toEqual 20
describe 'onChange', ->
beforeEach ->
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
@progressSlider.onChange {}, value: 20
it 'update the tooltip', ->
expect($.fn.qtip).toHaveBeenCalled()
describe 'onStop', ->
beforeEach ->
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
spyOnEvent @progressSlider, 'slide_seek'
@progressSlider.onStop {}, value: 20
it 'freeze the slider', ->
expect(@progressSlider.frozen).toBeTruthy()
it 'trigger seek event', ->
expect('slide_seek').toHaveBeenTriggeredOn @progressSlider
expect(@player.currentTime).toEqual 20
it 'set timeout to unfreeze the slider', ->
expect(window.setTimeout).toHaveBeenCalledWith jasmine.any(Function), 200
window.setTimeout.mostRecentCall.args[0]()
expect(@progressSlider.frozen).toBeFalsy()
describe 'updateTooltip', ->
beforeEach ->
@player = jasmine.stubVideoPlayerAlpha @
@progressSlider = @player.progressSlider
@progressSlider.updateTooltip 90
it 'set the tooltip value', ->
expect($.fn.qtip).toHaveBeenCalledWith 'option', 'content.text', '1:30'
describe 'VideoSpeedControlAlpha', ->
beforeEach ->
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
jasmine.stubVideoPlayerAlpha @
$('.speeds').remove()
describe 'constructor', ->
describe 'always', ->
beforeEach ->
@speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
it 'add the video speed control to player', ->
secondaryControls = $('.secondary-controls')
li = secondaryControls.find('.video_speeds li')
expect(secondaryControls).toContain '.speeds'
expect(secondaryControls).toContain '.video_speeds'
expect(secondaryControls.find('p.active').text()).toBe '1.0x'
expect(li.filter('.active')).toHaveData 'speed', @speedControl.currentSpeed
expect(li.length).toBe @speedControl.speeds.length
$.each li.toArray().reverse(), (index, link) =>
expect($(link)).toHaveData 'speed', @speedControl.speeds[index]
expect($(link).find('a').text()).toBe @speedControl.speeds[index] + 'x'
it 'bind to change video speed link', ->
expect($('.video_speeds a')).toHandleWith 'click', @speedControl.changeVideoSpeed
describe 'when running on touch based device', ->
beforeEach ->
window.onTouchBasedDevice.andReturn true
$('.speeds').removeClass 'open'
@speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
it 'open the speed toggle on click', ->
$('.speeds').click()
expect($('.speeds')).toHaveClass 'open'
$('.speeds').click()
expect($('.speeds')).not.toHaveClass 'open'
describe 'when running on non-touch based device', ->
beforeEach ->
$('.speeds').removeClass 'open'
@speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
it 'open the speed toggle on hover', ->
$('.speeds').mouseenter()
expect($('.speeds')).toHaveClass 'open'
$('.speeds').mouseleave()
expect($('.speeds')).not.toHaveClass 'open'
it 'close the speed toggle on mouse out', ->
$('.speeds').mouseenter().mouseleave()
expect($('.speeds')).not.toHaveClass 'open'
it 'close the speed toggle on click', ->
$('.speeds').mouseenter().click()
expect($('.speeds')).not.toHaveClass 'open'
describe 'changeVideoSpeed', ->
beforeEach ->
@speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
@video.setSpeed '1.0'
describe 'when new speed is the same', ->
beforeEach ->
spyOnEvent @speedControl, 'speedChange'
$('li[data-speed="1.0"] a').click()
it 'does not trigger speedChange event', ->
expect('speedChange').not.toHaveBeenTriggeredOn @speedControl
describe 'when new speed is not the same', ->
beforeEach ->
@newSpeed = null
$(@speedControl).bind 'speedChange', (event, newSpeed) => @newSpeed = newSpeed
spyOnEvent @speedControl, 'speedChange'
$('li[data-speed="0.75"] a').click()
it 'trigger speedChange event', ->
expect('speedChange').toHaveBeenTriggeredOn @speedControl
expect(@newSpeed).toEqual 0.75
describe 'onSpeedChange', ->
beforeEach ->
@speedControl = new VideoSpeedControlAlpha el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
$('li[data-speed="1.0"] a').addClass 'active'
@speedControl.setSpeed '0.75'
it 'set the new speed as active', ->
expect($('.video_speeds li[data-speed="1.0"]')).not.toHaveClass 'active'
expect($('.video_speeds li[data-speed="0.75"]')).toHaveClass 'active'
expect($('.speeds p.active')).toHaveHtml '0.75x'
describe 'VideoVolumeControlAlpha', ->
beforeEach ->
jasmine.stubVideoPlayerAlpha @
$('.volume').remove()
describe 'constructor', ->
beforeEach ->
spyOn($.fn, 'slider')
@volumeControl = new VideoVolumeControlAlpha el: $('.secondary-controls')
it 'initialize currentVolume to 100', ->
expect(@volumeControl.currentVolume).toEqual 100
it 'render the volume control', ->
expect($('.secondary-controls').html()).toContain """
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
"""
it 'create the slider', ->
expect($.fn.slider).toHaveBeenCalledWith
orientation: "vertical"
range: "min"
min: 0
max: 100
value: 100
change: @volumeControl.onChange
slide: @volumeControl.onChange
it 'bind the volume control', ->
expect($('.volume>a')).toHandleWith 'click', @volumeControl.toggleMute
expect($('.volume')).not.toHaveClass 'open'
$('.volume').mouseenter()
expect($('.volume')).toHaveClass 'open'
$('.volume').mouseleave()
expect($('.volume')).not.toHaveClass 'open'
describe 'onChange', ->
beforeEach ->
spyOnEvent @volumeControl, 'volumeChange'
@newVolume = undefined
@volumeControl = new VideoVolumeControlAlpha el: $('.secondary-controls')
$(@volumeControl).bind 'volumeChange', (event, volume) => @newVolume = volume
describe 'when the new volume is more than 0', ->
beforeEach ->
@volumeControl.onChange undefined, value: 60
it 'set the player volume', ->
expect(@newVolume).toEqual 60
it 'remote muted class', ->
expect($('.volume')).not.toHaveClass 'muted'
describe 'when the new volume is 0', ->
beforeEach ->
@volumeControl.onChange undefined, value: 0
it 'set the player volume', ->
expect(@newVolume).toEqual 0
it 'add muted class', ->
expect($('.volume')).toHaveClass 'muted'
describe 'toggleMute', ->
beforeEach ->
@newVolume = undefined
@volumeControl = new VideoVolumeControlAlpha el: $('.secondary-controls')
$(@volumeControl).bind 'volumeChange', (event, volume) => @newVolume = volume
describe 'when the current volume is more than 0', ->
beforeEach ->
@volumeControl.currentVolume = 60
@volumeControl.toggleMute()
it 'save the previous volume', ->
expect(@volumeControl.previousVolume).toEqual 60
it 'set the player volume', ->
expect(@newVolume).toEqual 0
describe 'when the current volume is 0', ->
beforeEach ->
@volumeControl.currentVolume = 0
@volumeControl.previousVolume = 60
@volumeControl.toggleMute()
it 'set the player volume to previous volume', ->
expect(@newVolume).toEqual 60
describe 'VideoAlpha', ->
metadata =
slowerSpeedYoutubeId:
id: @slowerSpeedYoutubeId
duration: 300
normalSpeedYoutubeId:
id: @normalSpeedYoutubeId
duration: 200
beforeEach ->
jasmine.stubRequests()
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
@videosDefinition = '0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
@slowerSpeedYoutubeId = 'slowerSpeedYoutubeId'
@normalSpeedYoutubeId = 'normalSpeedYoutubeId'
afterEach ->
window.OldVideoPlayerAlpha = undefined
window.onYouTubePlayerAPIReady = undefined
window.onHTML5PlayerAPIReady = undefined
describe 'constructor', ->
describe 'YT', ->
beforeEach ->
loadFixtures 'videoalpha.html'
@stubVideoPlayerAlpha = jasmine.createSpy('VideoPlayerAlpha')
$.cookie.andReturn '0.75'
describe 'by default', ->
beforeEach ->
spyOn(window.VideoAlpha.prototype, 'fetchMetadata').andCallFake ->
@metadata = metadata
@video = new VideoAlpha '#example', @videosDefinition
it 'check videoType', ->
expect(@video.videoType).toEqual('youtube')
it 'reset the current video player', ->
expect(window.OldVideoPlayerAlpha).toBeUndefined()
it 'set the elements', ->
expect(@video.el).toBe '#video_id'
it 'parse the videos', ->
expect(@video.videos).toEqual
'0.75': @slowerSpeedYoutubeId
'1.0': @normalSpeedYoutubeId
it 'fetch the video metadata', ->
expect(@video.fetchMetadata).toHaveBeenCalled
expect(@video.metadata).toEqual metadata
it 'parse available video speeds', ->
expect(@video.speeds).toEqual ['0.75', '1.0']
it 'set current video speed via cookie', ->
expect(@video.speed).toEqual '0.75'
it 'store a reference for this video player in the element', ->
expect($('.video').data('video')).toEqual @video
describe 'when the Youtube API is already available', ->
beforeEach ->
@originalYT = window.YT
window.YT = { Player: true }
spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha)
@video = new VideoAlpha '#example', @videosDefinition
afterEach ->
window.YT = @originalYT
it 'create the Video Player', ->
expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video)
expect(@video.player).toEqual @stubVideoPlayerAlpha
describe 'when the Youtube API is not ready', ->
beforeEach ->
@originalYT = window.YT
window.YT = {}
@video = new VideoAlpha '#example', @videosDefinition
afterEach ->
window.YT = @originalYT
it 'set the callback on the window object', ->
expect(window.onYouTubePlayerAPIReady).toEqual jasmine.any(Function)
describe 'when the Youtube API becoming ready', ->
beforeEach ->
@originalYT = window.YT
window.YT = {}
spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha)
@video = new VideoAlpha '#example', @videosDefinition
window.onYouTubePlayerAPIReady()
afterEach ->
window.YT = @originalYT
it 'create the Video Player for all video elements', ->
expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video)
expect(@video.player).toEqual @stubVideoPlayerAlpha
describe 'HTML5', ->
beforeEach ->
loadFixtures 'videoalpha_html5.html'
@stubVideoPlayerAlpha = jasmine.createSpy('VideoPlayerAlpha')
$.cookie.andReturn '0.75'
describe 'by default', ->
beforeEach ->
@originalHTML5 = window.HTML5Video.Player
window.HTML5Video.Player = undefined
@video = new VideoAlpha '#example', @videosDefinition
afterEach ->
window.HTML5Video.Player = @originalHTML5
it 'check videoType', ->
expect(@video.videoType).toEqual('html5')
it 'reset the current video player', ->
expect(window.OldVideoPlayerAlpha).toBeUndefined()
it 'set the elements', ->
expect(@video.el).toBe '#video_id'
it 'parse the videos if subtitles exist', ->
sub = 'test_name_of_the_subtitles'
expect(@video.videos).toEqual
'0.75': sub
'1.0': sub
'1.25': sub
'1.5': sub
it 'parse the videos if subtitles doesn\'t exist', ->
$('#example').find('.video').data('sub', '')
@video = new VideoAlpha '#example', @videosDefinition
sub = ''
expect(@video.videos).toEqual
'0.75': sub
'1.0': sub
'1.25': sub
'1.5': sub
it 'parse Html5 sources', ->
html5Sources =
mp4: 'test.mp4'
webm: 'test.webm'
ogg: 'test.ogv'
expect(@video.html5Sources).toEqual html5Sources
it 'parse available video speeds', ->
speeds = jasmine.stubbedHtml5Speeds
expect(@video.speeds).toEqual speeds
it 'set current video speed via cookie', ->
expect(@video.speed).toEqual '0.75'
it 'store a reference for this video player in the element', ->
expect($('.video').data('video')).toEqual @video
describe 'when the HTML5 API is already available', ->
beforeEach ->
@originalHTML5Video = window.HTML5Video
window.HTML5Video = { Player: true }
spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha)
@video = new VideoAlpha '#example', @videosDefinition
afterEach ->
window.HTML5Video = @originalHTML5Video
it 'create the Video Player', ->
expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video)
expect(@video.player).toEqual @stubVideoPlayerAlpha
describe 'when the HTML5 API is not ready', ->
beforeEach ->
@originalHTML5Video = window.HTML5Video
window.HTML5Video = {}
@video = new VideoAlpha '#example', @videosDefinition
afterEach ->
window.HTML5Video = @originalHTML5Video
it 'set the callback on the window object', ->
expect(window.onHTML5PlayerAPIReady).toEqual jasmine.any(Function)
describe 'when the HTML5 API becoming ready', ->
beforeEach ->
@originalHTML5Video = window.HTML5Video
window.HTML5Video = {}
spyOn(window, 'VideoPlayerAlpha').andReturn(@stubVideoPlayerAlpha)
@video = new VideoAlpha '#example', @videosDefinition
window.onHTML5PlayerAPIReady()
afterEach ->
window.HTML5Video = @originalHTML5Video
it 'create the Video Player for all video elements', ->
expect(window.VideoPlayerAlpha).toHaveBeenCalledWith(video: @video)
expect(@video.player).toEqual @stubVideoPlayerAlpha
describe 'youtubeId', ->
beforeEach ->
loadFixtures 'videoalpha.html'
$.cookie.andReturn '1.0'
@video = new VideoAlpha '#example', @videosDefinition
describe 'with speed', ->
it 'return the video id for given speed', ->
expect(@video.youtubeId('0.75')).toEqual @slowerSpeedYoutubeId
expect(@video.youtubeId('1.0')).toEqual @normalSpeedYoutubeId
describe 'without speed', ->
it 'return the video id for current speed', ->
expect(@video.youtubeId()).toEqual @normalSpeedYoutubeId
describe 'setSpeed', ->
describe 'YT', ->
beforeEach ->
loadFixtures 'videoalpha.html'
@video = new VideoAlpha '#example', @videosDefinition
describe 'when new speed is available', ->
beforeEach ->
@video.setSpeed '0.75'
it 'set new speed', ->
expect(@video.speed).toEqual '0.75'
it 'save setting for new speed', ->
expect($.cookie).toHaveBeenCalledWith 'video_speed', '0.75', expires: 3650, path: '/'
describe 'when new speed is not available', ->
beforeEach ->
@video.setSpeed '1.75'
it 'set speed to 1.0x', ->
expect(@video.speed).toEqual '1.0'
describe 'HTML5', ->
beforeEach ->
loadFixtures 'videoalpha_html5.html'
@video = new VideoAlpha '#example', @videosDefinition
describe 'when new speed is available', ->
beforeEach ->
@video.setSpeed '0.75'
it 'set new speed', ->
expect(@video.speed).toEqual '0.75'
it 'save setting for new speed', ->
expect($.cookie).toHaveBeenCalledWith 'video_speed', '0.75', expires: 3650, path: '/'
describe 'when new speed is not available', ->
beforeEach ->
@video.setSpeed '1.75'
it 'set speed to 1.0x', ->
expect(@video.speed).toEqual '1.0'
describe 'getDuration', ->
beforeEach ->
loadFixtures 'videoalpha.html'
@video = new VideoAlpha '#example', @videosDefinition
it 'return duration for current video', ->
expect(@video.getDuration()).toEqual 200
describe 'log', ->
beforeEach ->
loadFixtures 'videoalpha.html'
@video = new VideoAlpha '#example', @videosDefinition
spyOn Logger, 'log'
@video.log 'someEvent', {
currentTime: 25,
speed: '1.0'
}
it 'call the logger with valid extra parameters', ->
expect(Logger.log).toHaveBeenCalledWith 'someEvent',
id: 'id'
code: @normalSpeedYoutubeId
currentTime: 25
speed: '1.0'
...@@ -5,7 +5,7 @@ class @Video ...@@ -5,7 +5,7 @@ class @Video
@start = @el.data('start') @start = @el.data('start')
@end = @el.data('end') @end = @el.data('end')
@caption_asset_path = @el.data('caption-asset-path') @caption_asset_path = @el.data('caption-asset-path')
@show_captions = @el.data('show-captions') == "true" @show_captions = @el.data('show-captions')
window.player = null window.player = null
@el = $("#video_#{@id}") @el = $("#video_#{@id}")
@parseVideos @el.data('streams') @parseVideos @el.data('streams')
...@@ -13,7 +13,7 @@ class @Video ...@@ -13,7 +13,7 @@ class @Video
@parseSpeed() @parseSpeed()
$("#video_#{@id}").data('video', this).addClass('video-load-complete') $("#video_#{@id}").data('video', this).addClass('video-load-complete')
@hide_captions = $.cookie('hide_captions') == 'true' @hide_captions = $.cookie('hide_captions') == 'true' or (not @show_captions)
if YT.Player if YT.Player
@embed() @embed()
......
...@@ -37,7 +37,7 @@ class @VideoCaptionAlpha extends SubviewAlpha ...@@ -37,7 +37,7 @@ class @VideoCaptionAlpha extends SubviewAlpha
@loaded = true @loaded = true
if onTouchBasedDevice() if onTouchBasedDevice()
$('.subtitles li').html "Caption will be displayed when you start playing the video." $('.subtitles').html "<li>Caption will be displayed when you start playing the video.</li>"
else else
@renderCaption() @renderCaption()
...@@ -140,12 +140,16 @@ class @VideoCaptionAlpha extends SubviewAlpha ...@@ -140,12 +140,16 @@ class @VideoCaptionAlpha extends SubviewAlpha
hideCaptions: (hide_captions) => hideCaptions: (hide_captions) =>
if hide_captions if hide_captions
type = 'hide_transcript'
@$('.hide-subtitles').attr('title', 'Turn on captions') @$('.hide-subtitles').attr('title', 'Turn on captions')
@el.addClass('closed') @el.addClass('closed')
else else
type = 'show_transcript'
@$('.hide-subtitles').attr('title', 'Turn off captions') @$('.hide-subtitles').attr('title', 'Turn off captions')
@el.removeClass('closed') @el.removeClass('closed')
@scrollCaption() @scrollCaption()
@video.log type,
currentTime: @player.currentTime
$.cookie('hide_captions', hide_captions, expires: 3650, path: '/') $.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
captionHeight: -> captionHeight: ->
......
...@@ -6,7 +6,7 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -6,7 +6,7 @@ class @VideoPlayerAlpha extends SubviewAlpha
# we must pause the player (stop setInterval() method). # we must pause the player (stop setInterval() method).
if (window.OldVideoPlayerAlpha) and (window.OldVideoPlayerAlpha.onPause) if (window.OldVideoPlayerAlpha) and (window.OldVideoPlayerAlpha.onPause)
window.OldVideoPlayerAlpha.onPause() window.OldVideoPlayerAlpha.onPause()
window.OldVideoPlayerAlpha = this window.OldVideoPlayerAlpha = @
if @video.videoType is 'youtube' if @video.videoType is 'youtube'
@PlayerState = YT.PlayerState @PlayerState = YT.PlayerState
...@@ -29,7 +29,7 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -29,7 +29,7 @@ class @VideoPlayerAlpha extends SubviewAlpha
$(@progressSlider).bind('slide_seek', @onSeek) $(@progressSlider).bind('slide_seek', @onSeek)
if @volumeControl if @volumeControl
$(@volumeControl).bind('volumeChange', @onVolumeChange) $(@volumeControl).bind('volumeChange', @onVolumeChange)
$(document).keyup @bindExitFullScreen $(document.documentElement).keyup @bindExitFullScreen
@$('.add-fullscreen').click @toggleFullScreen @$('.add-fullscreen').click @toggleFullScreen
@addToolTip() unless onTouchBasedDevice() @addToolTip() unless onTouchBasedDevice()
...@@ -45,6 +45,8 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -45,6 +45,8 @@ class @VideoPlayerAlpha extends SubviewAlpha
if @video.show_captions is true if @video.show_captions is true
@caption = new VideoCaptionAlpha @caption = new VideoCaptionAlpha
el: @el el: @el
video: @video
player: @
youtubeId: @video.youtubeId('1.0') youtubeId: @video.youtubeId('1.0')
currentSpeed: @currentSpeed() currentSpeed: @currentSpeed()
captionAssetPath: @video.caption_asset_path captionAssetPath: @video.caption_asset_path
...@@ -66,7 +68,19 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -66,7 +68,19 @@ class @VideoPlayerAlpha extends SubviewAlpha
if @video.end if @video.end
# work in AS3, not HMLT5. but iframe use AS3 # work in AS3, not HMLT5. but iframe use AS3
@playerVars.end = @video.end @playerVars.end = @video.end
# There is a bug which prevents YouTube API to correctly set the speed to 1.0 from another speed
# in Firefox when in HTML5 mode. There is a fix which basically reloads the video at speed 1.0
# when this change is requested (instead of simply requesting a speed change to 1.0). This has to
# be done only when the video is being watched in Firefox. We need to figure out what browser is
# currently executing this code.
#
# TODO: Check the status of http://code.google.com/p/gdata-issues/issues/detail?id=4654
# When the YouTube team fixes the API bug, we can remove this temporary bug fix.
@video.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1
if @video.videoType is 'html5' if @video.videoType is 'html5'
@video.playerType = 'browser'
@player = new HTML5Video.Player @video.el, @player = new HTML5Video.Player @video.el,
playerVars: @playerVars, playerVars: @playerVars,
videoSources: @video.html5Sources, videoSources: @video.html5Sources,
...@@ -79,6 +93,7 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -79,6 +93,7 @@ class @VideoPlayerAlpha extends SubviewAlpha
youTubeId = @video.videos['1.0'] youTubeId = @video.videos['1.0']
else else
youTubeId = @video.youtubeId() youTubeId = @video.youtubeId()
@video.playerType = 'youtube'
@player = new YT.Player @video.id, @player = new YT.Player @video.id,
playerVars: @playerVars playerVars: @playerVars
videoId: youTubeId videoId: youTubeId
...@@ -99,7 +114,7 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -99,7 +114,7 @@ class @VideoPlayerAlpha extends SubviewAlpha
@video.log 'load_video' @video.log 'load_video'
if @video.videoType is 'html5' if @video.videoType is 'html5'
@player.setPlaybackRate @video.speed @player.setPlaybackRate @video.speed
unless onTouchBasedDevice() if not onTouchBasedDevice() and $('.video:first').data('autoplay') isnt 'False'
$('.video-load-complete:first').data('video').player.play() $('.video-load-complete:first').data('video').player.play()
onStateChange: (event) => onStateChange: (event) =>
...@@ -235,13 +250,21 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -235,13 +250,21 @@ class @VideoPlayerAlpha extends SubviewAlpha
if @video.videoType is 'youtube' if @video.videoType is 'youtube'
if @video.show_captions is true if @video.show_captions is true
@caption.currentSpeed = newSpeed @caption.currentSpeed = newSpeed
if @video.videoType is 'html5'
@player.setPlaybackRate newSpeed # We request the reloading of the video in the case when YouTube is in Flash player mode,
else if @video.videoType is 'youtube' # or when we are in Firefox, and the new speed is 1.0. The second case is necessary to
# avoid the bug where in Firefox speed switching to 1.0 in HTML5 player mode is handled
# incorrectly by YouTube API.
#
# TODO: Check the status of http://code.google.com/p/gdata-issues/issues/detail?id=4654
# When the YouTube team fixes the API bug, we can remove this temporary bug fix.
if (@video.videoType is 'youtube') or ((@video.isFirefox) and (@video.playerType is 'youtube') and (newSpeed is '1.0'))
if @isPlaying() if @isPlaying()
@player.loadVideoById(@video.youtubeId(), @currentTime) @player.loadVideoById(@video.youtubeId(), @currentTime)
else else
@player.cueVideoById(@video.youtubeId(), @currentTime) @player.cueVideoById(@video.youtubeId(), @currentTime)
else if @video.videoType is 'html5'
@player.setPlaybackRate newSpeed
if @video.videoType is 'youtube' if @video.videoType is 'youtube'
@updatePlayTime @currentTime @updatePlayTime @currentTime
...@@ -262,11 +285,15 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -262,11 +285,15 @@ class @VideoPlayerAlpha extends SubviewAlpha
toggleFullScreen: (event) => toggleFullScreen: (event) =>
event.preventDefault() event.preventDefault()
if @el.hasClass('fullscreen') if @el.hasClass('fullscreen')
type = 'not_fullscreen'
@$('.add-fullscreen').attr('title', 'Fill browser') @$('.add-fullscreen').attr('title', 'Fill browser')
@el.removeClass('fullscreen') @el.removeClass('fullscreen')
else else
type = 'fullscreen'
@el.addClass('fullscreen') @el.addClass('fullscreen')
@$('.add-fullscreen').attr('title', 'Exit fill browser') @$('.add-fullscreen').attr('title', 'Exit fill browser')
@video.log type,
currentTime: @currentTime
if @video.show_captions is true if @video.show_captions is true
@caption.resize() @caption.resize()
...@@ -281,7 +308,7 @@ class @VideoPlayerAlpha extends SubviewAlpha ...@@ -281,7 +308,7 @@ class @VideoPlayerAlpha extends SubviewAlpha
@player.pauseVideo() if @player.pauseVideo @player.pauseVideo() if @player.pauseVideo
duration: -> duration: ->
duration = @player.getDuration() duration = @player.getDuration() if @player.getDuration
if isFinite(duration) is false if isFinite(duration) is false
duration = @video.getDuration() duration = @video.getDuration()
duration duration
......
...@@ -12,7 +12,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha ...@@ -12,7 +12,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha
@buildHandle() @buildHandle()
buildHandle: -> buildHandle: ->
@handle = @$('.slider .ui-slider-handle') @handle = @$('.ui-slider-handle')
@handle.qtip @handle.qtip
content: "#{Time.format(@slider.slider('value'))}" content: "#{Time.format(@slider.slider('value'))}"
position: position:
...@@ -43,7 +43,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha ...@@ -43,7 +43,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha
onStop: (event, ui) => onStop: (event, ui) =>
@frozen = true @frozen = true
$(@).trigger('seek', ui.value) $(@).trigger('slide_seek', ui.value)
setTimeout (=> @frozen = false), 200 setTimeout (=> @frozen = false), 200
updateTooltip: (value)-> updateTooltip: (value)->
......
...@@ -18,14 +18,16 @@ class MakoModuleDescriptor(XModuleDescriptor): ...@@ -18,14 +18,16 @@ class MakoModuleDescriptor(XModuleDescriptor):
Expects the descriptor to have the `mako_template` attribute set Expects the descriptor to have the `mako_template` attribute set
with the name of the template to render, and it will pass with the name of the template to render, and it will pass
the descriptor as the `module` parameter to that template the descriptor as the `module` parameter to that template
MakoModuleDescriptor.__init__ takes the same arguments as xmodule.x_module:XModuleDescriptor.__init__
""" """
def __init__(self, system, location, model_data): def __init__(self, *args, **kwargs):
if getattr(system, 'render_template', None) is None: super(MakoModuleDescriptor, self).__init__(*args, **kwargs)
raise TypeError('{system} must have a render_template function' if getattr(self.runtime, 'render_template', None) is None:
raise TypeError('{runtime} must have a render_template function'
' in order to use a MakoDescriptor'.format( ' in order to use a MakoDescriptor'.format(
system=system)) runtime=self.runtime))
super(MakoModuleDescriptor, self).__init__(system, location, model_data)
def get_context(self): def get_context(self):
""" """
......
...@@ -37,15 +37,23 @@ def get_course_id_no_run(location): ...@@ -37,15 +37,23 @@ def get_course_id_no_run(location):
return "/".join([location.org, location.course]) return "/".join([location.org, location.course])
class InvalidWriteError(Exception):
"""
Raised to indicate that writing to a particular key
in the KeyValueStore is disabled
"""
class MongoKeyValueStore(KeyValueStore): class MongoKeyValueStore(KeyValueStore):
""" """
A KeyValueStore that maps keyed data access to one of the 3 data areas A KeyValueStore that maps keyed data access to one of the 3 data areas
known to the MongoModuleStore (data, children, and metadata) known to the MongoModuleStore (data, children, and metadata)
""" """
def __init__(self, data, children, metadata): def __init__(self, data, children, metadata, location):
self._data = data self._data = data
self._children = children self._children = children
self._metadata = metadata self._metadata = metadata
self._location = location
def get(self, key): def get(self, key):
if key.scope == Scope.children: if key.scope == Scope.children:
...@@ -55,7 +63,9 @@ class MongoKeyValueStore(KeyValueStore): ...@@ -55,7 +63,9 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.settings: elif key.scope == Scope.settings:
return self._metadata[key.field_name] return self._metadata[key.field_name]
elif key.scope == Scope.content: elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict): if key.field_name == 'location':
return self._location
elif key.field_name == 'data' and not isinstance(self._data, dict):
return self._data return self._data
else: else:
return self._data[key.field_name] return self._data[key.field_name]
...@@ -68,7 +78,9 @@ class MongoKeyValueStore(KeyValueStore): ...@@ -68,7 +78,9 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.settings: elif key.scope == Scope.settings:
self._metadata[key.field_name] = value self._metadata[key.field_name] = value
elif key.scope == Scope.content: elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict): if key.field_name == 'location':
self._location = value
elif key.field_name == 'data' and not isinstance(self._data, dict):
self._data = value self._data = value
else: else:
self._data[key.field_name] = value self._data[key.field_name] = value
...@@ -82,7 +94,9 @@ class MongoKeyValueStore(KeyValueStore): ...@@ -82,7 +94,9 @@ class MongoKeyValueStore(KeyValueStore):
if key.field_name in self._metadata: if key.field_name in self._metadata:
del self._metadata[key.field_name] del self._metadata[key.field_name]
elif key.scope == Scope.content: elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict): if key.field_name == 'location':
self._location = Location(None)
elif key.field_name == 'data' and not isinstance(self._data, dict):
self._data = None self._data = None
else: else:
del self._data[key.field_name] del self._data[key.field_name]
...@@ -95,7 +109,9 @@ class MongoKeyValueStore(KeyValueStore): ...@@ -95,7 +109,9 @@ class MongoKeyValueStore(KeyValueStore):
elif key.scope == Scope.settings: elif key.scope == Scope.settings:
return key.field_name in self._metadata return key.field_name in self._metadata
elif key.scope == Scope.content: elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict): if key.field_name == 'location':
return True
elif key.field_name == 'data' and not isinstance(self._data, dict):
return True return True
else: else:
return key.field_name in self._data return key.field_name in self._data
...@@ -171,10 +187,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -171,10 +187,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
definition.get('data', {}), definition.get('data', {}),
definition.get('children', []), definition.get('children', []),
metadata, metadata,
location,
) )
model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location)) model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location))
module = class_(self, location, model_data) module = class_(self, model_data)
if self.cached_metadata is not None: if self.cached_metadata is not None:
# parent container pointers don't differentiate between draft and non-draft # parent container pointers don't differentiate between draft and non-draft
# so when we do the lookup, we should do so with a non-draft location # so when we do the lookup, we should do so with a non-draft location
......
import pymongo import pymongo
from mock import Mock from mock import Mock
from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup, assert_false from nose.tools import assert_equals, assert_raises, assert_not_equals, assert_false
from pprint import pprint from pprint import pprint
from xblock.core import Scope
from xblock.runtime import KeyValueStore, InvalidScopeError
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore
from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.templates import update_templates from xmodule.templates import update_templates
...@@ -114,3 +117,75 @@ class TestMongoModuleStore(object): ...@@ -114,3 +117,75 @@ class TestMongoModuleStore(object):
course.location.org == 'edx' and course.location.course == 'templates', course.location.org == 'edx' and course.location.course == 'templates',
'{0} is a template course'.format(course) '{0} is a template course'.format(course)
) )
class TestMongoKeyValueStore(object):
def setUp(self):
self.data = {'foo': 'foo_value'}
self.location = Location('i4x://org/course/category/name@version')
self.children = ['i4x://org/course/child/a', 'i4x://org/course/child/b']
self.metadata = {'meta': 'meta_val'}
self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata, self.location)
def _check_read(self, key, expected_value):
assert_equals(expected_value, self.kvs.get(key))
assert self.kvs.has(key)
def test_read(self):
assert_equals(self.data['foo'], self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'foo')))
assert_equals(self.location, self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'location')))
assert_equals(self.children, self.kvs.get(KeyValueStore.Key(Scope.children, None, None, 'children')))
assert_equals(self.metadata['meta'], self.kvs.get(KeyValueStore.Key(Scope.settings, None, None, 'meta')))
assert_equals(None, self.kvs.get(KeyValueStore.Key(Scope.parent, None, None, 'parent')))
def test_read_invalid_scope(self):
for scope in (Scope.preferences, Scope.user_info, Scope.user_state):
key = KeyValueStore.Key(scope, None, None, 'foo')
with assert_raises(InvalidScopeError):
self.kvs.get(key)
assert_false(self.kvs.has(key))
def test_read_non_dict_data(self):
self.kvs._data = 'xml_data'
assert_equals('xml_data', self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'data')))
def _check_write(self, key, value):
self.kvs.set(key, value)
assert_equals(value, self.kvs.get(key))
def test_write(self):
yield (self._check_write, KeyValueStore.Key(Scope.content, None, None, 'foo'), 'new_data')
yield (self._check_write, KeyValueStore.Key(Scope.content, None, None, 'location'), Location('i4x://org/course/category/name@new_version'))
yield (self._check_write, KeyValueStore.Key(Scope.children, None, None, 'children'), [])
yield (self._check_write, KeyValueStore.Key(Scope.settings, None, None, 'meta'), 'new_settings')
def test_write_non_dict_data(self):
self.kvs._data = 'xml_data'
self._check_write(KeyValueStore.Key(Scope.content, None, None, 'data'), 'new_data')
def test_write_invalid_scope(self):
for scope in (Scope.preferences, Scope.user_info, Scope.user_state, Scope.parent):
with assert_raises(InvalidScopeError):
self.kvs.set(KeyValueStore.Key(scope, None, None, 'foo'), 'new_value')
def _check_delete_default(self, key, default_value):
self.kvs.delete(key)
assert_equals(default_value, self.kvs.get(key))
assert self.kvs.has(key)
def _check_delete_key_error(self, key):
self.kvs.delete(key)
with assert_raises(KeyError):
self.kvs.get(key)
assert_false(self.kvs.has(key))
def test_delete(self):
yield (self._check_delete_key_error, KeyValueStore.Key(Scope.content, None, None, 'foo'))
yield (self._check_delete_default, KeyValueStore.Key(Scope.content, None, None, 'location'), Location(None))
yield (self._check_delete_default, KeyValueStore.Key(Scope.children, None, None, 'children'), [])
yield (self._check_delete_key_error, KeyValueStore.Key(Scope.settings, None, None, 'meta'))
def test_delete_invalid_scope(self):
for scope in (Scope.preferences, Scope.user_info, Scope.user_state, Scope.parent):
with assert_raises(InvalidScopeError):
self.kvs.delete(KeyValueStore.Key(scope, None, None, 'foo'))
...@@ -463,7 +463,7 @@ class XMLModuleStore(ModuleStoreBase): ...@@ -463,7 +463,7 @@ class XMLModuleStore(ModuleStoreBase):
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix # tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
slug = os.path.splitext(os.path.basename(filepath))[0] slug = os.path.splitext(os.path.basename(filepath))[0]
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug) loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
module = HtmlDescriptor(system, loc, {'data': html}) module = HtmlDescriptor(system, {'data': html, 'location': loc})
# VS[compat]: # VS[compat]:
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them) # Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
# from the course policy # from the course policy
......
...@@ -117,7 +117,6 @@ class CombinedOpenEndedV1Module(): ...@@ -117,7 +117,6 @@ class CombinedOpenEndedV1Module():
self.instance_state = instance_state self.instance_state = instance_state
self.display_name = instance_state.get('display_name', "Open Ended") self.display_name = instance_state.get('display_name', "Open Ended")
self.rewrite_content_links = static_data.get('rewrite_content_links', "")
#We need to set the location here so the child modules can use it #We need to set the location here so the child modules can use it
system.set('location', location) system.set('location', location)
...@@ -354,17 +353,7 @@ class CombinedOpenEndedV1Module(): ...@@ -354,17 +353,7 @@ class CombinedOpenEndedV1Module():
Output: Child task HTML Output: Child task HTML
""" """
self.update_task_states() self.update_task_states()
html = self.current_task.get_html(self.system) return self.current_task.get_html(self.system)
return_html = html
try:
#Without try except block, get this error:
# File "/home/vik/mitx_all/mitx/common/lib/xmodule/xmodule/x_module.py", line 263, in rewrite_content_links
# if link.startswith(XASSET_SRCREF_PREFIX):
# Placing try except so that if the error is fixed, this code will start working again.
return_html = rewrite_links(html, self.rewrite_content_links)
except Exception:
pass
return return_html
def get_current_attributes(self, task_number): def get_current_attributes(self, task_number):
""" """
...@@ -823,7 +812,6 @@ class CombinedOpenEndedV1Descriptor(): ...@@ -823,7 +812,6 @@ class CombinedOpenEndedV1Descriptor():
module_class = CombinedOpenEndedV1Module module_class = CombinedOpenEndedV1Module
filename_extension = "xml" filename_extension = "xml"
stores_state = True
has_score = True has_score = True
template_dir_name = "combinedopenended" template_dir_name = "combinedopenended"
......
...@@ -731,7 +731,6 @@ class OpenEndedDescriptor(): ...@@ -731,7 +731,6 @@ class OpenEndedDescriptor():
module_class = OpenEndedModule module_class = OpenEndedModule
filename_extension = "xml" filename_extension = "xml"
stores_state = True
has_score = True has_score = True
template_dir_name = "openended" template_dir_name = "openended"
......
...@@ -286,7 +286,6 @@ class SelfAssessmentDescriptor(): ...@@ -286,7 +286,6 @@ class SelfAssessmentDescriptor():
module_class = SelfAssessmentModule module_class = SelfAssessmentModule
filename_extension = "xml" filename_extension = "xml"
stores_state = True
has_score = True has_score = True
template_dir_name = "selfassessment" template_dir_name = "selfassessment"
......
...@@ -10,8 +10,8 @@ from .x_module import XModule ...@@ -10,8 +10,8 @@ from .x_module import XModule
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from .timeinfo import TimeInfo from .timeinfo import TimeInfo
from xblock.core import Object, String, Scope from xblock.core import Dict, String, Scope, Boolean, Integer, Float
from xmodule.fields import Date, StringyFloat, StringyInteger, StringyBoolean from xmodule.fields import Date
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService
from open_ended_grading_classes import combined_open_ended_rubric from open_ended_grading_classes import combined_open_ended_rubric
...@@ -21,7 +21,6 @@ log = logging.getLogger(__name__) ...@@ -21,7 +21,6 @@ log = logging.getLogger(__name__)
USE_FOR_SINGLE_LOCATION = False USE_FOR_SINGLE_LOCATION = False
LINK_TO_LOCATION = "" LINK_TO_LOCATION = ""
TRUE_DICT = [True, "True", "true", "TRUE"]
MAX_SCORE = 1 MAX_SCORE = 1
IS_GRADED = False IS_GRADED = False
...@@ -29,7 +28,7 @@ EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please ...@@ -29,7 +28,7 @@ EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please
class PeerGradingFields(object): class PeerGradingFields(object):
use_for_single_location = StringyBoolean( use_for_single_location = Boolean(
display_name="Show Single Problem", display_name="Show Single Problem",
help='When True, only the single problem specified by "Link to Problem Location" is shown. ' help='When True, only the single problem specified by "Link to Problem Location" is shown. '
'When False, a panel is displayed with all problems available for peer grading.', 'When False, a panel is displayed with all problems available for peer grading.',
...@@ -40,22 +39,22 @@ class PeerGradingFields(object): ...@@ -40,22 +39,22 @@ class PeerGradingFields(object):
help='The location of the problem being graded. Only used when "Show Single Problem" is True.', help='The location of the problem being graded. Only used when "Show Single Problem" is True.',
default=LINK_TO_LOCATION, scope=Scope.settings default=LINK_TO_LOCATION, scope=Scope.settings
) )
is_graded = StringyBoolean( is_graded = Boolean(
display_name="Graded", display_name="Graded",
help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.', help='Defines whether the student gets credit for grading this problem. Only used when "Show Single Problem" is True.',
default=IS_GRADED, scope=Scope.settings default=IS_GRADED, scope=Scope.settings
) )
due_date = Date(help="Due date that should be displayed.", default=None, scope=Scope.settings) due_date = Date(help="Due date that should be displayed.", default=None, scope=Scope.settings)
grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings) grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings)
max_grade = StringyInteger( max_grade = Integer(
help="The maximum grade that a student can receive for this problem.", default=MAX_SCORE, help="The maximum grade that a student can receive for this problem.", default=MAX_SCORE,
scope=Scope.settings, values={"min": 0} scope=Scope.settings, values={"min": 0}
) )
student_data_for_location = Object( student_data_for_location = Dict(
help="Student data for a given peer grading problem.", help="Student data for a given peer grading problem.",
scope=Scope.user_state scope=Scope.user_state
) )
weight = StringyFloat( weight = Float(
display_name="Problem Weight", 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.", 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"} scope=Scope.settings, values={"min": 0, "step": ".1"}
...@@ -63,6 +62,9 @@ class PeerGradingFields(object): ...@@ -63,6 +62,9 @@ class PeerGradingFields(object):
class PeerGradingModule(PeerGradingFields, XModule): class PeerGradingModule(PeerGradingFields, XModule):
"""
PeerGradingModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
"""
_VERSION = 1 _VERSION = 1
js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'), js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
...@@ -74,18 +76,17 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -74,18 +76,17 @@ class PeerGradingModule(PeerGradingFields, XModule):
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
def __init__(self, system, location, descriptor, model_data): def __init__(self, *args, **kwargs):
XModule.__init__(self, system, location, descriptor, model_data) super(PeerGradingModule, self).__init__(*args, **kwargs)
# We need to set the location here so the child modules can use it #We need to set the location here so the child modules can use it
system.set('location', location) self.runtime.set('location', self.location)
self.system = system
if (self.system.open_ended_grading_interface): if (self.system.open_ended_grading_interface):
self.peer_gs = PeerGradingService(self.system.open_ended_grading_interface, self.system) self.peer_gs = PeerGradingService(self.system.open_ended_grading_interface, self.system)
else: else:
self.peer_gs = MockPeerGradingService() self.peer_gs = MockPeerGradingService()
if self.use_for_single_location in TRUE_DICT: if self.use_for_single_location:
try: try:
self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location) self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location)
except: except:
...@@ -113,7 +114,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -113,7 +114,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
if not self.ajax_url.endswith("/"): if not self.ajax_url.endswith("/"):
self.ajax_url = self.ajax_url + "/" self.ajax_url = self.ajax_url + "/"
# StringyInteger could return None, so keep this check. # Integer could return None, so keep this check.
if not isinstance(self.max_grade, int): if not isinstance(self.max_grade, int):
raise TypeError("max_grade needs to be an integer.") raise TypeError("max_grade needs to be an integer.")
...@@ -147,7 +148,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -147,7 +148,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
""" """
if self.closed(): if self.closed():
return self.peer_grading_closed() return self.peer_grading_closed()
if self.use_for_single_location not in TRUE_DICT: if not self.use_for_single_location:
return self.peer_grading() return self.peer_grading()
else: else:
return self.peer_grading_problem({'location': self.link_to_location})['html'] return self.peer_grading_problem({'location': self.link_to_location})['html']
...@@ -204,7 +205,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -204,7 +205,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
'score': score, 'score': score,
'total': max_score, 'total': max_score,
} }
if self.use_for_single_location not in TRUE_DICT or self.is_graded not in TRUE_DICT: if not self.use_for_single_location or not self.is_graded:
return score_dict return score_dict
try: try:
...@@ -239,7 +240,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -239,7 +240,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
randomization, and 5/7 on another randomization, and 5/7 on another
''' '''
max_grade = None max_grade = None
if self.use_for_single_location in TRUE_DICT and self.is_graded in TRUE_DICT: if self.use_for_single_location and self.is_graded:
max_grade = self.max_grade max_grade = self.max_grade
return max_grade return max_grade
...@@ -557,7 +558,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -557,7 +558,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
Show individual problem interface Show individual problem interface
''' '''
if get is None or get.get('location') is None: if get is None or get.get('location') is None:
if self.use_for_single_location not in TRUE_DICT: if not self.use_for_single_location:
# This is an error case, because it must be set to use a single location to be called without get parameters # This is an error case, because it must be set to use a single location to be called without get parameters
# This is a dev_facing_error # This is a dev_facing_error
log.error( log.error(
...@@ -603,7 +604,6 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor): ...@@ -603,7 +604,6 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
module_class = PeerGradingModule module_class = PeerGradingModule
filename_extension = "xml" filename_extension = "xml"
stores_state = True
has_score = True has_score = True
always_recalculate_grades = True always_recalculate_grades = True
template_dir_name = "peer_grading" template_dir_name = "peer_grading"
......
...@@ -19,7 +19,7 @@ from xmodule.x_module import XModule ...@@ -19,7 +19,7 @@ from xmodule.x_module import XModule
from xmodule.stringify import stringify_children from xmodule.stringify import stringify_children
from xmodule.mako_module import MakoModuleDescriptor from xmodule.mako_module import MakoModuleDescriptor
from xmodule.xml_module import XmlDescriptor from xmodule.xml_module import XmlDescriptor
from xblock.core import Scope, String, Object, Boolean, List from xblock.core import Scope, String, Dict, Boolean, List
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -30,7 +30,7 @@ class PollFields(object): ...@@ -30,7 +30,7 @@ class PollFields(object):
voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.user_state, default=False) voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.user_state, default=False)
poll_answer = String(help="Student answer", scope=Scope.user_state, default='') poll_answer = String(help="Student answer", scope=Scope.user_state, default='')
poll_answers = Object(help="All possible answers for the poll fro other students", scope=Scope.content) poll_answers = Dict(help="All possible answers for the poll fro other students", scope=Scope.content)
answers = List(help="Poll answers from xml", scope=Scope.content, default=[]) answers = List(help="Poll answers from xml", scope=Scope.content, default=[])
question = String(help="Poll question", scope=Scope.content, default='') question = String(help="Poll question", scope=Scope.content, default='')
...@@ -141,7 +141,6 @@ class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor): ...@@ -141,7 +141,6 @@ class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor):
module_class = PollModule module_class = PollModule
template_dir_name = 'poll' template_dir_name = 'poll'
stores_state = True
@classmethod @classmethod
def definition_from_xml(cls, xml_object, system): def definition_from_xml(cls, xml_object, system):
......
...@@ -94,7 +94,6 @@ class RandomizeDescriptor(RandomizeFields, SequenceDescriptor): ...@@ -94,7 +94,6 @@ class RandomizeDescriptor(RandomizeFields, SequenceDescriptor):
filename_extension = "xml" filename_extension = "xml"
stores_state = True
def definition_to_xml(self, resource_fs): def definition_to_xml(self, resource_fs):
......
...@@ -121,8 +121,6 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): ...@@ -121,8 +121,6 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
mako_template = 'widgets/sequence-edit.html' mako_template = 'widgets/sequence-edit.html'
module_class = SequenceModule module_class = SequenceModule
stores_state = True # For remembering where in the sequence the student is
js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]} js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]}
js_module_name = "SequenceDescriptor" js_module_name = "SequenceDescriptor"
......
...@@ -129,7 +129,7 @@ def _write_js(output_root, classes): ...@@ -129,7 +129,7 @@ def _write_js(output_root, classes):
def _write_files(output_root, contents): def _write_files(output_root, contents):
_ensure_dir(output_root) _ensure_dir(output_root)
for extra_file in set(output_root.files()) - set(contents.keys()): for extra_file in set(output_root.files()) - set(contents.keys()):
extra_file.remove() extra_file.remove_p()
for filename, file_content in contents.iteritems(): for filename, file_content in contents.iteritems():
(output_root / filename).write_bytes(file_content) (output_root / filename).write_bytes(file_content)
......
--- ---
metadata: metadata:
display_name: Video Alpha 1 display_name: Video Alpha
version: 1 version: 1
data: | data: |
<videoalpha show_captions="true" sub="name_of_file" youtube="0.75:JMD_ifUUfsU,1.0:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" > <videoalpha show_captions="true" sub="name_of_file" youtube="0.75:JMD_ifUUfsU,1.0:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" >
......
...@@ -29,10 +29,10 @@ class AnnotatableModuleTestCase(unittest.TestCase): ...@@ -29,10 +29,10 @@ class AnnotatableModuleTestCase(unittest.TestCase):
</annotatable> </annotatable>
''' '''
descriptor = Mock() descriptor = Mock()
module_data = {'data': sample_xml} module_data = {'data': sample_xml, 'location': location}
def setUp(self): def setUp(self):
self.annotatable = AnnotatableModule(test_system(), self.location, self.descriptor, self.module_data) self.annotatable = AnnotatableModule(test_system(), self.descriptor, self.module_data)
def test_annotation_data_attr(self): def test_annotation_data_attr(self):
el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>') el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
......
...@@ -19,6 +19,7 @@ from django.http import QueryDict ...@@ -19,6 +19,7 @@ from django.http import QueryDict
from . import test_system from . import test_system
from pytz import UTC from pytz import UTC
from capa.correctmap import CorrectMap
class CapaFactory(object): class CapaFactory(object):
...@@ -86,7 +87,7 @@ class CapaFactory(object): ...@@ -86,7 +87,7 @@ class CapaFactory(object):
""" """
location = Location(["i4x", "edX", "capa_test", "problem", location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem{0}".format(CapaFactory.next_num())]) "SampleProblem{0}".format(CapaFactory.next_num())])
model_data = {'data': CapaFactory.sample_problem_xml} model_data = {'data': CapaFactory.sample_problem_xml, 'location': location}
if graceperiod is not None: if graceperiod is not None:
model_data['graceperiod'] = graceperiod model_data['graceperiod'] = graceperiod
...@@ -113,7 +114,7 @@ class CapaFactory(object): ...@@ -113,7 +114,7 @@ class CapaFactory(object):
system = test_system() system = test_system()
system.render_template = Mock(return_value="<div>Test Template HTML</div>") system.render_template = Mock(return_value="<div>Test Template HTML</div>")
module = CapaModule(system, location, descriptor, model_data) module = CapaModule(system, descriptor, model_data)
if correct: if correct:
# TODO: probably better to actually set the internal state properly, but... # TODO: probably better to actually set the internal state properly, but...
...@@ -597,6 +598,85 @@ class CapaModuleTest(unittest.TestCase): ...@@ -597,6 +598,85 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the problem was NOT reset # Expect that the problem was NOT reset
self.assertTrue('success' in result and not result['success']) self.assertTrue('success' in result and not result['success'])
def test_rescore_problem_correct(self):
module = CapaFactory.create(attempts=1, done=True)
# Simulate that all answers are marked correct, no matter
# what the input is, by patching LoncapaResponse.evaluate_answers()
with patch('capa.responsetypes.LoncapaResponse.evaluate_answers') as mock_evaluate_answers:
mock_evaluate_answers.return_value = CorrectMap(CapaFactory.answer_key(), 'correct')
result = module.rescore_problem()
# Expect that the problem is marked correct
self.assertEqual(result['success'], 'correct')
# Expect that we get no HTML
self.assertFalse('contents' in result)
# Expect that the number of attempts is not incremented
self.assertEqual(module.attempts, 1)
def test_rescore_problem_incorrect(self):
# make sure it also works when attempts have been reset,
# so add this to the test:
module = CapaFactory.create(attempts=0, done=True)
# Simulate that all answers are marked incorrect, no matter
# what the input is, by patching LoncapaResponse.evaluate_answers()
with patch('capa.responsetypes.LoncapaResponse.evaluate_answers') as mock_evaluate_answers:
mock_evaluate_answers.return_value = CorrectMap(CapaFactory.answer_key(), 'incorrect')
result = module.rescore_problem()
# Expect that the problem is marked incorrect
self.assertEqual(result['success'], 'incorrect')
# Expect that the number of attempts is not incremented
self.assertEqual(module.attempts, 0)
def test_rescore_problem_not_done(self):
# Simulate that the problem is NOT done
module = CapaFactory.create(done=False)
# Try to rescore the problem, and get exception
with self.assertRaises(xmodule.exceptions.NotFoundError):
module.rescore_problem()
def test_rescore_problem_not_supported(self):
module = CapaFactory.create(done=True)
# Try to rescore the problem, and get exception
with patch('capa.capa_problem.LoncapaProblem.supports_rescoring') as mock_supports_rescoring:
mock_supports_rescoring.return_value = False
with self.assertRaises(NotImplementedError):
module.rescore_problem()
def _rescore_problem_error_helper(self, exception_class):
"""Helper to allow testing all errors that rescoring might return."""
# Create the module
module = CapaFactory.create(attempts=1, done=True)
# Simulate answering a problem that raises the exception
with patch('capa.capa_problem.LoncapaProblem.rescore_existing_answers') as mock_rescore:
mock_rescore.side_effect = exception_class(u'test error \u03a9')
result = module.rescore_problem()
# Expect an AJAX alert message in 'success'
expected_msg = u'Error: test error \u03a9'
self.assertEqual(result['success'], expected_msg)
# Expect that the number of attempts is NOT incremented
self.assertEqual(module.attempts, 1)
def test_rescore_problem_student_input_error(self):
self._rescore_problem_error_helper(StudentInputError)
def test_rescore_problem_problem_error(self):
self._rescore_problem_error_helper(LoncapaProblemError)
def test_rescore_problem_response_error(self):
self._rescore_problem_error_helper(ResponseError)
def test_save_problem(self): def test_save_problem(self):
module = CapaFactory.create(done=False) module = CapaFactory.create(done=False)
......
...@@ -175,7 +175,6 @@ class OpenEndedModuleTest(unittest.TestCase): ...@@ -175,7 +175,6 @@ class OpenEndedModuleTest(unittest.TestCase):
'max_score': max_score, 'max_score': max_score,
'display_name': 'Name', 'display_name': 'Name',
'accept_file_upload': False, 'accept_file_upload': False,
'rewrite_content_links': "",
'close_date': None, 'close_date': None,
's3_interface': test_util_open_ended.S3_INTERFACE, 's3_interface': test_util_open_ended.S3_INTERFACE,
'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE,
...@@ -332,7 +331,6 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ...@@ -332,7 +331,6 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
'max_score': max_score, 'max_score': max_score,
'display_name': 'Name', 'display_name': 'Name',
'accept_file_upload': False, 'accept_file_upload': False,
'rewrite_content_links': "",
'close_date': "", 'close_date': "",
's3_interface': test_util_open_ended.S3_INTERFACE, 's3_interface': test_util_open_ended.S3_INTERFACE,
'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE,
...@@ -370,10 +368,15 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ...@@ -370,10 +368,15 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
full_definition = definition_template.format(prompt=prompt, rubric=rubric, task1=task_xml1, task2=task_xml2) full_definition = definition_template.format(prompt=prompt, rubric=rubric, task1=task_xml1, task2=task_xml2)
descriptor = Mock(data=full_definition) descriptor = Mock(data=full_definition)
test_system = test_system() test_system = test_system()
combinedoe_container = CombinedOpenEndedModule(test_system, combinedoe_container = CombinedOpenEndedModule(
location, test_system,
descriptor, descriptor,
model_data={'data': full_definition, 'weight': '1'}) model_data={
'data': full_definition,
'weight': '1',
'location': location
}
)
def setUp(self): def setUp(self):
# TODO: this constructor call is definitely wrong, but neither branch # TODO: this constructor call is definitely wrong, but neither branch
......
...@@ -20,7 +20,7 @@ from . import test_system ...@@ -20,7 +20,7 @@ from . import test_system
class DummySystem(ImportSystem): class DummySystem(ImportSystem):
@patch('xmodule.modulestore.xml.OSFS', lambda dir: MemoryFS()) @patch('xmodule.modulestore.xml.OSFS', lambda directory: MemoryFS())
def __init__(self, load_error_modules): def __init__(self, load_error_modules):
xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules) xmlstore = XMLModuleStore("data_dir", course_dirs=[], load_error_modules=load_error_modules)
...@@ -41,7 +41,8 @@ class DummySystem(ImportSystem): ...@@ -41,7 +41,8 @@ class DummySystem(ImportSystem):
) )
def render_template(self, template, context): def render_template(self, template, context):
raise Exception("Shouldn't be called") raise Exception("Shouldn't be called")
class ConditionalFactory(object): class ConditionalFactory(object):
""" """
...@@ -60,9 +61,9 @@ class ConditionalFactory(object): ...@@ -60,9 +61,9 @@ class ConditionalFactory(object):
source_location = Location(["i4x", "edX", "conditional_test", "problem", "SampleProblem"]) source_location = Location(["i4x", "edX", "conditional_test", "problem", "SampleProblem"])
if source_is_error_module: if source_is_error_module:
# Make an error descriptor and module # Make an error descriptor and module
source_descriptor = NonStaffErrorDescriptor.from_xml('some random xml data', source_descriptor = NonStaffErrorDescriptor.from_xml('some random xml data',
system, system,
org=source_location.org, org=source_location.org,
course=source_location.course, course=source_location.course,
error_msg='random error message') error_msg='random error message')
source_module = source_descriptor.xmodule(system) source_module = source_descriptor.xmodule(system)
...@@ -87,13 +88,13 @@ class ConditionalFactory(object): ...@@ -87,13 +88,13 @@ class ConditionalFactory(object):
# construct conditional module: # construct conditional module:
cond_location = Location(["i4x", "edX", "conditional_test", "conditional", "SampleConditional"]) cond_location = Location(["i4x", "edX", "conditional_test", "conditional", "SampleConditional"])
model_data = {'data': '<conditional/>'} model_data = {'data': '<conditional/>', 'location': cond_location}
cond_module = ConditionalModule(system, cond_location, cond_descriptor, model_data) cond_module = ConditionalModule(system, cond_descriptor, model_data)
# return dict: # return dict:
return {'cond_module': cond_module, return {'cond_module': cond_module,
'source_module': source_module, 'source_module': source_module,
'child_module': child_module } 'child_module': child_module}
class ConditionalModuleBasicTest(unittest.TestCase): class ConditionalModuleBasicTest(unittest.TestCase):
...@@ -106,15 +107,14 @@ class ConditionalModuleBasicTest(unittest.TestCase): ...@@ -106,15 +107,14 @@ class ConditionalModuleBasicTest(unittest.TestCase):
self.test_system = test_system() self.test_system = test_system()
def test_icon_class(self): def test_icon_class(self):
'''verify that get_icon_class works independent of condition satisfaction''' '''verify that get_icon_class works independent of condition satisfaction'''
modules = ConditionalFactory.create(self.test_system) modules = ConditionalFactory.create(self.test_system)
for attempted in ["false", "true"]: for attempted in ["false", "true"]:
for icon_class in [ 'other', 'problem', 'video']: for icon_class in ['other', 'problem', 'video']:
modules['source_module'].is_attempted = attempted modules['source_module'].is_attempted = attempted
modules['child_module'].get_icon_class = lambda: icon_class modules['child_module'].get_icon_class = lambda: icon_class
self.assertEqual(modules['cond_module'].get_icon_class(), icon_class) self.assertEqual(modules['cond_module'].get_icon_class(), icon_class)
def test_get_html(self): def test_get_html(self):
modules = ConditionalFactory.create(self.test_system) modules = ConditionalFactory.create(self.test_system)
# because test_system returns the repr of the context dict passed to render_template, # because test_system returns the repr of the context dict passed to render_template,
...@@ -186,7 +186,6 @@ class ConditionalModuleXmlTest(unittest.TestCase): ...@@ -186,7 +186,6 @@ class ConditionalModuleXmlTest(unittest.TestCase):
if isinstance(descriptor, Location): if isinstance(descriptor, Location):
location = descriptor location = descriptor
descriptor = self.modulestore.get_instance(course.id, location, depth=None) descriptor = self.modulestore.get_instance(course.id, location, depth=None)
location = descriptor.location
return descriptor.xmodule(self.test_system) return descriptor.xmodule(self.test_system)
# edx - HarvardX # edx - HarvardX
...@@ -225,4 +224,3 @@ class ConditionalModuleXmlTest(unittest.TestCase): ...@@ -225,4 +224,3 @@ class ConditionalModuleXmlTest(unittest.TestCase):
print "post-attempt ajax: ", ajax print "post-attempt ajax: ", ajax
html = ajax['html'] html = ajax['html']
self.assertTrue(any(['This is a secret' in item for item in html])) self.assertTrue(any(['This is a secret' in item for item in html]))
"""Tests for classes defined in fields.py.""" """Tests for classes defined in fields.py."""
import datetime import datetime
import unittest import unittest
from xmodule.fields import Date, StringyFloat, StringyInteger, StringyBoolean
from django.utils.timezone import UTC from django.utils.timezone import UTC
from xmodule.fields import Date, Timedelta
from xmodule.timeinfo import TimeInfo
import time
class DateTest(unittest.TestCase): class DateTest(unittest.TestCase):
date = Date() date = Date()
...@@ -51,6 +54,18 @@ class DateTest(unittest.TestCase): ...@@ -51,6 +54,18 @@ class DateTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
datetime.datetime(current.year, 12, 4, 16, 30, tzinfo=UTC()), datetime.datetime(current.year, 12, 4, 16, 30, tzinfo=UTC()),
DateTest.date.from_json("December 4 16:30")) DateTest.date.from_json("December 4 16:30"))
self.assertIsNone(DateTest.date.from_json("12 12:00"))
def test_non_std_from_json(self):
"""
Test the non-standard args being passed to from_json
"""
now = datetime.datetime.now(UTC())
delta = now - datetime.datetime.fromtimestamp(0, UTC())
self.assertEqual(DateTest.date.from_json(delta.total_seconds() * 1000),
now)
yesterday = datetime.datetime.now(UTC()) - datetime.timedelta(days=-1)
self.assertEqual(DateTest.date.from_json(yesterday), yesterday)
def test_to_json(self): def test_to_json(self):
''' '''
...@@ -70,54 +85,31 @@ class DateTest(unittest.TestCase): ...@@ -70,54 +85,31 @@ class DateTest(unittest.TestCase):
"2012-12-31T23:00:01-01:00") "2012-12-31T23:00:01-01:00")
class StringyIntegerTest(unittest.TestCase): class TimedeltaTest(unittest.TestCase):
def assertEquals(self, expected, arg): delta = Timedelta()
self.assertEqual(expected, StringyInteger().from_json(arg))
def test_integer(self):
self.assertEquals(5, '5')
self.assertEquals(0, '0')
self.assertEquals(-1023, '-1023')
def test_none(self):
self.assertEquals(None, None)
self.assertEquals(None, 'abc')
self.assertEquals(None, '[1]')
self.assertEquals(None, '1.023')
class StringyFloatTest(unittest.TestCase):
def assertEquals(self, expected, arg):
self.assertEqual(expected, StringyFloat().from_json(arg))
def test_float(self):
self.assertEquals(.23, '.23')
self.assertEquals(5, '5')
self.assertEquals(0, '0.0')
self.assertEquals(-1023.22, '-1023.22')
def test_none(self):
self.assertEquals(None, None)
self.assertEquals(None, 'abc')
self.assertEquals(None, '[1]')
class StringyBooleanTest(unittest.TestCase): def test_from_json(self):
self.assertEqual(
def assertEquals(self, expected, arg): TimedeltaTest.delta.from_json('1 day 12 hours 59 minutes 59 seconds'),
self.assertEqual(expected, StringyBoolean().from_json(arg)) datetime.timedelta(days=1, hours=12, minutes=59, seconds=59)
)
def test_false(self):
self.assertEquals(False, "false")
self.assertEquals(False, "False")
self.assertEquals(False, "")
self.assertEquals(False, "hahahahah")
def test_true(self):
self.assertEquals(True, "true")
self.assertEquals(True, "TruE")
def test_pass_through(self): self.assertEqual(
self.assertEquals(123, 123) TimedeltaTest.delta.from_json('1 day 46799 seconds'),
datetime.timedelta(days=1, seconds=46799)
)
def test_to_json(self):
self.assertEqual(
'1 days 46799 seconds',
TimedeltaTest.delta.to_json(datetime.timedelta(days=1, hours=12, minutes=59, seconds=59))
)
class TimeInfoTest(unittest.TestCase):
def test_time_info(self):
due_date = datetime.datetime(2000, 4, 14, 10, tzinfo=UTC())
grace_pd_string = '1 day 12 hours 59 minutes 59 seconds'
timeinfo = TimeInfo(due_date, grace_pd_string)
self.assertEqual(timeinfo.close_date,
due_date + Timedelta().from_json(grace_pd_string))
...@@ -8,14 +8,13 @@ from xmodule.modulestore import Location ...@@ -8,14 +8,13 @@ from xmodule.modulestore import Location
from . import test_system from . import test_system
class HtmlModuleSubstitutionTestCase(unittest.TestCase): class HtmlModuleSubstitutionTestCase(unittest.TestCase):
location = Location(["i4x", "edX", "toy", "html", "simple_html"])
descriptor = Mock() descriptor = Mock()
def test_substitution_works(self): def test_substitution_works(self):
sample_xml = '''%%USER_ID%%''' sample_xml = '''%%USER_ID%%'''
module_data = {'data': sample_xml} module_data = {'data': sample_xml}
module_system = test_system() module_system = test_system()
module = HtmlModule(module_system, self.location, self.descriptor, module_data) module = HtmlModule(module_system, self.descriptor, module_data)
self.assertEqual(module.get_html(), str(module_system.anonymous_student_id)) self.assertEqual(module.get_html(), str(module_system.anonymous_student_id))
...@@ -26,7 +25,7 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase): ...@@ -26,7 +25,7 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase):
</html> </html>
''' '''
module_data = {'data': sample_xml} module_data = {'data': sample_xml}
module = HtmlModule(test_system(), self.location, self.descriptor, module_data) module = HtmlModule(test_system(), self.descriptor, module_data)
self.assertEqual(module.get_html(), sample_xml) self.assertEqual(module.get_html(), sample_xml)
...@@ -35,6 +34,6 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase): ...@@ -35,6 +34,6 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase):
module_data = {'data': sample_xml} module_data = {'data': sample_xml}
module_system = test_system() module_system = test_system()
module_system.anonymous_student_id = None module_system.anonymous_student_id = None
module = HtmlModule(module_system, self.location, self.descriptor, module_data) module = HtmlModule(module_system, self.descriptor, module_data)
self.assertEqual(module.get_html(), sample_xml) self.assertEqual(module.get_html(), sample_xml)
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# pylint: disable=W0232
"""Test for Xmodule functional logic.""" """Test for Xmodule functional logic."""
import json import json
import unittest import unittest
from lxml import etree
from xmodule.poll_module import PollDescriptor from xmodule.poll_module import PollDescriptor
from xmodule.conditional_module import ConditionalDescriptor from xmodule.conditional_module import ConditionalDescriptor
from xmodule.word_cloud_module import WordCloudDescriptor from xmodule.word_cloud_module import WordCloudDescriptor
from xmodule.videoalpha_module import VideoAlphaDescriptor from xmodule.tests import test_system
class PostData: class PostData:
"""Class which emulate postdata.""" """Class which emulate postdata."""
...@@ -17,6 +16,7 @@ class PostData: ...@@ -17,6 +16,7 @@ class PostData:
self.dict_data = dict_data self.dict_data = dict_data
def getlist(self, key): def getlist(self, key):
"""Get data by key from `self.dict_data`."""
return self.dict_data.get(key) return self.dict_data.get(key)
...@@ -27,23 +27,26 @@ class LogicTest(unittest.TestCase): ...@@ -27,23 +27,26 @@ class LogicTest(unittest.TestCase):
def setUp(self): def setUp(self):
class EmptyClass: class EmptyClass:
"""Empty object."""
pass pass
self.system = None self.system = test_system()
self.location = None
self.descriptor = EmptyClass() self.descriptor = EmptyClass()
self.xmodule_class = self.descriptor_class.module_class self.xmodule_class = self.descriptor_class.module_class
self.xmodule = self.xmodule_class( self.xmodule = self.xmodule_class(
self.system, self.location, self.system,
self.descriptor, self.raw_model_data self.descriptor,
self.raw_model_data
) )
def ajax_request(self, dispatch, get): def ajax_request(self, dispatch, get):
"""Call Xmodule.handle_ajax."""
return json.loads(self.xmodule.handle_ajax(dispatch, get)) return json.loads(self.xmodule.handle_ajax(dispatch, get))
class PollModuleTest(LogicTest): class PollModuleTest(LogicTest):
"""Logic tests for Poll Xmodule."""
descriptor_class = PollDescriptor descriptor_class = PollDescriptor
raw_model_data = { raw_model_data = {
'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0}, 'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0},
...@@ -69,6 +72,7 @@ class PollModuleTest(LogicTest): ...@@ -69,6 +72,7 @@ class PollModuleTest(LogicTest):
class ConditionalModuleTest(LogicTest): class ConditionalModuleTest(LogicTest):
"""Logic tests for Conditional Xmodule."""
descriptor_class = ConditionalDescriptor descriptor_class = ConditionalDescriptor
def test_ajax_request(self): def test_ajax_request(self):
...@@ -83,6 +87,7 @@ class ConditionalModuleTest(LogicTest): ...@@ -83,6 +87,7 @@ class ConditionalModuleTest(LogicTest):
class WordCloudModuleTest(LogicTest): class WordCloudModuleTest(LogicTest):
"""Logic tests for Word Cloud Xmodule."""
descriptor_class = WordCloudDescriptor descriptor_class = WordCloudDescriptor
raw_model_data = { raw_model_data = {
'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2}, 'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2},
...@@ -91,8 +96,6 @@ class WordCloudModuleTest(LogicTest): ...@@ -91,8 +96,6 @@ class WordCloudModuleTest(LogicTest):
} }
def test_bad_ajax_request(self): def test_bad_ajax_request(self):
# TODO: move top global test. Formalize all our Xmodule errors.
response = self.ajax_request('bad_dispatch', {}) response = self.ajax_request('bad_dispatch', {})
self.assertDictEqual(response, { self.assertDictEqual(response, {
'status': 'fail', 'status': 'fail',
...@@ -118,34 +121,6 @@ class WordCloudModuleTest(LogicTest): ...@@ -118,34 +121,6 @@ class WordCloudModuleTest(LogicTest):
{'text': 'cat', 'size': 12, 'percent': 54.0}] {'text': 'cat', 'size': 12, 'percent': 54.0}]
) )
self.assertEqual(100.0, sum(i['percent'] for i in response['top_words']) ) self.assertEqual(
100.0,
sum(i['percent'] for i in response['top_words']))
class VideoAlphaModuleTest(LogicTest):
descriptor_class = VideoAlphaDescriptor
raw_model_data = {
'data': '<videoalpha />'
}
def test_get_timeframe_no_parameters(self):
xmltree = etree.fromstring('<videoalpha>test</videoalpha>')
output = self.xmodule._get_timeframe(xmltree)
self.assertEqual(output, ('', ''))
def test_get_timeframe_with_one_parameter(self):
xmltree = etree.fromstring(
'<videoalpha start_time="00:04:07">test</videoalpha>'
)
output = self.xmodule._get_timeframe(xmltree)
self.assertEqual(output, (247, ''))
def test_get_timeframe_with_two_parameters(self):
xmltree = etree.fromstring(
'''<videoalpha
start_time="00:04:07"
end_time="13:04:39"
>test</videoalpha>'''
)
output = self.xmodule._get_timeframe(xmltree)
self.assertEqual(output, (247, 47079))
""" Test mako_module.py """
from unittest import TestCase
from mock import Mock
from xmodule.mako_module import MakoModuleDescriptor
class MakoModuleTest(TestCase):
""" Test MakoModuleDescriptor """
def test_render_template_check(self):
mock_system = Mock()
mock_system.render_template = None
with self.assertRaises(TypeError):
MakoModuleDescriptor(mock_system, {})
del mock_system.render_template
with self.assertRaises(TypeError):
MakoModuleDescriptor(mock_system, {})
...@@ -134,6 +134,6 @@ class ModuleProgressTest(unittest.TestCase): ...@@ -134,6 +134,6 @@ class ModuleProgressTest(unittest.TestCase):
''' '''
def test_xmodule_default(self): def test_xmodule_default(self):
'''Make sure default get_progress exists, returns None''' '''Make sure default get_progress exists, returns None'''
xm = x_module.XModule(test_system(), 'a://b/c/d/e', None, {}) xm = x_module.XModule(test_system(), None, {'location': 'a://b/c/d/e'})
p = xm.get_progress() p = xm.get_progress()
self.assertEqual(p, None) self.assertEqual(p, None)
...@@ -47,13 +47,13 @@ class VideoFactory(object): ...@@ -47,13 +47,13 @@ class VideoFactory(object):
"""Method return Video Xmodule instance.""" """Method return Video Xmodule instance."""
location = Location(["i4x", "edX", "video", "default", location = Location(["i4x", "edX", "video", "default",
"SampleProblem1"]) "SampleProblem1"])
model_data = {'data': VideoFactory.sample_problem_xml_youtube} model_data = {'data': VideoFactory.sample_problem_xml_youtube, 'location': location}
descriptor = Mock(weight="1") descriptor = Mock(weight="1")
system = test_system() system = test_system()
system.render_template = lambda template, context: context system.render_template = lambda template, context: context
module = VideoModule(system, location, descriptor, model_data) module = VideoModule(system, descriptor, model_data)
return module return module
......
from .timeparse import parse_timedelta
import logging import logging
from xmodule.fields import Timedelta
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class TimeInfo(object): class TimeInfo(object):
...@@ -14,6 +13,7 @@ class TimeInfo(object): ...@@ -14,6 +13,7 @@ class TimeInfo(object):
self.close_date - the real due date self.close_date - the real due date
""" """
_delta_standin = Timedelta()
def __init__(self, due_date, grace_period_string): def __init__(self, due_date, grace_period_string):
if due_date is not None: if due_date is not None:
self.display_due_date = due_date self.display_due_date = due_date
...@@ -23,7 +23,7 @@ class TimeInfo(object): ...@@ -23,7 +23,7 @@ class TimeInfo(object):
if grace_period_string is not None and self.display_due_date: if grace_period_string is not None and self.display_due_date:
try: try:
self.grace_period = parse_timedelta(grace_period_string) self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string)
self.close_date = self.display_due_date + self.grace_period self.close_date = self.display_due_date + self.grace_period
except: except:
log.error("Error parsing the grace period {0}".format(grace_period_string)) log.error("Error parsing the grace period {0}".format(grace_period_string))
......
...@@ -123,9 +123,6 @@ class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor): ...@@ -123,9 +123,6 @@ class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor):
module_class = TimeLimitModule module_class = TimeLimitModule
# For remembering when a student started, and when they should end
stores_state = True
@classmethod @classmethod
def definition_from_xml(cls, xml_object, system): def definition_from_xml(cls, xml_object, system):
children = [] children = []
......
"""
Helper functions for handling time in the format we like.
"""
import re
from datetime import timedelta, datetime
TIME_FORMAT = "%Y-%m-%dT%H:%M"
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)?)?$')
def parse_time(time_str):
"""
Takes a time string in TIME_FORMAT
Returns it as a time_struct.
Raises ValueError if the string is not in the right format.
"""
return datetime.strptime(time_str, TIME_FORMAT)
def stringify_time(dt):
"""
Convert a datetime struct to a string
"""
return dt.isoformat()
def parse_timedelta(time_str):
"""
time_str: A string with the following components:
<D> day[s] (optional)
<H> hour[s] (optional)
<M> minute[s] (optional)
<S> second[s] (optional)
Returns a datetime.timedelta parsed from the string
"""
parts = TIMEDELTA_REGEX.match(time_str)
if not parts:
return
parts = parts.groupdict()
time_params = {}
for (name, param) in parts.iteritems():
if param:
time_params[name] = int(param)
return timedelta(**time_params)
...@@ -138,5 +138,4 @@ class VideoModule(VideoFields, XModule): ...@@ -138,5 +138,4 @@ class VideoModule(VideoFields, XModule):
class VideoDescriptor(VideoFields, RawDescriptor): class VideoDescriptor(VideoFields, RawDescriptor):
"""Descriptor for `VideoModule`.""" """Descriptor for `VideoModule`."""
module_class = VideoModule module_class = VideoModule
stores_state = True
template_dir_name = "video" template_dir_name = "video"
# pylint: disable=W0223
"""VideoAlpha is ungraded Xmodule for support video content.
It's new improved video module, which support additional feature:
- Can play non-YouTube video sources via in-browser HTML5 video player.
- YouTube defaults to HTML5 mode from the start.
- Speed changes in both YouTube and non-YouTube videos happen via
in-browser HTML5 video method (when in HTML5 mode).
- Navigational subtitles can be disabled altogether via an attribute
in XML.
"""
import json import json
import logging import logging
...@@ -5,6 +17,7 @@ from lxml import etree ...@@ -5,6 +17,7 @@ from lxml import etree
from pkg_resources import resource_string, resource_listdir from pkg_resources import resource_string, resource_listdir
from django.http import Http404 from django.http import Http404
from django.conf import settings
from xmodule.x_module import XModule from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
...@@ -20,6 +33,7 @@ log = logging.getLogger(__name__) ...@@ -20,6 +33,7 @@ log = logging.getLogger(__name__)
class VideoAlphaFields(object): class VideoAlphaFields(object):
"""Fields for `VideoAlphaModule` and `VideoAlphaDescriptor`."""
data = String(help="XML data for the problem", scope=Scope.content) data = String(help="XML data for the problem", scope=Scope.content)
position = Integer(help="Current position in the video", scope=Scope.user_state, default=0) position = Integer(help="Current position in the video", scope=Scope.user_state, default=0)
display_name = String(help="Display name for this module", scope=Scope.settings) display_name = String(help="Display name for this module", scope=Scope.settings)
...@@ -67,7 +81,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule): ...@@ -67,7 +81,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
'ogv': self._get_source(xmltree, ['ogv']), 'ogv': self._get_source(xmltree, ['ogv']),
} }
self.track = self._get_track(xmltree) self.track = self._get_track(xmltree)
self.start_time, self.end_time = self._get_timeframe(xmltree) self.start_time, self.end_time = self.get_timeframe(xmltree)
def _get_source(self, xmltree, exts=None): def _get_source(self, xmltree, exts=None):
"""Find the first valid source, which ends with one of `exts`.""" """Find the first valid source, which ends with one of `exts`."""
...@@ -76,7 +90,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule): ...@@ -76,7 +90,7 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
return self._get_first_external(xmltree, 'source', condition) return self._get_first_external(xmltree, 'source', condition)
def _get_track(self, xmltree): def _get_track(self, xmltree):
# find the first valid track """Find the first valid track."""
return self._get_first_external(xmltree, 'track') return self._get_first_external(xmltree, 'track')
def _get_first_external(self, xmltree, tag, condition=bool): def _get_first_external(self, xmltree, tag, condition=bool):
...@@ -92,39 +106,33 @@ class VideoAlphaModule(VideoAlphaFields, XModule): ...@@ -92,39 +106,33 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
break break
return result return result
def _get_timeframe(self, xmltree): def get_timeframe(self, xmltree):
""" Converts 'start_time' and 'end_time' parameters in video tag to seconds. """ Converts 'start_time' and 'end_time' parameters in video tag to seconds.
If there are no parameters, returns empty string. """ If there are no parameters, returns empty string. """
def parse_time(s): def parse_time(str_time):
"""Converts s in '12:34:45' format to seconds. If s is """Converts s in '12:34:45' format to seconds. If s is
None, returns empty string""" None, returns empty string"""
if s is None: if str_time is None:
return '' return ''
else: else:
x = time.strptime(s, '%H:%M:%S') obj_time = time.strptime(str_time, '%H:%M:%S')
return datetime.timedelta( return datetime.timedelta(
hours=x.tm_hour, hours=obj_time.tm_hour,
minutes=x.tm_min, minutes=obj_time.tm_min,
seconds=x.tm_sec seconds=obj_time.tm_sec
).total_seconds() ).total_seconds()
return parse_time(xmltree.get('start_time')), parse_time(xmltree.get('end_time')) return parse_time(xmltree.get('start_time')), parse_time(xmltree.get('end_time'))
def handle_ajax(self, dispatch, get): def handle_ajax(self, dispatch, get):
"""Handle ajax calls to this video. """This is not being called right now and we raise 404 error."""
TODO (vshnayder): This is not being called right now, so the
position is not being saved.
"""
log.debug(u"GET {0}".format(get)) log.debug(u"GET {0}".format(get))
log.debug(u"DISPATCH {0}".format(dispatch)) log.debug(u"DISPATCH {0}".format(dispatch))
if dispatch == 'goto_position':
self.position = int(float(get['position']))
log.info(u"NEW POSITION {0}".format(self.position))
return json.dumps({'success': True})
raise Http404() raise Http404()
def get_instance_state(self): def get_instance_state(self):
"""Return information about state (position)."""
return json.dumps({'position': self.position}) return json.dumps({'position': self.position})
def get_html(self): def get_html(self):
...@@ -142,16 +150,18 @@ class VideoAlphaModule(VideoAlphaFields, XModule): ...@@ -142,16 +150,18 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
'sources': self.sources, 'sources': self.sources,
'track': self.track, 'track': self.track,
'display_name': self.display_name_with_default, 'display_name': self.display_name_with_default,
# TODO (cpennington): This won't work when we move to data that isn't on the filesystem # This won't work when we move to data that
# isn't on the filesystem
'data_dir': getattr(self, 'data_dir', None), 'data_dir': getattr(self, 'data_dir', None),
'caption_asset_path': caption_asset_path, 'caption_asset_path': caption_asset_path,
'show_captions': self.show_captions, 'show_captions': self.show_captions,
'start': self.start_time, 'start': self.start_time,
'end': self.end_time 'end': self.end_time,
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
}) })
class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor): class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor):
"""Descriptor for `VideoAlphaModule`."""
module_class = VideoAlphaModule module_class = VideoAlphaModule
stores_state = True
template_dir_name = "videoalpha" template_dir_name = "videoalpha"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment