Merge branch 'master' into jnater/playground
Conflicts: common/lib/xmodule/xmodule/modulestore/tests/factories.py lms/djangoapps/courseware/tests/tests.py
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,8 @@
|
||||
.AppleDouble
|
||||
database.sqlite
|
||||
requirements/private.txt
|
||||
lms/envs/private.py
|
||||
cms/envs/private.py
|
||||
courseware/static/js/mathjax/*
|
||||
flushdb.sh
|
||||
build
|
||||
|
||||
1
AUTHORS
1
AUTHORS
@@ -75,3 +75,4 @@ Frances Botsford <frances@edx.org>
|
||||
Jonah Stanley <Jonah_Stanley@brown.edu>
|
||||
Slater Victoroff <slater.r.victoroff@gmail.com>
|
||||
Peter Fogg <peter.p.fogg@gmail.com>
|
||||
Renzo Lucioni <renzolucioni@gmail.com>
|
||||
93
CHANGELOG.rst
Normal file
93
CHANGELOG.rst
Normal file
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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: Developers can now have private Django settings files.
|
||||
|
||||
Common: Safety code added to prevent anything above the vertical level in the
|
||||
course tree from being marked as version='draft'. It will raise an exception if
|
||||
the code tries to so mark a node. We need the backtraces to figure out where
|
||||
this very infrequent intermittent marking was occurring. It was making courses
|
||||
look different in Studio than in LMS.
|
||||
|
||||
Deploy: MKTG_URLS is now read from env.json.
|
||||
|
||||
Common: Theming makes it possible to change the look of the site, from
|
||||
Stanford.
|
||||
|
||||
Common: Accessibility UI fixes.
|
||||
|
||||
Common: The "duplicate email" error message is more informative.
|
||||
|
||||
Studio: Component metadata settings editor.
|
||||
|
||||
Studio: Autoplay for Video Alpha is disabled (only in Studio).
|
||||
|
||||
Studio: Single-click creation for video and discussion components.
|
||||
|
||||
Studio: fixed a bad link in the activation page.
|
||||
|
||||
LMS: Changed the help button text.
|
||||
|
||||
LMS: Fixed failing numeric response (decimal but no trailing digits).
|
||||
|
||||
LMS: XML Error module no longer shows students a stack trace.
|
||||
|
||||
Blades: Videoalpha.
|
||||
|
||||
XModules: Added partial credit for foldit module.
|
||||
|
||||
XModules: Added "randomize" XModule to list of XModule types.
|
||||
|
||||
XModules: Show errors with full descriptors.
|
||||
|
||||
XQueue: Fixed (hopefully) worker crash when the connection to RabbitMQ is
|
||||
dropped suddenly.
|
||||
|
||||
XQueue: Upload file submissions to a specially named bucket in S3.
|
||||
|
||||
Common: Removed request debugger.
|
||||
|
||||
Common: Updated Django to version 1.4.5.
|
||||
|
||||
Common: Updated CodeJail.
|
||||
|
||||
Common: Allow setting of authentication session cookie name.
|
||||
|
||||
@@ -115,7 +115,7 @@ CMS templates. Fortunately, `rake` will do all of this for you! Just run:
|
||||
|
||||
If you are running these commands using the [`zsh`](http://www.zsh.org/) shell,
|
||||
zsh will assume that you are doing
|
||||
[shell globbing](https://en.wikipedia.org/wiki/Glob_(programming)), search for
|
||||
[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for
|
||||
a file in your directory named `django-adminsyncdb` or `django-adminmigrate`,
|
||||
and fail. To fix this, just surround the argument with quotation marks, so that
|
||||
you're running `rake "django-admin[syncdb]"`.
|
||||
|
||||
@@ -28,11 +28,18 @@ Feature: Advanced (manual) course policy
|
||||
|
||||
Scenario: Test how multi-line input appears
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value
|
||||
When I create a JSON object as a value for "discussion_topics"
|
||||
Then it is displayed as formatted
|
||||
And I reload the page
|
||||
Then it is displayed as formatted
|
||||
|
||||
Scenario: Test error if value supplied is of the wrong type
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a JSON object as a value for "display_name"
|
||||
Then I get an error on save
|
||||
And I reload the page
|
||||
Then the policy key value is unchanged
|
||||
|
||||
Scenario: Test automatic quoting of non-JSON values
|
||||
Given I am on the Advanced Course Settings page in Studio
|
||||
When I create a non-JSON value not in quotes
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
#pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_false, assert_equal
|
||||
from nose.tools import assert_false, assert_equal, assert_regexp_matches
|
||||
|
||||
"""
|
||||
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
|
||||
@@ -52,9 +51,9 @@ def edit_the_value_of_a_policy_key_and_save(step):
|
||||
change_display_name_value(step, '"foo"')
|
||||
|
||||
|
||||
@step('I create a JSON object as a value$')
|
||||
def create_JSON_object(step):
|
||||
change_display_name_value(step, '{"key": "value", "key_2": "value_2"}')
|
||||
@step('I create a JSON object as a value for "(.*)"$')
|
||||
def create_JSON_object(step, key):
|
||||
change_value(step, key, '{"key": "value", "key_2": "value_2"}')
|
||||
|
||||
|
||||
@step('I create a non-JSON value not in quotes$')
|
||||
@@ -82,7 +81,12 @@ def they_are_alphabetized(step):
|
||||
|
||||
@step('it is displayed as formatted$')
|
||||
def it_is_formatted(step):
|
||||
assert_policy_entries([DISPLAY_NAME_KEY], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
|
||||
assert_policy_entries(['discussion_topics'], ['{\n "key": "value",\n "key_2": "value_2"\n}'])
|
||||
|
||||
|
||||
@step('I get an error on save$')
|
||||
def error_on_save(step):
|
||||
assert_regexp_matches(world.css_text('#notification-error-description'), 'Incorrect setting format')
|
||||
|
||||
|
||||
@step('it is displayed as a string')
|
||||
@@ -124,11 +128,16 @@ def get_display_name_value():
|
||||
|
||||
|
||||
def change_display_name_value(step, new_value):
|
||||
change_value(step, DISPLAY_NAME_KEY, new_value)
|
||||
|
||||
world.css_find(".CodeMirror")[get_index_of(DISPLAY_NAME_KEY)].click()
|
||||
|
||||
def change_value(step, key, new_value):
|
||||
index = get_index_of(key)
|
||||
world.css_find(".CodeMirror")[index].click()
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
display_name = get_display_name_value()
|
||||
for count in range(len(display_name)):
|
||||
current_value = world.css_find(VALUE_CSS)[index].value
|
||||
g._element.send_keys(Keys.CONTROL + Keys.END)
|
||||
for count in range(len(current_value)):
|
||||
g._element.send_keys(Keys.END, Keys.BACK_SPACE)
|
||||
# Must delete "" before typing the JSON value
|
||||
g._element.send_keys(Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value)
|
||||
|
||||
@@ -52,7 +52,7 @@ Feature: Problem Editor
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
And I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "1"
|
||||
Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "0"
|
||||
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
|
||||
@@ -41,7 +41,9 @@ def i_see_five_settings_with_values(step):
|
||||
|
||||
@step('I can modify the display name')
|
||||
def i_can_modify_the_display_name(step):
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified')
|
||||
# Verifying that the display name can be a string containing a floating point value
|
||||
# (to confirm that we don't throw an error because it is of the wrong type).
|
||||
world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('3.4')
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@@ -172,7 +174,7 @@ def verify_modified_randomization():
|
||||
|
||||
|
||||
def verify_modified_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True)
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True)
|
||||
|
||||
|
||||
def verify_modified_display_name_with_special_chars():
|
||||
|
||||
@@ -26,11 +26,9 @@ Feature: Create Section
|
||||
And I save a new section release date
|
||||
Then the section release date is updated
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver hangs on alert
|
||||
@skip
|
||||
Scenario: Delete section
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I press the "section" delete icon
|
||||
And I confirm the alert
|
||||
When I will confirm all alerts
|
||||
And I press the "section" delete icon
|
||||
Then the section does not exist
|
||||
|
||||
@@ -9,34 +9,34 @@ from nose.tools import assert_equal
|
||||
|
||||
|
||||
@step('I click the new section link$')
|
||||
def i_click_new_section_link(step):
|
||||
def i_click_new_section_link(_step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I enter the section name and click save$')
|
||||
def i_save_section_name(step):
|
||||
def i_save_section_name(_step):
|
||||
save_section_name('My Section')
|
||||
|
||||
|
||||
@step('I enter a section name with a quote and click save$')
|
||||
def i_save_section_name_with_quote(step):
|
||||
def i_save_section_name_with_quote(_step):
|
||||
save_section_name('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I have added a new section$')
|
||||
def i_have_added_new_section(step):
|
||||
def i_have_added_new_section(_step):
|
||||
add_section()
|
||||
|
||||
|
||||
@step('I click the Edit link for the release date$')
|
||||
def i_click_the_edit_link_for_the_release_date(step):
|
||||
def i_click_the_edit_link_for_the_release_date(_step):
|
||||
button_css = 'div.section-published-date a.edit-button'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I save a new section release date$')
|
||||
def i_save_a_new_section_release_date(step):
|
||||
def i_save_a_new_section_release_date(_step):
|
||||
set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013',
|
||||
'input.start-time.time.ui-timepicker-input', '00:00')
|
||||
world.browser.click_link_by_text('Save')
|
||||
@@ -46,35 +46,35 @@ def i_save_a_new_section_release_date(step):
|
||||
|
||||
|
||||
@step('I see my section on the Courseware page$')
|
||||
def i_see_my_section_on_the_courseware_page(step):
|
||||
def i_see_my_section_on_the_courseware_page(_step):
|
||||
see_my_section_on_the_courseware_page('My Section')
|
||||
|
||||
|
||||
@step('I see my section name with a quote on the Courseware page$')
|
||||
def i_see_my_section_name_with_quote_on_the_courseware_page(step):
|
||||
def i_see_my_section_name_with_quote_on_the_courseware_page(_step):
|
||||
see_my_section_on_the_courseware_page('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I click to edit the section name$')
|
||||
def i_click_to_edit_section_name(step):
|
||||
def i_click_to_edit_section_name(_step):
|
||||
world.css_click('span.section-name-span')
|
||||
|
||||
|
||||
@step('I see the complete section name with a quote in the editor$')
|
||||
def i_see_complete_section_name_with_quote_in_editor(step):
|
||||
def i_see_complete_section_name_with_quote_in_editor(_step):
|
||||
css = '.section-name-edit input[type=text]'
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"')
|
||||
|
||||
|
||||
@step('the section does not exist$')
|
||||
def section_does_not_exist(step):
|
||||
css = 'span.section-name-span'
|
||||
assert world.browser.is_element_not_present_by_css(css)
|
||||
def section_does_not_exist(_step):
|
||||
css = 'h3[data-name="My Section"]'
|
||||
assert world.is_css_not_present(css)
|
||||
|
||||
|
||||
@step('I see a release date for my section$')
|
||||
def i_see_a_release_date_for_my_section(step):
|
||||
def i_see_a_release_date_for_my_section(_step):
|
||||
import re
|
||||
|
||||
css = 'span.published-status'
|
||||
@@ -83,26 +83,32 @@ def i_see_a_release_date_for_my_section(step):
|
||||
|
||||
# e.g. 11/06/2012 at 16:25
|
||||
msg = 'Will Release:'
|
||||
date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]'
|
||||
time_regex = '[0-2][0-9]:[0-5][0-9]'
|
||||
match_string = '%s %s at %s' % (msg, date_regex, time_regex)
|
||||
date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}'
|
||||
if not re.search(date_regex, status_text):
|
||||
print status_text, date_regex
|
||||
time_regex = r'[0-2]\d:[0-5]\d( \w{3})?'
|
||||
if not re.search(time_regex, status_text):
|
||||
print status_text, time_regex
|
||||
match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex)
|
||||
if not re.match(match_string, status_text):
|
||||
print status_text, match_string
|
||||
assert re.match(match_string, status_text)
|
||||
|
||||
|
||||
@step('I see a link to create a new subsection$')
|
||||
def i_see_a_link_to_create_a_new_subsection(step):
|
||||
def i_see_a_link_to_create_a_new_subsection(_step):
|
||||
css = 'a.new-subsection-item'
|
||||
assert world.is_css_present(css)
|
||||
|
||||
|
||||
@step('the section release date picker is not visible$')
|
||||
def the_section_release_date_picker_not_visible(step):
|
||||
def the_section_release_date_picker_not_visible(_step):
|
||||
css = 'div.edit-subsection-publish-settings'
|
||||
assert not world.css_visible(css)
|
||||
|
||||
|
||||
@step('the section release date is updated$')
|
||||
def the_section_release_date_is_updated(step):
|
||||
def the_section_release_date_is_updated(_step):
|
||||
css = 'span.published-status'
|
||||
status_text = world.css_text(css)
|
||||
assert_equal(status_text, 'Will Release: 12/25/2013 at 00:00 UTC')
|
||||
|
||||
@@ -1,61 +1,59 @@
|
||||
Feature: Overview Toggle Section
|
||||
In order to quickly view the details of a course's section or to scan the inventory of sections
|
||||
In order to quickly view the details of a course's section or to scan the inventory of sections
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
When I navigate to the course overview page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
When I navigate to the course overview page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Expand /collapse for a course with no sections
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
Scenario: Expand /collapse for a course with no sections
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapse link appears after creating first section of a course
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
Scenario: Collapse link appears after creating first section of a course
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver hangs on alert
|
||||
@skip
|
||||
Scenario: Collapse link is not removed after last section of a course is deleted
|
||||
Given I have a course with 1 section
|
||||
And I navigate to the course overview page
|
||||
When I press the "section" delete icon
|
||||
And I confirm the alert
|
||||
Then I see the "Collapse All Sections" link
|
||||
Scenario: Collapse link is not removed after last section of a course is deleted
|
||||
Given I have a course with 1 section
|
||||
And I navigate to the course overview page
|
||||
When I will confirm all alerts
|
||||
And I press the "section" delete icon
|
||||
Then I see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapsing all sections when all sections are expanded
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And all sections are expanded
|
||||
When I click the "Collapse All Sections" link
|
||||
Then I see the "Expand All Sections" link
|
||||
And all sections are collapsed
|
||||
Scenario: Collapsing all sections when all sections are expanded
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And all sections are expanded
|
||||
When I click the "Collapse All Sections" link
|
||||
Then I see the "Expand All Sections" link
|
||||
And all sections are collapsed
|
||||
|
||||
Scenario: Collapsing all sections when 1 or more sections are already collapsed
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And all sections are expanded
|
||||
When I collapse the first section
|
||||
And I click the "Collapse All Sections" link
|
||||
Then I see the "Expand All Sections" link
|
||||
And all sections are collapsed
|
||||
Scenario: Collapsing all sections when 1 or more sections are already collapsed
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And all sections are expanded
|
||||
When I collapse the first section
|
||||
And I click the "Collapse All Sections" link
|
||||
Then I see the "Expand All Sections" link
|
||||
And all sections are collapsed
|
||||
|
||||
Scenario: Expanding all sections when all sections are collapsed
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And I click the "Collapse All Sections" link
|
||||
When I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
Scenario: Expanding all sections when all sections are collapsed
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And I click the "Collapse All Sections" link
|
||||
When I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Expanding all sections when 1 or more sections are already expanded
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And I click the "Collapse All Sections" link
|
||||
When I expand the first section
|
||||
And I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
Scenario: Expanding all sections when 1 or more sections are already expanded
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
And I click the "Collapse All Sections" link
|
||||
When I expand the first section
|
||||
And I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
@@ -32,12 +32,10 @@ Feature: Create Subsection
|
||||
And I reload the page
|
||||
Then I see the correct dates
|
||||
|
||||
# Skipped because Ubuntu ChromeDriver hangs on alert
|
||||
@skip
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I see my subsection on the Courseware page
|
||||
When I press the "subsection" delete icon
|
||||
And I confirm the alert
|
||||
When I will confirm all alerts
|
||||
And I press the "subsection" delete icon
|
||||
Then the subsection does not exist
|
||||
|
||||
@@ -8,3 +8,8 @@ Feature: Video Component
|
||||
Scenario: Creating a video takes a single click
|
||||
Given I have clicked the new unit button
|
||||
Then creating a video takes a single click
|
||||
|
||||
Scenario: Captions are shown correctly
|
||||
Given I have created a Video component
|
||||
And I have hidden captions
|
||||
Then when I view the video it does not show the captions
|
||||
|
||||
@@ -16,3 +16,13 @@ def video_takes_a_single_click(step):
|
||||
assert(not world.is_css_present('.xmodule_VideoModule'))
|
||||
world.css_click("a[data-location='i4x://edx/templates/video/default']")
|
||||
assert(world.is_css_present('.xmodule_VideoModule'))
|
||||
|
||||
|
||||
@step('I have hidden captions')
|
||||
def set_show_captions_false(step):
|
||||
world.css_click('a.hide-subtitles')
|
||||
|
||||
|
||||
@step('when I view the video it does not show the captions')
|
||||
def does_not_show_captions(step):
|
||||
assert world.css_find('.video')[0].has_class('closed')
|
||||
|
||||
@@ -19,6 +19,24 @@ class ChecklistTestCase(CourseTestCase):
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
return modulestore.get_item(self.course.location).checklists
|
||||
|
||||
|
||||
def compare_checklists(self, persisted, request):
|
||||
"""
|
||||
Handles url expansion as possible difference and descends into guts
|
||||
:param persisted:
|
||||
:param request:
|
||||
"""
|
||||
self.assertEqual(persisted['short_description'], request['short_description'])
|
||||
compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded'))
|
||||
for pers, req in zip(persisted['items'], request['items']):
|
||||
self.assertEqual(pers['short_description'], req['short_description'])
|
||||
self.assertEqual(pers['long_description'], req['long_description'])
|
||||
self.assertEqual(pers['is_checked'], req['is_checked'])
|
||||
if compare_urls:
|
||||
self.assertEqual(pers['action_url'], req['action_url'])
|
||||
self.assertEqual(pers['action_text'], req['action_text'])
|
||||
self.assertEqual(pers['action_external'], req['action_external'])
|
||||
|
||||
def test_get_checklists(self):
|
||||
""" Tests the get checklists method. """
|
||||
checklists_url = get_url_reverse('Checklists', self.course)
|
||||
@@ -31,9 +49,9 @@ class ChecklistTestCase(CourseTestCase):
|
||||
self.course.checklists = None
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
modulestore.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.assertEquals(self.get_persisted_checklists(), None)
|
||||
self.assertEqual(self.get_persisted_checklists(), None)
|
||||
response = self.client.get(checklists_url)
|
||||
self.assertEquals(payload, response.content)
|
||||
self.assertEqual(payload, response.content)
|
||||
|
||||
def test_update_checklists_no_index(self):
|
||||
""" No checklist index, should return all of them. """
|
||||
@@ -43,7 +61,8 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
|
||||
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_index_ignored_on_get(self):
|
||||
""" Checklist index ignored on get. """
|
||||
@@ -53,7 +72,8 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'checklist_index': 1})
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
self.assertListEqual(self.get_persisted_checklists(), returned_checklists)
|
||||
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_post_no_index(self):
|
||||
""" No checklist index, will error on post. """
|
||||
@@ -78,13 +98,18 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'course': self.course.location.course,
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 2})
|
||||
|
||||
def get_first_item(checklist):
|
||||
return checklist['items'][0]
|
||||
|
||||
payload = self.course.checklists[2]
|
||||
self.assertFalse(payload.get('is_checked'))
|
||||
payload['is_checked'] = True
|
||||
self.assertFalse(get_first_item(payload).get('is_checked'))
|
||||
get_first_item(payload)['is_checked'] = True
|
||||
|
||||
returned_checklist = json.loads(self.client.post(update_url, json.dumps(payload), "application/json").content)
|
||||
self.assertTrue(returned_checklist.get('is_checked'))
|
||||
self.assertEqual(self.get_persisted_checklists()[2], returned_checklist)
|
||||
self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
|
||||
pers = self.get_persisted_checklists()
|
||||
self.compare_checklists(pers[2], returned_checklist)
|
||||
|
||||
def test_update_checklists_delete_unsupported(self):
|
||||
""" Delete operation is not supported. """
|
||||
@@ -93,4 +118,4 @@ class ChecklistTestCase(CourseTestCase):
|
||||
'name': self.course.location.name,
|
||||
'checklist_index': 100})
|
||||
response = self.client.delete(update_url)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
self.assertContains(response, 'Unsupported request', status_code=400)
|
||||
|
||||
@@ -37,6 +37,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_TYPES
|
||||
|
||||
from django_comment_common.utils import are_permissions_roles_seeded
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
|
||||
TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
|
||||
TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
@@ -120,6 +123,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_advanced_components_require_two_clicks(self):
|
||||
self.check_components_on_page(['videoalpha'], ['Video Alpha'])
|
||||
|
||||
def test_malformed_edit_unit_request(self):
|
||||
store = modulestore('direct')
|
||||
import_from_xml(store, 'common/test/data/', ['simple'])
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
|
||||
location = descriptor.location._replace(name='.' + descriptor.location.name)
|
||||
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': location.url()}))
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def check_edit_unit(self, test_course_name):
|
||||
import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name])
|
||||
|
||||
@@ -257,7 +271,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
)
|
||||
self.assertTrue(getattr(draft_problem, 'is_draft', False))
|
||||
|
||||
#now requery with depth
|
||||
# now requery with depth
|
||||
course = modulestore('draft').get_item(
|
||||
Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]),
|
||||
depth=None
|
||||
@@ -404,6 +418,32 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()}))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_illegal_draft_crud_ops(self):
|
||||
draft_store = modulestore('draft')
|
||||
direct_store = modulestore('direct')
|
||||
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
location = Location('i4x://MITx/999/chapter/neuvo')
|
||||
self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty',
|
||||
location)
|
||||
direct_store.clone_item('i4x://edx/templates/chapter/Empty', location)
|
||||
self.assertRaises(InvalidVersionError, draft_store.clone_item, location,
|
||||
location)
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_item, location,
|
||||
'chapter data')
|
||||
|
||||
# taking advantage of update_children and other functions never checking that the ids are valid
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_children, location,
|
||||
['i4x://MITx/999/problem/doesntexist'])
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_metadata, location,
|
||||
{'due': datetime.datetime.now(UTC)})
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.unpublish, location)
|
||||
|
||||
|
||||
def test_bad_contentstore_request(self):
|
||||
resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
@@ -499,7 +539,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
on_disk = loads(grading_policy.read())
|
||||
self.assertEqual(on_disk, course.grading_policy)
|
||||
|
||||
#check for policy.json
|
||||
# check for policy.json
|
||||
self.assertTrue(filesystem.exists('policy.json'))
|
||||
|
||||
# compare what's on disk to what we have in the course module
|
||||
|
||||
@@ -54,6 +54,7 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
self.assertEqual(details.course_location, self.course_location, "Location not copied into")
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
|
||||
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
|
||||
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
|
||||
@@ -67,7 +68,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=")
|
||||
# Note, start_date is being initialized someplace. I'm not sure why b/c the default will make no sense.
|
||||
self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
|
||||
@@ -76,6 +76,23 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
|
||||
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
|
||||
|
||||
def test_ooc_encoder(self):
|
||||
"""
|
||||
Test the encoder out of its original constrained purpose to see if it functions for general use
|
||||
"""
|
||||
details = {'location': Location(['tag', 'org', 'course', 'category', 'name']),
|
||||
'number': 1,
|
||||
'string': 'string',
|
||||
'datetime': datetime.datetime.now(UTC())}
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
jsondetails = json.loads(jsondetails)
|
||||
|
||||
self.assertIn('location', jsondetails)
|
||||
self.assertIn('org', jsondetails['location'])
|
||||
self.assertEquals('org', jsondetails['location'][1])
|
||||
self.assertEquals(1, jsondetails['number'])
|
||||
self.assertEqual(jsondetails['string'], 'string')
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
# # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions
|
||||
jsondetails = CourseDetails.fetch(self.course_location)
|
||||
@@ -116,11 +133,8 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(datetime):
|
||||
if datetime is not None:
|
||||
return datetime.isoformat("T")
|
||||
else:
|
||||
return None
|
||||
def convert_datetime_to_iso(dt):
|
||||
return Date().to_json(dt)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
details = CourseDetails.fetch(self.course_location)
|
||||
@@ -151,22 +165,12 @@ class CourseDetailsViewTest(CourseTestCase):
|
||||
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
||||
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
||||
|
||||
@staticmethod
|
||||
def struct_to_datetime(struct_time):
|
||||
return datetime.datetime(*struct_time[:6], tzinfo=UTC())
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
encoded_encoded = date.from_json(encoded[field])
|
||||
dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded)
|
||||
|
||||
if isinstance(details[field], datetime.datetime):
|
||||
dt2 = details[field]
|
||||
else:
|
||||
details_encoded = date.from_json(details[field])
|
||||
dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded)
|
||||
dt1 = date.from_json(encoded[field])
|
||||
dt2 = details[field]
|
||||
|
||||
expected_delta = datetime.timedelta(0)
|
||||
self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context)
|
||||
|
||||
@@ -24,6 +24,30 @@ class LMSLinksTestCase(TestCase):
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
|
||||
self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about")
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'http://www.dummy'})
|
||||
def about_page_marketing_site_remove_http_test(self):
|
||||
""" Get URL for about page, marketing root present, remove http://. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about")
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'https://www.dummy'})
|
||||
def about_page_marketing_site_remove_https_test(self):
|
||||
""" Get URL for about page, marketing root present, remove https://. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about")
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'www.dummyhttps://x'})
|
||||
def about_page_marketing_site_https__edge_test(self):
|
||||
""" Get URL for about page, only remove https:// at the beginning of the string. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
self.assertEquals(self.get_about_page_link(), "//www.dummyhttps://x/courses/mitX/101/test/about")
|
||||
|
||||
@override_settings(MKTG_URLS={})
|
||||
def about_page_marketing_urls_not_set_test(self):
|
||||
""" Error case. ENABLE_MKTG_SITE is True, but there is either no MKTG_URLS, or no MKTG_URLS Root property. """
|
||||
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
|
||||
self.assertEquals(self.get_about_page_link(), None)
|
||||
|
||||
@override_settings(LMS_BASE=None)
|
||||
def about_page_no_lms_base_test(self):
|
||||
""" No LMS_BASE, nor is ENABLE_MKTG_SITE True """
|
||||
|
||||
@@ -4,8 +4,11 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from django.core.urlresolvers import reverse
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
|
||||
|
||||
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#In order to instantiate an open ended tab automatically, need to have this data
|
||||
OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"}
|
||||
@@ -108,9 +111,20 @@ def get_lms_link_for_about_page(location):
|
||||
Returns the url to the course about page from the location tuple.
|
||||
"""
|
||||
if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False):
|
||||
# Root will be "www.edx.org". The complete URL will still not be exactly correct,
|
||||
# but redirects exist from www.edx.org to get to the drupal course about page URL.
|
||||
about_base = settings.MKTG_URLS.get('ROOT')
|
||||
if not hasattr(settings, 'MKTG_URLS'):
|
||||
log.exception("ENABLE_MKTG_SITE is True, but MKTG_URLS is not defined.")
|
||||
about_base = None
|
||||
else:
|
||||
marketing_urls = settings.MKTG_URLS
|
||||
if marketing_urls.get('ROOT', None) is None:
|
||||
log.exception('There is no ROOT defined in MKTG_URLS')
|
||||
about_base = None
|
||||
else:
|
||||
# Root will be "https://www.edx.org". The complete URL will still not be exactly correct,
|
||||
# but redirects exist from www.edx.org to get to the Drupal course about page URL.
|
||||
about_base = marketing_urls.get('ROOT')
|
||||
# Strip off https:// (or http://) to be consistent with the formatting of LMS_BASE.
|
||||
about_base = re.sub(r"^https?://", "", about_base)
|
||||
elif settings.LMS_BASE is not None:
|
||||
about_base = settings.LMS_BASE
|
||||
else:
|
||||
@@ -214,7 +228,7 @@ def add_extra_panel_tab(tab_type, course):
|
||||
course_tabs = copy.copy(course.tabs)
|
||||
changed = False
|
||||
#Check to see if open ended panel is defined in the course
|
||||
|
||||
|
||||
tab_panel = EXTRA_TAB_PANELS.get(tab_type)
|
||||
if tab_panel not in course_tabs:
|
||||
#Add panel to the tabs if it is not defined
|
||||
|
||||
@@ -62,7 +62,7 @@ def asset_index(request, org, course, name):
|
||||
asset_id = asset['_id']
|
||||
display_info = {}
|
||||
display_info['displayname'] = asset['displayname']
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'].timetuple())
|
||||
display_info['uploadDate'] = get_default_time_display(asset['uploadDate'])
|
||||
|
||||
asset_location = StaticContent.compute_location(asset_id['org'], asset_id['course'], asset_id['name'])
|
||||
display_info['url'] = StaticContent.get_url_path_from_location(asset_location)
|
||||
@@ -103,6 +103,9 @@ def upload_asset(request, org, course, coursename):
|
||||
logging.error('Could not find course' + location)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if 'file' not in request.FILES:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# compute a 'filename' which is similar to the location formatting, we're using the 'filename'
|
||||
# nomenclature since we're using a FileSystem paradigm here. We're just imposing
|
||||
# the Location string formatting expectations to keep things a bit more consistent
|
||||
@@ -131,7 +134,7 @@ def upload_asset(request, org, course, coursename):
|
||||
readback = contentstore().find(content.location)
|
||||
|
||||
response_payload = {'displayname': content.name,
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at.timetuple()),
|
||||
'uploadDate': get_default_time_display(readback.last_modified_at),
|
||||
'url': StaticContent.get_url_path_from_location(content.location),
|
||||
'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None,
|
||||
'msg': 'Upload completed'
|
||||
@@ -227,11 +230,9 @@ def generate_export_course(request, org, course, name):
|
||||
root_dir = path(mkdtemp())
|
||||
|
||||
# export out to a tempdir
|
||||
|
||||
logging.debug('root = {0}'.format(root_dir))
|
||||
|
||||
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
|
||||
#filename = root_dir / name + '.tar.gz'
|
||||
|
||||
logging.debug('tar file being generated at {0}'.format(export_file.name))
|
||||
tar_file = tarfile.open(name=export_file.name, mode='w:gz')
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
@@ -50,11 +50,18 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
@login_required
|
||||
def edit_subsection(request, location):
|
||||
# check that we have permissions to edit this item
|
||||
course = get_course_for_item(location)
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
except InvalidLocationError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
try:
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
lms_link = get_lms_link_for_item(location, course_id=course.location.course_id)
|
||||
preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True)
|
||||
@@ -113,11 +120,18 @@ def edit_unit(request, location):
|
||||
|
||||
id: A Location URL
|
||||
"""
|
||||
course = get_course_for_item(location)
|
||||
try:
|
||||
course = get_course_for_item(location)
|
||||
except InvalidLocationError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not has_access(request.user, course.location):
|
||||
raise PermissionDenied()
|
||||
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
try:
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
@@ -32,6 +31,8 @@ from .component import OPEN_ENDED_COMPONENT_TYPES, \
|
||||
NOTE_COMPONENT_TYPES, ADVANCED_COMPONENT_POLICY_KEY
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
# TODO: should explicitly enumerate exports with __all__
|
||||
|
||||
@@ -130,7 +131,7 @@ def create_new_course(request):
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = time.gmtime()
|
||||
new_course.start = datetime.datetime.now(UTC())
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
@@ -357,52 +358,55 @@ def course_advanced_updates(request, org, course, name):
|
||||
# Whether or not to filter the tabs key out of the settings metadata
|
||||
filter_tabs = True
|
||||
|
||||
#Check to see if the user instantiated any advanced components. This is a hack
|
||||
#that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically if the user
|
||||
# Check to see if the user instantiated any advanced components. This is a hack
|
||||
# that does the following :
|
||||
# 1) adds/removes the open ended panel tab to a course automatically if the user
|
||||
# has indicated that they want to edit the combinedopendended or peergrading module
|
||||
# 2) adds/removes the notes panel tab to a course automatically if the user has
|
||||
# indicated that they want the notes module enabled in their course
|
||||
# TODO refactor the above into distinct advanced policy settings
|
||||
if ADVANCED_COMPONENT_POLICY_KEY in request_body:
|
||||
#Get the course so that we can scrape current tabs
|
||||
# Get the course so that we can scrape current tabs
|
||||
course_module = modulestore().get_item(location)
|
||||
|
||||
#Maps tab types to components
|
||||
# Maps tab types to components
|
||||
tab_component_map = {
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'open_ended': OPEN_ENDED_COMPONENT_TYPES,
|
||||
'notes': NOTE_COMPONENT_TYPES,
|
||||
}
|
||||
|
||||
#Check to see if the user instantiated any notes or open ended components
|
||||
# Check to see if the user instantiated any notes or open ended components
|
||||
for tab_type in tab_component_map.keys():
|
||||
component_types = tab_component_map.get(tab_type)
|
||||
found_ac_type = False
|
||||
for ac_type in component_types:
|
||||
if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]:
|
||||
#Add tab to the course if needed
|
||||
# Add tab to the course if needed
|
||||
changed, new_tabs = add_extra_panel_tab(tab_type, course_module)
|
||||
#If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
# If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should not be filtered out of the metadata
|
||||
# Indicate that tabs should not be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
#Set this flag to avoid the tab removal code below.
|
||||
# Set this flag to avoid the tab removal code below.
|
||||
found_ac_type = True
|
||||
break
|
||||
#If we did not find a module type in the advanced settings,
|
||||
# If we did not find a module type in the advanced settings,
|
||||
# we may need to remove the tab from the course.
|
||||
if not found_ac_type:
|
||||
#Remove tab from the course if needed
|
||||
# Remove tab from the course if needed
|
||||
changed, new_tabs = remove_extra_panel_tab(tab_type, course_module)
|
||||
if changed:
|
||||
course_module.tabs = new_tabs
|
||||
request_body.update({'tabs': new_tabs})
|
||||
#Indicate that tabs should *not* be filtered out of the metadata
|
||||
# Indicate that tabs should *not* be filtered out of the metadata
|
||||
filter_tabs = False
|
||||
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
try:
|
||||
response_json = json.dumps(CourseMetadata.update_from_json(location,
|
||||
request_body,
|
||||
filter_tabs=filter_tabs))
|
||||
except (TypeError, ValueError), e:
|
||||
return HttpResponseBadRequest("Incorrect setting format. " + str(e), content_type="text/plain")
|
||||
|
||||
return HttpResponse(response_json, mimetype="application/json")
|
||||
|
||||
@@ -3,26 +3,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
import time
|
||||
from contentstore.utils import get_modulestore
|
||||
from models.settings import course_grading
|
||||
from contentstore.utils import update_item
|
||||
from xmodule.fields import Date
|
||||
import re
|
||||
import logging
|
||||
import datetime
|
||||
|
||||
|
||||
class CourseDetails(object):
|
||||
def __init__(self, location):
|
||||
self.course_location = location # a Location obj
|
||||
self.course_location = location # a Location obj
|
||||
self.start_date = None # 'start'
|
||||
self.end_date = None # 'end'
|
||||
self.end_date = None # 'end'
|
||||
self.enrollment_start = None
|
||||
self.enrollment_end = None
|
||||
self.syllabus = None # a pdf file asset
|
||||
self.overview = "" # html to render as the overview
|
||||
self.intro_video = None # a video pointer
|
||||
self.effort = None # int hours/week
|
||||
self.syllabus = None # a pdf file asset
|
||||
self.overview = "" # html to render as the overview
|
||||
self.intro_video = None # a video pointer
|
||||
self.effort = None # int hours/week
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, course_location):
|
||||
@@ -73,9 +73,9 @@ class CourseDetails(object):
|
||||
"""
|
||||
Decode the json into CourseDetails and save any changed attrs to the db
|
||||
"""
|
||||
## TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
|
||||
# TODO make it an error for this to be undefined & for it to not be retrievable from modulestore
|
||||
course_location = jsondict['course_location']
|
||||
## Will probably want to cache the inflight courses because every blur generates an update
|
||||
# Will probably want to cache the inflight courses because every blur generates an update
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
dirty = False
|
||||
@@ -181,7 +181,7 @@ class CourseSettingsEncoder(json.JSONEncoder):
|
||||
return obj.__dict__
|
||||
elif isinstance(obj, Location):
|
||||
return obj.dict()
|
||||
elif isinstance(obj, time.struct_time):
|
||||
elif isinstance(obj, datetime.datetime):
|
||||
return Date().to_json(obj)
|
||||
else:
|
||||
return JSONEncoder.default(self, obj)
|
||||
|
||||
@@ -23,7 +23,7 @@ MODULESTORE_OPTIONS = {
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'acceptance_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string'
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
|
||||
@@ -103,6 +103,7 @@ DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL)
|
||||
DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL)
|
||||
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
|
||||
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
|
||||
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
|
||||
|
||||
#Timezone overrides
|
||||
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
|
||||
|
||||
@@ -25,6 +25,7 @@ Longer TODO:
|
||||
|
||||
import sys
|
||||
import lms.envs.common
|
||||
from lms.envs.common import USE_TZ
|
||||
from path import path
|
||||
|
||||
############################ FEATURE CONFIGURATION #############################
|
||||
@@ -34,8 +35,8 @@ MITX_FEATURES = {
|
||||
'GITHUB_PUSH': False,
|
||||
'ENABLE_DISCUSSION_SERVICE': False,
|
||||
'AUTH_USE_MIT_CERTIFICATES': False,
|
||||
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
|
||||
'STAFF_EMAIL': '', # email address for staff (eg to request course creation)
|
||||
'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)
|
||||
'STUDIO_NPS_SURVEY': True,
|
||||
'SEGMENT_IO': True,
|
||||
|
||||
@@ -183,7 +184,7 @@ STATICFILES_DIRS = [
|
||||
|
||||
# Locale/Internationalization
|
||||
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
@@ -335,3 +336,14 @@ INSTALLED_APPS = (
|
||||
################# EDX MARKETING SITE ##################################
|
||||
|
||||
EDXMKTG_COOKIE_NAME = 'edxloggedin'
|
||||
MKTG_URLS = {}
|
||||
MKTG_URL_LINK_MAP = {
|
||||
'ABOUT': 'about_edx',
|
||||
'CONTACT': 'contact',
|
||||
'FAQ': 'help_edx',
|
||||
'COURSES': 'courses',
|
||||
'ROOT': 'root',
|
||||
'TOS': 'tos',
|
||||
'HONOR': 'honor',
|
||||
'PRIVACY': 'privacy_edx',
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ modulestore_options = {
|
||||
'db': 'xmodule',
|
||||
'collection': 'modulestore',
|
||||
'fs_root': GITHUB_REPO_ROOT,
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string'
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
@@ -64,7 +64,7 @@ REPOS = {
|
||||
},
|
||||
'content-mit-6002x': {
|
||||
'branch': 'master',
|
||||
#'origin': 'git@github.com:MITx/6002x-fall-2012.git',
|
||||
# 'origin': 'git@github.com:MITx/6002x-fall-2012.git',
|
||||
'origin': 'git@github.com:MITx/content-mit-6002x.git',
|
||||
},
|
||||
'6.00x': {
|
||||
@@ -165,3 +165,11 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True
|
||||
|
||||
# segment-io key for dev
|
||||
SEGMENT_IO_KEY = 'mty8edrrsg'
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Lastly, see if the developer has any local overrides.
|
||||
try:
|
||||
from .private import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -48,7 +48,7 @@ MODULESTORE_OPTIONS = {
|
||||
'db': 'test_xmodule',
|
||||
'collection': 'test_modulestore',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string',
|
||||
'render_template': 'mitxmako.shortcuts.render_to_string'
|
||||
}
|
||||
|
||||
MODULESTORE = {
|
||||
@@ -121,7 +121,7 @@ CELERY_RESULT_BACKEND = 'cache'
|
||||
BROKER_TRANSPORT = 'memory'
|
||||
|
||||
################### Make tests faster
|
||||
#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
|
||||
# http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
|
||||
PASSWORD_HASHERS = (
|
||||
'django.contrib.auth.hashers.SHA1PasswordHasher',
|
||||
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||
|
||||
11
cms/pydev_manage.py
Normal file
11
cms/pydev_manage.py
Normal file
@@ -0,0 +1,11 @@
|
||||
'''
|
||||
Used for pydev eclipse. Should be innocuous for everyone else.
|
||||
Created on May 8, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
#!/home/<username>/mitx_all/python/bin/python
|
||||
from django.core import management
|
||||
|
||||
if __name__ == '__main__':
|
||||
management.execute_from_command_line()
|
||||
@@ -411,8 +411,12 @@ function showFileSelectionMenu(e) {
|
||||
}
|
||||
|
||||
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($('.file-input').val().replace('C:\\fakepath\\', ''));
|
||||
$('.upload-modal .file-name').html(files[0].name);
|
||||
$('.upload-modal .file-chooser').ajaxSubmit({
|
||||
beforeSend: resetUploadBar,
|
||||
uploadProgress: showUploadFeedback,
|
||||
|
||||
@@ -14,7 +14,7 @@ body {
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
body, input {
|
||||
body, input, button {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import logging
|
||||
from xmodule.util.date_utils import get_time_struct_display
|
||||
from xmodule.util.date_utils import get_default_time_display
|
||||
%>
|
||||
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
@@ -36,11 +36,15 @@
|
||||
<div class="datepair" data-language="javascript">
|
||||
<div class="field field-start-date">
|
||||
<label for="start_date">Release Day</label>
|
||||
<input type="text" id="start_date" name="start_date" value="${get_time_struct_display(subsection.lms.start, '%m/%d/%Y')}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="start_date" name="start_date"
|
||||
value="${subsection.lms.start.strftime('%m/%d/%Y') if subsection.lms.start else ''}"
|
||||
placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field field-start-time">
|
||||
<label for="start_time">Release Time (<abbr title="Coordinated Universal Time">UTC</abbr>)</label>
|
||||
<input type="text" id="start_time" name="start_time" value="${get_time_struct_display(subsection.lms.start, '%H:%M')}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="start_time" name="start_time"
|
||||
value="${subsection.lms.start.strftime('%H:%M') if subsection.lms.start else ''}"
|
||||
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</div>
|
||||
</div>
|
||||
% if subsection.lms.start != parent_item.lms.start and subsection.lms.start:
|
||||
@@ -48,7 +52,7 @@
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset.
|
||||
% else:
|
||||
<p class="notice">The date above differs from the release date of ${parent_item.display_name_with_default} –
|
||||
${get_time_struct_display(parent_item.lms.start, '%m/%d/%Y at %H:%M UTC')}.
|
||||
${get_default_time_display(parent_item.lms.start)}.
|
||||
% endif
|
||||
<a href="#" class="sync-date no-spinner">Sync to ${parent_item.display_name_with_default}.</a></p>
|
||||
% endif
|
||||
@@ -65,11 +69,15 @@
|
||||
<div class="datepair date-setter">
|
||||
<div class="field field-start-date">
|
||||
<label for="due_date">Due Day</label>
|
||||
<input type="text" id="due_date" name="due_date" value="${get_time_struct_display(subsection.lms.due, '%m/%d/%Y')}" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
<input type="text" id="due_date" name="due_date"
|
||||
value="${subsection.lms.due.strftime('%m/%d/%Y') if subsection.lms.due else ''}"
|
||||
placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
</div>
|
||||
<div class="field field-start-time">
|
||||
<label for="due_time">Due Time (<abbr title="Coordinated Universal Time">UTC</abbr>)</label>
|
||||
<input type="text" id="due_time" name="due_time" value="${get_time_struct_display(subsection.lms.due, '%H:%M')}" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
<input type="text" id="due_time" name="due_time"
|
||||
value="${subsection.lms.due.strftime('%H:%M') if subsection.lms.due else ''}"
|
||||
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</div>
|
||||
<a href="#" class="remove-date">Remove due date</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import logging
|
||||
from xmodule.util.date_utils import get_time_struct_display
|
||||
from xmodule.util import date_utils
|
||||
%>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Course Outline</%block>
|
||||
@@ -154,14 +154,19 @@
|
||||
<h3 class="section-name" data-name="${section.display_name_with_default | h}"></h3>
|
||||
<div class="section-published-date">
|
||||
<%
|
||||
start_date_str = get_time_struct_display(section.lms.start, '%m/%d/%Y')
|
||||
start_time_str = get_time_struct_display(section.lms.start, '%H:%M')
|
||||
if section.lms.start is not None:
|
||||
start_date_str = section.lms.start.strftime('%m/%d/%Y')
|
||||
start_time_str = section.lms.start.strftime('%H:%M')
|
||||
else:
|
||||
start_date_str = ''
|
||||
start_time_str = ''
|
||||
%>
|
||||
%if section.lms.start is None:
|
||||
<span class="published-status">This section has not been released.</span>
|
||||
<a href="#" class="schedule-button" data-date="" data-time="" data-id="${section.location}">Schedule</a>
|
||||
%else:
|
||||
<span class="published-status"><strong>Will Release:</strong> ${get_time_struct_display(section.lms.start, '%m/%d/%Y at %H:%M UTC')}</span>
|
||||
<span class="published-status"><strong>Will Release:</strong>
|
||||
${date_utils.get_default_time_display(section.lms.start)}</span>
|
||||
<a href="#" class="edit-button" data-date="${start_date_str}" data-time="${start_time_str}" data-id="${section.location}">Edit</a>
|
||||
%endif
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ Namespace defining common fields used by Studio for all blocks
|
||||
import datetime
|
||||
|
||||
from xblock.core import Namespace, Scope, ModelType, String
|
||||
from xmodule.fields import StringyBoolean
|
||||
|
||||
|
||||
class DateTuple(ModelType):
|
||||
@@ -28,4 +27,3 @@ class CmsNamespace(Namespace):
|
||||
"""
|
||||
published_date = DateTuple(help="Date when the module was published", scope=Scope.settings)
|
||||
published_by = String(help="Id of the user who published this module", scope=Scope.settings)
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.http import HttpResponse, Http404, HttpResponseNotModified
|
||||
from django.http import HttpResponse, HttpResponseNotModified
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG
|
||||
@@ -20,7 +17,7 @@ class StaticContentServer(object):
|
||||
# return a 'Bad Request' to browser as we have a malformed Location
|
||||
response = HttpResponse()
|
||||
response.status_code = 400
|
||||
return response
|
||||
return response
|
||||
|
||||
# first look in our cache so we don't have to round-trip to the DB
|
||||
content = get_cached_content(loc)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from mitxmako.shortcuts import marketing_link
|
||||
from mock import patch
|
||||
from util.testing import UrlResetMixin
|
||||
|
||||
|
||||
class ShortcutsTests(TestCase):
|
||||
class ShortcutsTests(UrlResetMixin, TestCase):
|
||||
"""
|
||||
Test the mitxmako shortcuts file
|
||||
"""
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'})
|
||||
@override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'})
|
||||
def test_marketing_link(self):
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
import datetime
|
||||
|
||||
import json
|
||||
from pytz import UTC
|
||||
|
||||
middleware.MakoMiddleware()
|
||||
|
||||
@@ -32,7 +33,7 @@ def group_from_value(groups, v):
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = \
|
||||
help = \
|
||||
''' Assign users to test groups. Takes a list
|
||||
of groups:
|
||||
a:0.3,b:0.4,c:0.3 file.txt "Testing something"
|
||||
@@ -75,7 +76,7 @@ Will log what happened to file.txt.
|
||||
utg = UserTestGroup()
|
||||
utg.name = group
|
||||
utg.description = json.dumps({"description": args[2]},
|
||||
{"time": datetime.datetime.utcnow().isoformat()})
|
||||
{"time": datetime.datetime.now(UTC).isoformat()})
|
||||
group_objects[group] = utg
|
||||
group_objects[group].save()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -58,7 +59,7 @@ class Command(BaseCommand):
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.utcnow()
|
||||
uploaded_at = datetime.now(UTC)
|
||||
|
||||
# if specified destination is an existing directory, then
|
||||
# create a filename for it automatically. If it doesn't exist,
|
||||
@@ -100,7 +101,7 @@ class Command(BaseCommand):
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcu in TestCenterUser.objects.order_by('id'):
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
if tcu.needs_uploading: # or dump_all
|
||||
record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
|
||||
for csv_field, model_field
|
||||
in Command.CSV_TO_MODEL_FIELDS.items())
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -51,7 +52,7 @@ class Command(BaseCommand):
|
||||
def handle(self, **options):
|
||||
# update time should use UTC in order to be comparable to the user_updated_at
|
||||
# field
|
||||
uploaded_at = datetime.utcnow()
|
||||
uploaded_at = datetime.now(UTC)
|
||||
|
||||
# if specified destination is an existing directory, then
|
||||
# create a filename for it automatically. If it doesn't exist,
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
|
||||
from student.models import TestCenterUser, TestCenterRegistration
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -68,7 +69,7 @@ class Command(BaseCommand):
|
||||
Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name)
|
||||
# now update the record:
|
||||
registration.upload_status = row['Status']
|
||||
registration.upload_error_message = row['Message']
|
||||
registration.upload_error_message = row['Message']
|
||||
try:
|
||||
registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S'))
|
||||
except ValueError as ve:
|
||||
@@ -80,7 +81,7 @@ class Command(BaseCommand):
|
||||
except ValueError as ve:
|
||||
Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name)
|
||||
|
||||
registration.confirmed_at = datetime.utcnow()
|
||||
registration.confirmed_at = datetime.now(UTC)
|
||||
registration.save()
|
||||
except TestCenterRegistration.DoesNotExist:
|
||||
Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from optparse import make_option
|
||||
from time import strftime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
@@ -128,8 +127,8 @@ class Command(BaseCommand):
|
||||
exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info)
|
||||
# update option values for date_first and date_last to use YYYY-MM-DD format
|
||||
# instead of YYYY-MM-DDTHH:MM
|
||||
our_options['eligibility_appointment_date_first'] = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
|
||||
our_options['eligibility_appointment_date_last'] = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
|
||||
our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
|
||||
if exam is None:
|
||||
raise CommandError("Exam for course_id {} does not exist".format(course_id))
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding field 'CourseEnrollmentAllowed.auto_enroll'
|
||||
db.add_column('student_courseenrollmentallowed', 'auto_enroll',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'CourseEnrollmentAllowed.auto_enroll'
|
||||
db.delete_column('student_courseenrollmentallowed', 'auto_enroll')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.testcenteruser': {
|
||||
'Meta': {'object_name': 'TestCenterUser'},
|
||||
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
|
||||
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
|
||||
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
|
||||
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
|
||||
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
|
||||
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -16,7 +16,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from random import randint
|
||||
from time import strftime
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
@@ -27,6 +26,7 @@ from django.dispatch import receiver
|
||||
from django.forms import ModelForm, forms
|
||||
|
||||
import comment_client as cc
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -54,7 +54,7 @@ class UserProfile(models.Model):
|
||||
class Meta:
|
||||
db_table = "auth_userprofile"
|
||||
|
||||
## CRITICAL TODO/SECURITY
|
||||
# CRITICAL TODO/SECURITY
|
||||
# Sanitize all fields.
|
||||
# This is not visible to other users, but could introduce holes later
|
||||
user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile')
|
||||
@@ -254,7 +254,7 @@ class TestCenterUserForm(ModelForm):
|
||||
def update_and_save(self):
|
||||
new_user = self.save(commit=False)
|
||||
# create additional values here:
|
||||
new_user.user_updated_at = datetime.utcnow()
|
||||
new_user.user_updated_at = datetime.now(UTC)
|
||||
new_user.upload_status = ''
|
||||
new_user.save()
|
||||
log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username))
|
||||
@@ -429,8 +429,8 @@ class TestCenterRegistration(models.Model):
|
||||
registration.course_id = exam.course_id
|
||||
registration.accommodation_request = accommodation_request.strip()
|
||||
registration.exam_series_code = exam.exam_series_code
|
||||
registration.eligibility_appointment_date_first = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
|
||||
registration.eligibility_appointment_date_last = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
|
||||
registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d")
|
||||
registration.client_authorization_id = cls._create_client_authorization_id()
|
||||
# accommodation_code remains blank for now, along with Pearson confirmation information
|
||||
return registration
|
||||
@@ -556,7 +556,7 @@ class TestCenterRegistrationForm(ModelForm):
|
||||
def update_and_save(self):
|
||||
registration = self.save(commit=False)
|
||||
# create additional values here:
|
||||
registration.user_updated_at = datetime.utcnow()
|
||||
registration.user_updated_at = datetime.now(UTC)
|
||||
registration.upload_status = ''
|
||||
registration.save()
|
||||
log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code))
|
||||
@@ -598,7 +598,7 @@ def unique_id_for_user(user):
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
## TODO: Should be renamed to generic UserGroup, and possibly
|
||||
# TODO: Should be renamed to generic UserGroup, and possibly
|
||||
# Given an optional field for type of group
|
||||
class UserTestGroup(models.Model):
|
||||
users = models.ManyToManyField(User, db_index=True)
|
||||
@@ -626,7 +626,6 @@ class Registration(models.Model):
|
||||
def activate(self):
|
||||
self.user.is_active = True
|
||||
self.user.save()
|
||||
#self.delete()
|
||||
|
||||
|
||||
class PendingNameChange(models.Model):
|
||||
@@ -648,7 +647,7 @@ class CourseEnrollment(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('user', 'course_id'), )
|
||||
unique_together = (('user', 'course_id'),)
|
||||
|
||||
def __unicode__(self):
|
||||
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
|
||||
@@ -662,16 +661,17 @@ class CourseEnrollmentAllowed(models.Model):
|
||||
"""
|
||||
email = models.CharField(max_length=255, db_index=True)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
auto_enroll = models.BooleanField(default=0)
|
||||
|
||||
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('email', 'course_id'), )
|
||||
unique_together = (('email', 'course_id'),)
|
||||
|
||||
def __unicode__(self):
|
||||
return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created)
|
||||
|
||||
#cache_relation(User.profile)
|
||||
# cache_relation(User.profile)
|
||||
|
||||
#### Helper methods for use from python manage.py shell and other classes.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.contrib.auth.models import Group
|
||||
from datetime import datetime
|
||||
from factory import DjangoModelFactory, SubFactory, PostGenerationMethodCall, post_generation, Sequence
|
||||
from uuid import uuid4
|
||||
from pytz import UTC
|
||||
|
||||
# Factories don't have __init__ methods, and are self documenting
|
||||
# pylint: disable=W0232
|
||||
@@ -46,8 +47,8 @@ class UserFactory(DjangoModelFactory):
|
||||
is_staff = False
|
||||
is_active = True
|
||||
is_superuser = False
|
||||
last_login = datetime(2012, 1, 1)
|
||||
date_joined = datetime(2011, 1, 1)
|
||||
last_login = datetime(2012, 1, 1, tzinfo=UTC)
|
||||
date_joined = datetime(2011, 1, 1, tzinfo=UTC)
|
||||
|
||||
@post_generation
|
||||
def profile(obj, create, extracted, **kwargs):
|
||||
|
||||
@@ -32,7 +32,7 @@ from student.models import (Registration, UserProfile, TestCenterUser, TestCente
|
||||
TestCenterRegistration, TestCenterRegistrationForm,
|
||||
PendingNameChange, PendingEmailChange,
|
||||
CourseEnrollment, unique_id_for_user,
|
||||
get_testcenter_registration)
|
||||
get_testcenter_registration, CourseEnrollmentAllowed)
|
||||
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
|
||||
@@ -49,6 +49,7 @@ from courseware.views import get_module_for_descriptor, jump_to
|
||||
from courseware.model_data import ModelDataCache
|
||||
|
||||
from statsd import statsd
|
||||
from pytz import UTC
|
||||
|
||||
log = logging.getLogger("mitx.student")
|
||||
Article = namedtuple('Article', 'title url author image deck publication publish_date')
|
||||
@@ -77,7 +78,7 @@ def index(request, extra_context={}, user=None):
|
||||
'''
|
||||
|
||||
# The course selection work is done in courseware.courses.
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
|
||||
# do explicit check, because domain=None is valid
|
||||
if domain == False:
|
||||
domain = request.META.get('HTTP_HOST')
|
||||
@@ -264,7 +265,6 @@ def dashboard(request):
|
||||
if not user.is_active:
|
||||
message = render_to_string('registration/activate_account_notice.html', {'email': user.email})
|
||||
|
||||
|
||||
# Global staff can see what courses errored on their dashboard
|
||||
staff_access = False
|
||||
errored_courses = {}
|
||||
@@ -356,16 +356,16 @@ def change_enrollment(request):
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
log.warning("User {0} tried to enroll in non-existent course {1}"
|
||||
.format(user.username, course_id))
|
||||
.format(user.username, course_id))
|
||||
return HttpResponseBadRequest("Course id is invalid")
|
||||
if not has_access(user, course, 'enroll'):
|
||||
return HttpResponseBadRequest("Enrollment is closed")
|
||||
|
||||
org, course_num, run = course_id.split("/")
|
||||
statsd.increment("common.student.enrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
try:
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
@@ -382,9 +382,9 @@ def change_enrollment(request):
|
||||
|
||||
org, course_num, run = course_id.split("/")
|
||||
statsd.increment("common.student.unenrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
return HttpResponse()
|
||||
except CourseEnrollment.DoesNotExist:
|
||||
@@ -454,7 +454,6 @@ def login_user(request, error=""):
|
||||
expires_time = time.time() + max_age
|
||||
expires = cookie_date(expires_time)
|
||||
|
||||
|
||||
response.set_cookie(settings.EDXMKTG_COOKIE_NAME,
|
||||
'true', max_age=max_age,
|
||||
expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
|
||||
@@ -515,8 +514,8 @@ def _do_create_account(post_vars):
|
||||
Note: this function is also used for creating test users.
|
||||
"""
|
||||
user = User(username=post_vars['username'],
|
||||
email=post_vars['email'],
|
||||
is_active=False)
|
||||
email=post_vars['email'],
|
||||
is_active=False)
|
||||
user.set_password(post_vars['password'])
|
||||
registration = Registration()
|
||||
# TODO: Rearrange so that if part of the process fails, the whole process fails.
|
||||
@@ -632,7 +631,7 @@ def create_account(request, post_override=None):
|
||||
|
||||
# Ok, looks like everything is legit. Create the account.
|
||||
ret = _do_create_account(post_vars)
|
||||
if isinstance(ret, HttpResponse): # if there was an error then return that
|
||||
if isinstance(ret, HttpResponse): # if there was an error then return that
|
||||
return ret
|
||||
(user, profile, registration) = ret
|
||||
|
||||
@@ -670,7 +669,7 @@ def create_account(request, post_override=None):
|
||||
|
||||
if DoExternalAuth:
|
||||
eamap.user = login_user
|
||||
eamap.dtsignup = datetime.datetime.now()
|
||||
eamap.dtsignup = datetime.datetime.now(UTC)
|
||||
eamap.save()
|
||||
log.debug('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap))
|
||||
|
||||
@@ -698,7 +697,6 @@ def create_account(request, post_override=None):
|
||||
expires_time = time.time() + max_age
|
||||
expires = cookie_date(expires_time)
|
||||
|
||||
|
||||
response.set_cookie(settings.EDXMKTG_COOKIE_NAME,
|
||||
'true', max_age=max_age,
|
||||
expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
|
||||
@@ -708,7 +706,6 @@ def create_account(request, post_override=None):
|
||||
return response
|
||||
|
||||
|
||||
|
||||
def exam_registration_info(user, course):
|
||||
""" Returns a Registration object if the user is currently registered for a current
|
||||
exam of the course. Returns None if the user is not registered, or if there is no
|
||||
@@ -849,7 +846,6 @@ def create_exam_registration(request, post_override=None):
|
||||
response_data['non_field_errors'] = form.non_field_errors()
|
||||
return HttpResponse(json.dumps(response_data), mimetype="application/json")
|
||||
|
||||
|
||||
# only do the following if there is accommodation text to send,
|
||||
# and a destination to which to send it.
|
||||
# TODO: still need to create the accommodation email templates
|
||||
@@ -872,7 +868,6 @@ def create_exam_registration(request, post_override=None):
|
||||
# response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ]
|
||||
# return HttpResponse(json.dumps(response_data), mimetype="application/json")
|
||||
|
||||
|
||||
js = {'success': True}
|
||||
return HttpResponse(json.dumps(js), mimetype="application/json")
|
||||
|
||||
@@ -916,6 +911,16 @@ def activate_account(request, key):
|
||||
if not r[0].user.is_active:
|
||||
r[0].activate()
|
||||
already_active = False
|
||||
|
||||
#Enroll student in any pending courses he/she may have if auto_enroll flag is set
|
||||
student = User.objects.filter(id=r[0].user_id)
|
||||
if student:
|
||||
ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email)
|
||||
for cea in ceas:
|
||||
if cea.auto_enroll:
|
||||
course_id = cea.course_id
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id)
|
||||
|
||||
resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active})
|
||||
return resp
|
||||
if len(r) == 0:
|
||||
|
||||
@@ -159,3 +159,33 @@ def registered_edx_user(step, uname):
|
||||
@step(u'All dialogs should be closed$')
|
||||
def dialogs_are_closed(step):
|
||||
assert world.dialogs_closed()
|
||||
|
||||
|
||||
@step('I will confirm all alerts')
|
||||
def i_confirm_all_alerts(step):
|
||||
"""
|
||||
Please note: This method must be called RIGHT BEFORE an expected alert
|
||||
Window variables are page local and thus all changes are removed upon navigating to a new page
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.confirm = function(){return true;} ; window.alert = function(){return;}')
|
||||
|
||||
|
||||
@step('I will cancel all alerts')
|
||||
def i_cancel_all_alerts(step):
|
||||
"""
|
||||
Please note: This method must be called RIGHT BEFORE an expected alert
|
||||
Window variables are page local and thus all changes are removed upon navigating to a new page
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.confirm = function(){return false;} ; window.alert = function(){return;}')
|
||||
|
||||
|
||||
@step('I will answer all prompts with "([^"]*)"')
|
||||
def i_answer_prompts_with(step, prompt):
|
||||
"""
|
||||
Please note: This method must be called RIGHT BEFORE an expected alert
|
||||
Window variables are page local and thus all changes are removed upon navigating to a new page
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.prompt = function(){return %s;}') % prompt
|
||||
|
||||
49
common/djangoapps/tests.py
Normal file
49
common/djangoapps/tests.py
Normal file
@@ -0,0 +1,49 @@
|
||||
'''
|
||||
Created on Jun 6, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from student.tests.factories import AdminFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
import xmodule_modifiers
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
from xmodule.modulestore.tests import factories
|
||||
|
||||
class TestXmoduleModfiers(ModuleStoreTestCase):
|
||||
|
||||
# FIXME disabled b/c start date inheritance is not occuring and render_... in get_html is failing due
|
||||
# to middleware.lookup['main'] not being defined
|
||||
def _test_add_histogram(self):
|
||||
instructor = AdminFactory.create()
|
||||
self.client.login(username=instructor.username, password='test')
|
||||
|
||||
course = CourseFactory.create(org='test',
|
||||
number='313', display_name='histogram test')
|
||||
section = ItemFactory.create(
|
||||
parent_location=course.location, display_name='chapter hist',
|
||||
template='i4x://edx/templates/chapter/Empty')
|
||||
problem = ItemFactory.create(
|
||||
parent_location=section.location, display_name='problem hist 1',
|
||||
template='i4x://edx/templates/problem/Blank_Common_Problem')
|
||||
problem.has_score = False # don't trip trying to retrieve db data
|
||||
|
||||
late_problem = ItemFactory.create(
|
||||
parent_location=section.location, display_name='problem hist 2',
|
||||
template='i4x://edx/templates/problem/Blank_Common_Problem')
|
||||
late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
|
||||
late_problem.has_score = False
|
||||
|
||||
|
||||
problem_module = factories.get_test_xmodule_for_descriptor(problem)
|
||||
problem_module.get_html = xmodule_modifiers.add_histogram(lambda:'', problem_module, instructor)
|
||||
|
||||
self.assertRegexpMatches(
|
||||
problem_module.get_html(), r'.*<font color=\'green\'>Not yet</font>.*')
|
||||
|
||||
problem_module = factories.get_test_xmodule_for_descriptor(late_problem)
|
||||
problem_module.get_html = xmodule_modifiers.add_histogram(lambda: '', problem_module, instructor)
|
||||
|
||||
self.assertRegexpMatches(
|
||||
problem_module.get_html(), r'.*<font color=\'red\'>Yes!</font>.*')
|
||||
@@ -14,6 +14,7 @@ from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from track.models import TrackingLog
|
||||
from pytz import UTC
|
||||
|
||||
log = logging.getLogger("tracking")
|
||||
|
||||
@@ -59,7 +60,7 @@ def user_track(request):
|
||||
"event": request.GET['event'],
|
||||
"agent": agent,
|
||||
"page": request.GET['page'],
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"time": datetime.datetime.now(UTC).isoformat(),
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
log_event(event)
|
||||
@@ -85,11 +86,11 @@ def server_track(request, event_type, event, page=None):
|
||||
"event": event,
|
||||
"agent": agent,
|
||||
"page": page,
|
||||
"time": datetime.datetime.utcnow().isoformat(),
|
||||
"time": datetime.datetime.now(UTC).isoformat(),
|
||||
"host": request.META['SERVER_NAME'],
|
||||
}
|
||||
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log
|
||||
return
|
||||
log_event(event)
|
||||
|
||||
|
||||
34
common/djangoapps/util/testing.py
Normal file
34
common/djangoapps/util/testing.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import clear_url_caches
|
||||
|
||||
|
||||
class UrlResetMixin(object):
|
||||
"""Mixin to reset urls.py before and after a test
|
||||
|
||||
Django memoizes the function that reads the urls module (whatever module
|
||||
urlconf names). The module itself is also stored by python in sys.modules.
|
||||
To fully reload it, we need to reload the python module, and also clear django's
|
||||
cache of the parsed urls.
|
||||
|
||||
However, the order in which we do this doesn't matter, because neither one will
|
||||
get reloaded until the next request
|
||||
|
||||
Doing this is expensive, so it should only be added to tests that modify settings
|
||||
that affect the contents of urls.py
|
||||
"""
|
||||
|
||||
def _reset_urls(self, urlconf=None):
|
||||
if urlconf is None:
|
||||
urlconf = settings.ROOT_URLCONF
|
||||
|
||||
if urlconf in sys.modules:
|
||||
reload(sys.modules[urlconf])
|
||||
clear_url_caches()
|
||||
|
||||
def setUp(self):
|
||||
"""Reset django default urlconf before tests and after tests"""
|
||||
super(UrlResetMixin, self).setUp()
|
||||
self._reset_urls()
|
||||
self.addCleanup(self._reset_urls)
|
||||
@@ -15,8 +15,9 @@ import mock
|
||||
|
||||
@mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True})
|
||||
@override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy")
|
||||
@mock.patch("util.views.dog_stats_api")
|
||||
@mock.patch("util.views._ZendeskApi", autospec=True)
|
||||
class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
class SubmitFeedbackTest(TestCase):
|
||||
def setUp(self):
|
||||
"""Set up data for the test case"""
|
||||
self._request_factory = RequestFactory()
|
||||
@@ -26,18 +27,19 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
username="test",
|
||||
profile__name="Test User"
|
||||
)
|
||||
# This contains a tag to ensure that tags are submitted correctly
|
||||
# This contains issue_type and course_id to ensure that tags are submitted correctly
|
||||
self._anon_fields = {
|
||||
"email": "test@edx.org",
|
||||
"name": "Test User",
|
||||
"subject": "a subject",
|
||||
"details": "some details",
|
||||
"tag": "a tag"
|
||||
"issue_type": "test_issue",
|
||||
"course_id": "test_course"
|
||||
}
|
||||
# This does not contain a tag to ensure that tag is optional
|
||||
# This does not contain issue_type nor course_id to ensure that they are optional
|
||||
self._auth_fields = {"subject": "a subject", "details": "some details"}
|
||||
|
||||
def _test_request(self, user, fields):
|
||||
def _build_and_run_request(self, user, fields):
|
||||
"""
|
||||
Generate a request and invoke the view, returning the response.
|
||||
|
||||
@@ -48,12 +50,14 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
"/submit_feedback",
|
||||
data=fields,
|
||||
HTTP_REFERER="test_referer",
|
||||
HTTP_USER_AGENT="test_user_agent"
|
||||
HTTP_USER_AGENT="test_user_agent",
|
||||
REMOTE_ADDR="1.2.3.4",
|
||||
SERVER_NAME="test_server"
|
||||
)
|
||||
req.user = user
|
||||
return views.submit_feedback_via_zendesk(req)
|
||||
return views.submit_feedback(req)
|
||||
|
||||
def _assert_bad_request(self, response, field, zendesk_mock_class):
|
||||
def _assert_bad_request(self, response, field, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Assert that the given `response` contains correct failure data.
|
||||
|
||||
@@ -67,8 +71,9 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
self.assertTrue("error" in resp_json)
|
||||
# There should be absolutely no interaction with Zendesk
|
||||
self.assertFalse(zendesk_mock_class.return_value.mock_calls)
|
||||
self.assertFalse(datadog_mock.mock_calls)
|
||||
|
||||
def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class):
|
||||
def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Invoke the view with a request missing a field and assert correctness.
|
||||
|
||||
@@ -79,10 +84,10 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
have been invoked.
|
||||
"""
|
||||
filtered_fields = {k: v for (k, v) in fields.items() if k != omit_field}
|
||||
resp = self._test_request(user, filtered_fields)
|
||||
self._assert_bad_request(resp, omit_field, zendesk_mock_class)
|
||||
resp = self._build_and_run_request(user, filtered_fields)
|
||||
self._assert_bad_request(resp, omit_field, zendesk_mock_class, datadog_mock)
|
||||
|
||||
def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class):
|
||||
def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Invoke the view with an empty field and assert correctness.
|
||||
|
||||
@@ -94,8 +99,8 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
"""
|
||||
altered_fields = fields.copy()
|
||||
altered_fields[empty_field] = ""
|
||||
resp = self._test_request(user, altered_fields)
|
||||
self._assert_bad_request(resp, empty_field, zendesk_mock_class)
|
||||
resp = self._build_and_run_request(user, altered_fields)
|
||||
self._assert_bad_request(resp, empty_field, zendesk_mock_class, datadog_mock)
|
||||
|
||||
def _test_success(self, user, fields):
|
||||
"""
|
||||
@@ -105,30 +110,46 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
`fields` in the POST body. The response should have a 200 (success)
|
||||
status code.
|
||||
"""
|
||||
resp = self._test_request(user, fields)
|
||||
resp = self._build_and_run_request(user, fields)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_bad_request_anon_user_no_name(self, zendesk_mock_class):
|
||||
def _assert_datadog_called(self, datadog_mock, with_tags):
|
||||
expected_datadog_calls = [
|
||||
mock.call.increment(
|
||||
views.DATADOG_FEEDBACK_METRIC,
|
||||
tags=(["course_id:test_course", "issue_type:test_issue"] if with_tags else [])
|
||||
)
|
||||
]
|
||||
self.assertEqual(datadog_mock.mock_calls, expected_datadog_calls)
|
||||
|
||||
def test_bad_request_anon_user_no_name(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an anonymous user not specifying `name`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class)
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class, datadog_mock)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_bad_request_anon_user_no_email(self, zendesk_mock_class):
|
||||
def test_bad_request_anon_user_no_email(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an anonymous user not specifying `email`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class)
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class, datadog_mock)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_bad_request_anon_user_no_subject(self, zendesk_mock_class):
|
||||
def test_bad_request_anon_user_invalid_email(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an anonymous user specifying an invalid `email`."""
|
||||
fields = self._anon_fields.copy()
|
||||
fields["email"] = "This is not a valid email address!"
|
||||
resp = self._build_and_run_request(self._anon_user, fields)
|
||||
self._assert_bad_request(resp, "email", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_bad_request_anon_user_no_subject(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an anonymous user not specifying `subject`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class)
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class, datadog_mock)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_bad_request_anon_user_no_details(self, zendesk_mock_class):
|
||||
def test_bad_request_anon_user_no_details(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an anonymous user not specifying `details`."""
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class)
|
||||
self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class, datadog_mock)
|
||||
self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_valid_request_anon_user(self, zendesk_mock_class):
|
||||
def test_valid_request_anon_user(self, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Test a valid request from an anonymous user.
|
||||
|
||||
@@ -138,14 +159,14 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.create_ticket.return_value = 42
|
||||
self._test_success(self._anon_user, self._anon_fields)
|
||||
expected_calls = [
|
||||
expected_zendesk_calls = [
|
||||
mock.call.create_ticket(
|
||||
{
|
||||
"ticket": {
|
||||
"requester": {"name": "Test User", "email": "test@edx.org"},
|
||||
"subject": "a subject",
|
||||
"comment": {"body": "some details"},
|
||||
"tags": ["a tag"]
|
||||
"tags": ["test_course", "test_issue", "LMS"]
|
||||
}
|
||||
}
|
||||
),
|
||||
@@ -157,26 +178,29 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
"public": False,
|
||||
"body":
|
||||
"Additional information:\n\n"
|
||||
"HTTP_USER_AGENT: test_user_agent\n"
|
||||
"HTTP_REFERER: test_referer"
|
||||
"Client IP: 1.2.3.4\n"
|
||||
"Host: test_server\n"
|
||||
"Page: test_referer\n"
|
||||
"Browser: test_user_agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls)
|
||||
self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls)
|
||||
self._assert_datadog_called(datadog_mock, with_tags=True)
|
||||
|
||||
def test_bad_request_auth_user_no_subject(self, zendesk_mock_class):
|
||||
def test_bad_request_auth_user_no_subject(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an authenticated user not specifying `subject`."""
|
||||
self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class)
|
||||
self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class, datadog_mock)
|
||||
self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_bad_request_auth_user_no_details(self, zendesk_mock_class):
|
||||
def test_bad_request_auth_user_no_details(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test a request from an authenticated user not specifying `details`."""
|
||||
self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class)
|
||||
self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class)
|
||||
self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class, datadog_mock)
|
||||
self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class, datadog_mock)
|
||||
|
||||
def test_valid_request_auth_user(self, zendesk_mock_class):
|
||||
def test_valid_request_auth_user(self, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Test a valid request from an authenticated user.
|
||||
|
||||
@@ -186,14 +210,14 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.create_ticket.return_value = 42
|
||||
self._test_success(self._auth_user, self._auth_fields)
|
||||
expected_calls = [
|
||||
expected_zendesk_calls = [
|
||||
mock.call.create_ticket(
|
||||
{
|
||||
"ticket": {
|
||||
"requester": {"name": "Test User", "email": "test@edx.org"},
|
||||
"subject": "a subject",
|
||||
"comment": {"body": "some details"},
|
||||
"tags": []
|
||||
"tags": ["LMS"]
|
||||
}
|
||||
}
|
||||
),
|
||||
@@ -206,27 +230,31 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
"body":
|
||||
"Additional information:\n\n"
|
||||
"username: test\n"
|
||||
"HTTP_USER_AGENT: test_user_agent\n"
|
||||
"HTTP_REFERER: test_referer"
|
||||
"Client IP: 1.2.3.4\n"
|
||||
"Host: test_server\n"
|
||||
"Page: test_referer\n"
|
||||
"Browser: test_user_agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls)
|
||||
self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls)
|
||||
self._assert_datadog_called(datadog_mock, with_tags=False)
|
||||
|
||||
def test_get_request(self, zendesk_mock_class):
|
||||
def test_get_request(self, zendesk_mock_class, datadog_mock):
|
||||
"""Test that a GET results in a 405 even with all required fields"""
|
||||
req = self._request_factory.get("/submit_feedback", data=self._anon_fields)
|
||||
req.user = self._anon_user
|
||||
resp = views.submit_feedback_via_zendesk(req)
|
||||
resp = views.submit_feedback(req)
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
self.assertIn("Allow", resp)
|
||||
self.assertEqual(resp["Allow"], "POST")
|
||||
# There should be absolutely no interaction with Zendesk
|
||||
self.assertFalse(zendesk_mock_class.mock_calls)
|
||||
self.assertFalse(datadog_mock.mock_calls)
|
||||
|
||||
def test_zendesk_error_on_create(self, zendesk_mock_class):
|
||||
def test_zendesk_error_on_create(self, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Test Zendesk returning an error on ticket creation.
|
||||
|
||||
@@ -235,11 +263,12 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
err = ZendeskError(msg="", error_code=404)
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.create_ticket.side_effect = err
|
||||
resp = self._test_request(self._anon_user, self._anon_fields)
|
||||
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
self.assertFalse(resp.content)
|
||||
self._assert_datadog_called(datadog_mock, with_tags=True)
|
||||
|
||||
def test_zendesk_error_on_update(self, zendesk_mock_class):
|
||||
def test_zendesk_error_on_update(self, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Test for Zendesk returning an error on ticket update.
|
||||
|
||||
@@ -250,20 +279,21 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
err = ZendeskError(msg="", error_code=500)
|
||||
zendesk_mock_instance = zendesk_mock_class.return_value
|
||||
zendesk_mock_instance.update_ticket.side_effect = err
|
||||
resp = self._test_request(self._anon_user, self._anon_fields)
|
||||
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self._assert_datadog_called(datadog_mock, with_tags=True)
|
||||
|
||||
@mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False})
|
||||
def test_not_enabled(self, zendesk_mock_class):
|
||||
def test_not_enabled(self, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Test for Zendesk submission not enabled in `settings`.
|
||||
|
||||
We should raise Http404.
|
||||
"""
|
||||
with self.assertRaises(Http404):
|
||||
self._test_request(self._anon_user, self._anon_fields)
|
||||
self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
|
||||
def test_zendesk_not_configured(self, zendesk_mock_class):
|
||||
def test_zendesk_not_configured(self, zendesk_mock_class, datadog_mock):
|
||||
"""
|
||||
Test for Zendesk not fully configured in `settings`.
|
||||
|
||||
@@ -273,7 +303,7 @@ class SubmitFeedbackViaZendeskTest(TestCase):
|
||||
def test_case(missing_config):
|
||||
with mock.patch(missing_config, None):
|
||||
with self.assertRaises(Exception):
|
||||
self._test_request(self._anon_user, self._anon_fields)
|
||||
self._build_and_run_request(self._anon_user, self._anon_fields)
|
||||
|
||||
test_case("django.conf.settings.ZENDESK_URL")
|
||||
test_case("django.conf.settings.ZENDESK_USER")
|
||||
@@ -12,6 +12,7 @@ from django.core.validators import ValidationError, validate_email
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError
|
||||
from django.shortcuts import redirect
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from dogapi import dog_stats_api
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from urllib import urlencode
|
||||
import zendesk
|
||||
@@ -73,11 +74,64 @@ class _ZendeskApi(object):
|
||||
self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update)
|
||||
|
||||
|
||||
def submit_feedback_via_zendesk(request):
|
||||
def _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info):
|
||||
"""
|
||||
Create a new user-requested Zendesk ticket.
|
||||
|
||||
If Zendesk submission is not enabled, any request will raise `Http404`.
|
||||
Once created, the ticket will be updated with a private comment containing
|
||||
additional information from the browser and server, such as HTTP headers
|
||||
and user state. Returns a boolean value indicating whether ticket creation
|
||||
was successful, regardless of whether the private comment update succeeded.
|
||||
"""
|
||||
zendesk_api = _ZendeskApi()
|
||||
|
||||
additional_info_string = (
|
||||
"Additional information:\n\n" +
|
||||
"\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
|
||||
)
|
||||
|
||||
# Tag all issues with LMS to distinguish channel in Zendesk; requested by student support team
|
||||
zendesk_tags = list(tags.values()) + ["LMS"]
|
||||
new_ticket = {
|
||||
"ticket": {
|
||||
"requester": {"name": realname, "email": email},
|
||||
"subject": subject,
|
||||
"comment": {"body": details},
|
||||
"tags": zendesk_tags
|
||||
}
|
||||
}
|
||||
try:
|
||||
ticket_id = zendesk_api.create_ticket(new_ticket)
|
||||
except zendesk.ZendeskError as err:
|
||||
log.error("Error creating Zendesk ticket: %s", str(err))
|
||||
return False
|
||||
|
||||
# Additional information is provided as a private update so the information
|
||||
# is not visible to the user.
|
||||
ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}}
|
||||
try:
|
||||
zendesk_api.update_ticket(ticket_id, ticket_update)
|
||||
except zendesk.ZendeskError as err:
|
||||
log.error("Error updating Zendesk ticket: %s", str(err))
|
||||
# The update is not strictly necessary, so do not indicate failure to the user
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
DATADOG_FEEDBACK_METRIC = "lms_feedback_submissions"
|
||||
|
||||
|
||||
def _record_feedback_in_datadog(tags):
|
||||
datadog_tags = ["{k}:{v}".format(k=k, v=v) for k, v in tags.items()]
|
||||
dog_stats_api.increment(DATADOG_FEEDBACK_METRIC, tags=datadog_tags)
|
||||
|
||||
|
||||
def submit_feedback(request):
|
||||
"""
|
||||
Create a new user-requested ticket, currently implemented with Zendesk.
|
||||
|
||||
If feedback submission is not enabled, any request will raise `Http404`.
|
||||
If any configuration parameter (`ZENDESK_URL`, `ZENDESK_USER`, or
|
||||
`ZENDESK_API_KEY`) is missing, any request will raise an `Exception`.
|
||||
The request must be a POST request specifying `subject` and `details`.
|
||||
@@ -85,12 +139,9 @@ def submit_feedback_via_zendesk(request):
|
||||
`email`. If the user is authenticated, the `name` and `email` will be
|
||||
populated from the user's information. If any required parameter is
|
||||
missing, a 400 error will be returned indicating which field is missing and
|
||||
providing an error message. If Zendesk returns any error on ticket
|
||||
creation, a 500 error will be returned with no body. Once created, the
|
||||
ticket will be updated with a private comment containing additional
|
||||
information from the browser and server, such as HTTP headers and user
|
||||
state. Whether or not the update succeeds, if the user's ticket is
|
||||
successfully created, an empty successful response (200) will be returned.
|
||||
providing an error message. If Zendesk ticket creation fails, 500 error
|
||||
will be returned with no body; if ticket creation succeeds, an empty
|
||||
successful response (200) will be returned.
|
||||
"""
|
||||
if not settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False):
|
||||
raise Http404()
|
||||
@@ -124,9 +175,9 @@ def submit_feedback_via_zendesk(request):
|
||||
|
||||
subject = request.POST["subject"]
|
||||
details = request.POST["details"]
|
||||
tags = []
|
||||
if "tag" in request.POST:
|
||||
tags = [request.POST["tag"]]
|
||||
tags = dict(
|
||||
[(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if tag in request.POST]
|
||||
)
|
||||
|
||||
if request.user.is_authenticated():
|
||||
realname = request.user.profile.name
|
||||
@@ -140,41 +191,18 @@ def submit_feedback_via_zendesk(request):
|
||||
except ValidationError:
|
||||
return build_error_response(400, "email", required_field_errs["email"])
|
||||
|
||||
for header in ["HTTP_REFERER", "HTTP_USER_AGENT"]:
|
||||
additional_info[header] = request.META.get(header)
|
||||
for header, pretty in [
|
||||
("HTTP_REFERER", "Page"),
|
||||
("HTTP_USER_AGENT", "Browser"),
|
||||
("REMOTE_ADDR", "Client IP"),
|
||||
("SERVER_NAME", "Host")
|
||||
]:
|
||||
additional_info[pretty] = request.META.get(header)
|
||||
|
||||
zendesk_api = _ZendeskApi()
|
||||
success = _record_feedback_in_zendesk(realname, email, subject, details, tags, additional_info)
|
||||
_record_feedback_in_datadog(tags)
|
||||
|
||||
additional_info_string = (
|
||||
"Additional information:\n\n" +
|
||||
"\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
|
||||
)
|
||||
|
||||
new_ticket = {
|
||||
"ticket": {
|
||||
"requester": {"name": realname, "email": email},
|
||||
"subject": subject,
|
||||
"comment": {"body": details},
|
||||
"tags": tags
|
||||
}
|
||||
}
|
||||
try:
|
||||
ticket_id = zendesk_api.create_ticket(new_ticket)
|
||||
except zendesk.ZendeskError as err:
|
||||
log.error("Error creating Zendesk ticket: %s", str(err))
|
||||
return HttpResponse(status=500)
|
||||
|
||||
# Additional information is provided as a private update so the information
|
||||
# is not visible to the user.
|
||||
ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}}
|
||||
try:
|
||||
zendesk_api.update_ticket(ticket_id, ticket_update)
|
||||
except zendesk.ZendeskError as err:
|
||||
log.error("Error updating Zendesk ticket: %s", str(err))
|
||||
# The update is not strictly necessary, so do not indicate failure to the user
|
||||
pass
|
||||
|
||||
return HttpResponse()
|
||||
return HttpResponse(status=(200 if success else 500))
|
||||
|
||||
|
||||
def info(request):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import static_replace
|
||||
|
||||
from django.conf import settings
|
||||
@@ -9,6 +8,8 @@ from functools import wraps
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from xmodule.seq_module import SequenceModule
|
||||
from xmodule.vertical_module import VerticalModule
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
log = logging.getLogger("mitx.xmodule_modifiers")
|
||||
|
||||
@@ -83,7 +84,7 @@ def grade_histogram(module_id):
|
||||
cursor.execute(q, [module_id])
|
||||
|
||||
grades = list(cursor.fetchall())
|
||||
grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query?
|
||||
grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query?
|
||||
if len(grades) >= 1 and grades[0][0] is None:
|
||||
return []
|
||||
return grades
|
||||
@@ -101,7 +102,7 @@ def add_histogram(get_html, module, user):
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
|
||||
if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead
|
||||
if type(module) in [SequenceModule, VerticalModule]: # TODO: make this more general, eg use an XModule attribute instead
|
||||
return get_html()
|
||||
|
||||
module_id = module.id
|
||||
@@ -132,7 +133,7 @@ def add_histogram(get_html, module, user):
|
||||
|
||||
# useful to indicate to staff if problem has been released or not
|
||||
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
|
||||
now = time.gmtime()
|
||||
now = datetime.datetime.now(UTC())
|
||||
is_released = "unknown"
|
||||
mstart = module.descriptor.lms.start
|
||||
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
"""
|
||||
Parser and evaluator for FormulaResponse and NumericalResponse
|
||||
|
||||
Uses pyparsing to parse. Main function as of now is evaluator().
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import math
|
||||
import operator
|
||||
import re
|
||||
|
||||
import numpy
|
||||
import numbers
|
||||
import scipy.constants
|
||||
import calcfunctions
|
||||
|
||||
from pyparsing import Word, alphas, nums, oneOf, Literal
|
||||
from pyparsing import ZeroOrMore, OneOrMore, StringStart
|
||||
from pyparsing import StringEnd, Optional, Forward
|
||||
from pyparsing import CaselessLiteral, Group, StringEnd
|
||||
from pyparsing import NoMatch, stringEnd, alphanums
|
||||
# have numpy raise errors on functions outside its domain
|
||||
# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html
|
||||
numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise'
|
||||
|
||||
default_functions = {'sin': numpy.sin,
|
||||
from pyparsing import (Word, nums, Literal,
|
||||
ZeroOrMore, MatchFirst,
|
||||
Optional, Forward,
|
||||
CaselessLiteral,
|
||||
stringEnd, Suppress, Combine)
|
||||
|
||||
DEFAULT_FUNCTIONS = {'sin': numpy.sin,
|
||||
'cos': numpy.cos,
|
||||
'tan': numpy.tan,
|
||||
'sec': calcfunctions.sec,
|
||||
'csc': calcfunctions.csc,
|
||||
'cot': calcfunctions.cot,
|
||||
'sqrt': numpy.sqrt,
|
||||
'log10': numpy.log10,
|
||||
'log2': numpy.log2,
|
||||
'ln': numpy.log,
|
||||
'exp': numpy.exp,
|
||||
'arccos': numpy.arccos,
|
||||
'arcsin': numpy.arcsin,
|
||||
'arctan': numpy.arctan,
|
||||
'arcsec': calcfunctions.arcsec,
|
||||
'arccsc': calcfunctions.arccsc,
|
||||
'arccot': calcfunctions.arccot,
|
||||
'abs': numpy.abs,
|
||||
'fact': math.factorial,
|
||||
'factorial': math.factorial
|
||||
'factorial': math.factorial,
|
||||
'sinh': numpy.sinh,
|
||||
'cosh': numpy.cosh,
|
||||
'tanh': numpy.tanh,
|
||||
'sech': calcfunctions.sech,
|
||||
'csch': calcfunctions.csch,
|
||||
'coth': calcfunctions.coth,
|
||||
'arcsinh': numpy.arcsinh,
|
||||
'arccosh': numpy.arccosh,
|
||||
'arctanh': numpy.arctanh,
|
||||
'arcsech': calcfunctions.arcsech,
|
||||
'arccsch': calcfunctions.arccsch,
|
||||
'arccoth': calcfunctions.arccoth
|
||||
}
|
||||
default_variables = {'j': numpy.complex(0, 1),
|
||||
DEFAULT_VARIABLES = {'i': numpy.complex(0, 1),
|
||||
'j': numpy.complex(0, 1),
|
||||
'e': numpy.e,
|
||||
'pi': numpy.pi,
|
||||
'k': scipy.constants.k,
|
||||
@@ -37,65 +66,166 @@ default_variables = {'j': numpy.complex(0, 1),
|
||||
'q': scipy.constants.e
|
||||
}
|
||||
|
||||
log = logging.getLogger("mitx.courseware.capa")
|
||||
# We eliminated the following extreme suffixes:
|
||||
# P (1e15), E (1e18), Z (1e21), Y (1e24),
|
||||
# f (1e-15), a (1e-18), z (1e-21), y (1e-24)
|
||||
# since they're rarely used, and potentially
|
||||
# confusing. They may also conflict with variables if we ever allow e.g.
|
||||
# 5R instead of 5*R
|
||||
SUFFIXES = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12,
|
||||
'c': 1e-2, 'm': 1e-3, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12}
|
||||
|
||||
|
||||
class UndefinedVariable(Exception):
|
||||
def raiseself(self):
|
||||
''' Helper so we can use inside of a lambda '''
|
||||
raise self
|
||||
|
||||
|
||||
general_whitespace = re.compile('[^\w]+')
|
||||
"""
|
||||
Used to indicate the student input of a variable, which was unused by the
|
||||
instructor.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def check_variables(string, variables):
|
||||
'''Confirm the only variables in string are defined.
|
||||
"""
|
||||
Confirm the only variables in string are defined.
|
||||
|
||||
Pyparsing uses a left-to-right parser, which makes the more
|
||||
Otherwise, raise an UndefinedVariable containing all bad variables.
|
||||
|
||||
Pyparsing uses a left-to-right parser, which makes a more
|
||||
elegant approach pretty hopeless.
|
||||
|
||||
achar = reduce(lambda a,b:a|b ,map(Literal,alphas)) # Any alphabetic character
|
||||
undefined_variable = achar + Word(alphanums)
|
||||
undefined_variable.setParseAction(lambda x:UndefinedVariable("".join(x)).raiseself())
|
||||
varnames = varnames | undefined_variable
|
||||
'''
|
||||
possible_variables = re.split(general_whitespace, string) # List of all alnums in string
|
||||
bad_variables = list()
|
||||
for v in possible_variables:
|
||||
if len(v) == 0:
|
||||
"""
|
||||
general_whitespace = re.compile('[^\\w]+')
|
||||
# List of all alnums in string
|
||||
possible_variables = re.split(general_whitespace, string)
|
||||
bad_variables = []
|
||||
for var in possible_variables:
|
||||
if len(var) == 0:
|
||||
continue
|
||||
if v[0] <= '9' and '0' <= 'v': # Skip things that begin with numbers
|
||||
if var[0].isdigit(): # Skip things that begin with numbers
|
||||
continue
|
||||
if v not in variables:
|
||||
bad_variables.append(v)
|
||||
if var not in variables:
|
||||
bad_variables.append(var)
|
||||
if len(bad_variables) > 0:
|
||||
raise UndefinedVariable(' '.join(bad_variables))
|
||||
|
||||
|
||||
def lower_dict(input_dict):
|
||||
"""
|
||||
takes each key in the dict and makes it lowercase, still mapping to the
|
||||
same value.
|
||||
|
||||
keep in mind that it is possible (but not useful?) to define different
|
||||
variables that have the same lowercase representation. It would be hard to
|
||||
tell which is used in the final dict and which isn't.
|
||||
"""
|
||||
return {k.lower(): v for k, v in input_dict.iteritems()}
|
||||
|
||||
|
||||
# The following few functions define parse actions, which are run on lists of
|
||||
# results from each parse component. They convert the strings and (previously
|
||||
# calculated) numbers into the number that component represents.
|
||||
|
||||
def super_float(text):
|
||||
"""
|
||||
Like float, but with si extensions. 1k goes to 1000
|
||||
"""
|
||||
if text[-1] in SUFFIXES:
|
||||
return float(text[:-1]) * SUFFIXES[text[-1]]
|
||||
else:
|
||||
return float(text)
|
||||
|
||||
|
||||
def number_parse_action(parse_result):
|
||||
"""
|
||||
Create a float out of its string parts
|
||||
|
||||
e.g. [ '7', '.', '13' ] -> [ 7.13 ]
|
||||
Calls super_float above
|
||||
"""
|
||||
return super_float("".join(parse_result))
|
||||
|
||||
|
||||
def exp_parse_action(parse_result):
|
||||
"""
|
||||
Take a list of numbers and exponentiate them, right to left
|
||||
|
||||
e.g. [ 3, 2, 3 ] (which is 3^2^3 = 3^(2^3)) -> 6561
|
||||
"""
|
||||
# pyparsing.ParseResults doesn't play well with reverse()
|
||||
parse_result = reversed(parse_result)
|
||||
# the result of an exponentiation is called a power
|
||||
power = reduce(lambda a, b: b ** a, parse_result)
|
||||
return power
|
||||
|
||||
|
||||
def parallel(parse_result):
|
||||
"""
|
||||
Compute numbers according to the parallel resistors operator
|
||||
|
||||
BTW it is commutative. Its formula is given by
|
||||
out = 1 / (1/in1 + 1/in2 + ...)
|
||||
e.g. [ 1, 2 ] => 2/3
|
||||
|
||||
Return NaN if there is a zero among the inputs
|
||||
"""
|
||||
# convert from pyparsing.ParseResults, which doesn't support '0 in parse_result'
|
||||
parse_result = parse_result.asList()
|
||||
if len(parse_result) == 1:
|
||||
return parse_result[0]
|
||||
if 0 in parse_result:
|
||||
return float('nan')
|
||||
reciprocals = [1. / e for e in parse_result]
|
||||
return 1. / sum(reciprocals)
|
||||
|
||||
|
||||
def sum_parse_action(parse_result):
|
||||
"""
|
||||
Add the inputs
|
||||
|
||||
[ 1, '+', 2, '-', 3 ] -> 0
|
||||
|
||||
Allow a leading + or -
|
||||
"""
|
||||
total = 0.0
|
||||
current_op = operator.add
|
||||
for token in parse_result:
|
||||
if token is '+':
|
||||
current_op = operator.add
|
||||
elif token is '-':
|
||||
current_op = operator.sub
|
||||
else:
|
||||
total = current_op(total, token)
|
||||
return total
|
||||
|
||||
|
||||
def prod_parse_action(parse_result):
|
||||
"""
|
||||
Multiply the inputs
|
||||
|
||||
[ 1, '*', 2, '/', 3 ] => 0.66
|
||||
"""
|
||||
prod = 1.0
|
||||
current_op = operator.mul
|
||||
for token in parse_result:
|
||||
if token is '*':
|
||||
current_op = operator.mul
|
||||
elif token is '/':
|
||||
current_op = operator.truediv
|
||||
else:
|
||||
prod = current_op(prod, token)
|
||||
return prod
|
||||
|
||||
|
||||
def evaluator(variables, functions, string, cs=False):
|
||||
'''
|
||||
"""
|
||||
Evaluate an expression. Variables are passed as a dictionary
|
||||
from string to value. Unary functions are passed as a dictionary
|
||||
from string to function. Variables must be floats.
|
||||
cs: Case sensitive
|
||||
|
||||
TODO: Fix it so we can pass integers and complex numbers in variables dict
|
||||
'''
|
||||
# log.debug("variables: {0}".format(variables))
|
||||
# log.debug("functions: {0}".format(functions))
|
||||
# log.debug("string: {0}".format(string))
|
||||
|
||||
def lower_dict(d):
|
||||
return dict([(k.lower(), d[k]) for k in d])
|
||||
|
||||
all_variables = copy.copy(default_variables)
|
||||
all_functions = copy.copy(default_functions)
|
||||
|
||||
if not cs:
|
||||
all_variables = lower_dict(all_variables)
|
||||
all_functions = lower_dict(all_functions)
|
||||
"""
|
||||
|
||||
all_variables = copy.copy(DEFAULT_VARIABLES)
|
||||
all_functions = copy.copy(DEFAULT_FUNCTIONS)
|
||||
all_variables.update(variables)
|
||||
all_functions.update(functions)
|
||||
|
||||
@@ -113,122 +243,59 @@ def evaluator(variables, functions, string, cs=False):
|
||||
if string.strip() == "":
|
||||
return float('nan')
|
||||
|
||||
ops = {"^": operator.pow,
|
||||
"*": operator.mul,
|
||||
"/": operator.truediv,
|
||||
"+": operator.add,
|
||||
"-": operator.sub,
|
||||
}
|
||||
# We eliminated extreme ones, since they're rarely used, and potentially
|
||||
# confusing. They may also conflict with variables if we ever allow e.g.
|
||||
# 5R instead of 5*R
|
||||
suffixes = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9,
|
||||
'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24,
|
||||
'c': 1e-2, 'm': 1e-3, 'u': 1e-6,
|
||||
'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24}
|
||||
|
||||
def super_float(text):
|
||||
''' Like float, but with si extensions. 1k goes to 1000'''
|
||||
if text[-1] in suffixes:
|
||||
return float(text[:-1]) * suffixes[text[-1]]
|
||||
else:
|
||||
return float(text)
|
||||
|
||||
def number_parse_action(x): # [ '7' ] -> [ 7 ]
|
||||
return [super_float("".join(x))]
|
||||
|
||||
def exp_parse_action(x): # [ 2 ^ 3 ^ 2 ] -> 512
|
||||
x = [e for e in x if isinstance(e, numbers.Number)] # Ignore ^
|
||||
x.reverse()
|
||||
x = reduce(lambda a, b: b ** a, x)
|
||||
return x
|
||||
|
||||
def parallel(x): # Parallel resistors [ 1 2 ] => 2/3
|
||||
# convert from pyparsing.ParseResults, which doesn't support '0 in x'
|
||||
x = list(x)
|
||||
if len(x) == 1:
|
||||
return x[0]
|
||||
if 0 in x:
|
||||
return float('nan')
|
||||
x = [1. / e for e in x if isinstance(e, numbers.Number)] # Ignore ||
|
||||
return 1. / sum(x)
|
||||
|
||||
def sum_parse_action(x): # [ 1 + 2 - 3 ] -> 0
|
||||
total = 0.0
|
||||
op = ops['+']
|
||||
for e in x:
|
||||
if e in set('+-'):
|
||||
op = ops[e]
|
||||
else:
|
||||
total = op(total, e)
|
||||
return total
|
||||
|
||||
def prod_parse_action(x): # [ 1 * 2 / 3 ] => 0.66
|
||||
prod = 1.0
|
||||
op = ops['*']
|
||||
for e in x:
|
||||
if e in set('*/'):
|
||||
op = ops[e]
|
||||
else:
|
||||
prod = op(prod, e)
|
||||
return prod
|
||||
|
||||
def func_parse_action(x):
|
||||
return [all_functions[x[0]](x[1])]
|
||||
|
||||
# SI suffixes and percent
|
||||
number_suffix = reduce(lambda a, b: a | b, map(Literal, suffixes.keys()), NoMatch())
|
||||
(dot, minus, plus, times, div, lpar, rpar, exp) = map(Literal, ".-+*/()^")
|
||||
number_suffix = MatchFirst([Literal(k) for k in SUFFIXES.keys()])
|
||||
plus_minus = Literal('+') | Literal('-')
|
||||
times_div = Literal('*') | Literal('/')
|
||||
|
||||
number_part = Word(nums)
|
||||
|
||||
# 0.33 or 7 or .34 or 16.
|
||||
inner_number = (number_part + Optional("." + Optional(number_part))) | ("." + number_part)
|
||||
# by default pyparsing allows spaces between tokens--Combine prevents that
|
||||
inner_number = Combine(inner_number)
|
||||
|
||||
# 0.33k or -17
|
||||
number = (Optional(minus | plus) + inner_number
|
||||
+ Optional(CaselessLiteral("E") + Optional((plus | minus)) + number_part)
|
||||
number = (inner_number
|
||||
+ Optional(CaselessLiteral("E") + Optional(plus_minus) + number_part)
|
||||
+ Optional(number_suffix))
|
||||
number = number.setParseAction(number_parse_action) # Convert to number
|
||||
number.setParseAction(number_parse_action) # Convert to number
|
||||
|
||||
# Predefine recursive variables
|
||||
expr = Forward()
|
||||
factor = Forward()
|
||||
|
||||
def sreduce(f, l):
|
||||
''' Same as reduce, but handle len 1 and len 0 lists sensibly '''
|
||||
if len(l) == 0:
|
||||
return NoMatch()
|
||||
if len(l) == 1:
|
||||
return l[0]
|
||||
return reduce(f, l)
|
||||
# Handle variables passed in.
|
||||
# E.g. if we have {'R':0.5}, we make the substitution.
|
||||
# We sort the list so that var names (like "e2") match before
|
||||
# mathematical constants (like "e"). This is kind of a hack.
|
||||
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
|
||||
varnames = MatchFirst([CasedLiteral(k) for k in all_variables_keys])
|
||||
varnames.setParseAction(
|
||||
lambda x: [all_variables[k] for k in x]
|
||||
)
|
||||
|
||||
# Handle variables passed in. E.g. if we have {'R':0.5}, we make the substitution.
|
||||
# Special case for no variables because of how we understand PyParsing is put together
|
||||
if len(all_variables) > 0:
|
||||
# We sort the list so that var names (like "e2") match before
|
||||
# mathematical constants (like "e"). This is kind of a hack.
|
||||
all_variables_keys = sorted(all_variables.keys(), key=len, reverse=True)
|
||||
varnames = sreduce(lambda x, y: x | y, map(lambda x: CasedLiteral(x), all_variables_keys))
|
||||
varnames.setParseAction(lambda x: map(lambda y: all_variables[y], x))
|
||||
else:
|
||||
varnames = NoMatch()
|
||||
# if all_variables were empty, then pyparsing wants
|
||||
# varnames = NoMatch()
|
||||
# this is not the case, as all_variables contains the defaults
|
||||
|
||||
# Same thing for functions.
|
||||
if len(all_functions) > 0:
|
||||
funcnames = sreduce(lambda x, y: x | y,
|
||||
map(lambda x: CasedLiteral(x), all_functions.keys()))
|
||||
function = funcnames + lpar.suppress() + expr + rpar.suppress()
|
||||
function.setParseAction(func_parse_action)
|
||||
else:
|
||||
function = NoMatch()
|
||||
all_functions_keys = sorted(all_functions.keys(), key=len, reverse=True)
|
||||
funcnames = MatchFirst([CasedLiteral(k) for k in all_functions_keys])
|
||||
function = funcnames + Suppress("(") + expr + Suppress(")")
|
||||
function.setParseAction(
|
||||
lambda x: [all_functions[x[0]](x[1])]
|
||||
)
|
||||
|
||||
atom = number | function | varnames | lpar + expr + rpar
|
||||
factor << (atom + ZeroOrMore(exp + atom)).setParseAction(exp_parse_action) # 7^6
|
||||
paritem = factor + ZeroOrMore(Literal('||') + factor) # 5k || 4k
|
||||
paritem = paritem.setParseAction(parallel)
|
||||
term = paritem + ZeroOrMore((times | div) + paritem) # 7 * 5 / 4 - 3
|
||||
term = term.setParseAction(prod_parse_action)
|
||||
expr << Optional((plus | minus)) + term + ZeroOrMore((plus | minus) + term) # -5 + 4 - 3
|
||||
expr = expr.setParseAction(sum_parse_action)
|
||||
atom = number | function | varnames | Suppress("(") + expr + Suppress(")")
|
||||
|
||||
# Do the following in the correct order to preserve order of operation
|
||||
pow_term = atom + ZeroOrMore(Suppress("^") + atom)
|
||||
pow_term.setParseAction(exp_parse_action) # 7^6
|
||||
par_term = pow_term + ZeroOrMore(Suppress('||') + pow_term) # 5k || 4k
|
||||
par_term.setParseAction(parallel)
|
||||
prod_term = par_term + ZeroOrMore(times_div + par_term) # 7 * 5 / 4 - 3
|
||||
prod_term.setParseAction(prod_parse_action)
|
||||
sum_term = Optional(plus_minus) + prod_term + ZeroOrMore(plus_minus + prod_term) # -5 + 4 - 3
|
||||
sum_term.setParseAction(sum_parse_action)
|
||||
expr << sum_term # finish the recursion
|
||||
return (expr + stringEnd).parseString(string)[0]
|
||||
|
||||
99
common/lib/calc/calcfunctions.py
Normal file
99
common/lib/calc/calcfunctions.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Provide the mathematical functions that numpy doesn't.
|
||||
|
||||
Specifically, the secant/cosecant/cotangents and their inverses and
|
||||
hyperbolic counterparts
|
||||
"""
|
||||
import numpy
|
||||
|
||||
|
||||
# Normal Trig
|
||||
def sec(arg):
|
||||
"""
|
||||
Secant
|
||||
"""
|
||||
return 1 / numpy.cos(arg)
|
||||
|
||||
|
||||
def csc(arg):
|
||||
"""
|
||||
Cosecant
|
||||
"""
|
||||
return 1 / numpy.sin(arg)
|
||||
|
||||
|
||||
def cot(arg):
|
||||
"""
|
||||
Cotangent
|
||||
"""
|
||||
return 1 / numpy.tan(arg)
|
||||
|
||||
|
||||
# Inverse Trig
|
||||
# http://en.wikipedia.org/wiki/Inverse_trigonometric_functions#Relationships_among_the_inverse_trigonometric_functions
|
||||
def arcsec(val):
|
||||
"""
|
||||
Inverse secant
|
||||
"""
|
||||
return numpy.arccos(1. / val)
|
||||
|
||||
|
||||
def arccsc(val):
|
||||
"""
|
||||
Inverse cosecant
|
||||
"""
|
||||
return numpy.arcsin(1. / val)
|
||||
|
||||
|
||||
def arccot(val):
|
||||
"""
|
||||
Inverse cotangent
|
||||
"""
|
||||
if numpy.real(val) < 0:
|
||||
return -numpy.pi / 2 - numpy.arctan(val)
|
||||
else:
|
||||
return numpy.pi / 2 - numpy.arctan(val)
|
||||
|
||||
|
||||
# Hyperbolic Trig
|
||||
def sech(arg):
|
||||
"""
|
||||
Hyperbolic secant
|
||||
"""
|
||||
return 1 / numpy.cosh(arg)
|
||||
|
||||
|
||||
def csch(arg):
|
||||
"""
|
||||
Hyperbolic cosecant
|
||||
"""
|
||||
return 1 / numpy.sinh(arg)
|
||||
|
||||
|
||||
def coth(arg):
|
||||
"""
|
||||
Hyperbolic cotangent
|
||||
"""
|
||||
return 1 / numpy.tanh(arg)
|
||||
|
||||
|
||||
# And their inverses
|
||||
def arcsech(val):
|
||||
"""
|
||||
Inverse hyperbolic secant
|
||||
"""
|
||||
return numpy.arccosh(1. / val)
|
||||
|
||||
|
||||
def arccsch(val):
|
||||
"""
|
||||
Inverse hyperbolic cosecant
|
||||
"""
|
||||
return numpy.arcsinh(1. / val)
|
||||
|
||||
|
||||
def arccoth(val):
|
||||
"""
|
||||
Inverse hyperbolic cotangent
|
||||
"""
|
||||
return numpy.arctanh(1. / val)
|
||||
@@ -194,6 +194,105 @@ class EvaluatorTest(unittest.TestCase):
|
||||
arctan_angles = arcsin_angles
|
||||
self.assert_function_values('arctan', arctan_inputs, arctan_angles)
|
||||
|
||||
def test_reciprocal_trig_functions(self):
|
||||
"""
|
||||
Test the reciprocal trig functions provided in calc.py
|
||||
|
||||
which are: sec, csc, cot, arcsec, arccsc, arccot
|
||||
"""
|
||||
angles = ['-pi/4', 'pi/6', 'pi/5', '5*pi/4', '9*pi/4', '1 + j']
|
||||
sec_values = [1.414, 1.155, 1.236, -1.414, 1.414, 0.498 + 0.591j]
|
||||
csc_values = [-1.414, 2, 1.701, -1.414, 1.414, 0.622 - 0.304j]
|
||||
cot_values = [-1, 1.732, 1.376, 1, 1, 0.218 - 0.868j]
|
||||
|
||||
self.assert_function_values('sec', angles, sec_values)
|
||||
self.assert_function_values('csc', angles, csc_values)
|
||||
self.assert_function_values('cot', angles, cot_values)
|
||||
|
||||
arcsec_inputs = ['1.1547', '1.2361', '2', '-2', '-1.4142', '0.4983+0.5911*j']
|
||||
arcsec_angles = [0.524, 0.628, 1.047, 2.094, 2.356, 1 + 1j]
|
||||
self.assert_function_values('arcsec', arcsec_inputs, arcsec_angles)
|
||||
|
||||
arccsc_inputs = ['-1.1547', '-1.4142', '2', '1.7013', '1.1547', '0.6215-0.3039*j']
|
||||
arccsc_angles = [-1.047, -0.785, 0.524, 0.628, 1.047, 1 + 1j]
|
||||
self.assert_function_values('arccsc', arccsc_inputs, arccsc_angles)
|
||||
|
||||
# Has the same range as arccsc
|
||||
arccot_inputs = ['-0.5774', '-1', '1.7321', '1.3764', '0.5774', '(0.2176-0.868*j)']
|
||||
arccot_angles = arccsc_angles
|
||||
self.assert_function_values('arccot', arccot_inputs, arccot_angles)
|
||||
|
||||
def test_hyperbolic_functions(self):
|
||||
"""
|
||||
Test the hyperbolic functions
|
||||
|
||||
which are: sinh, cosh, tanh, sech, csch, coth
|
||||
"""
|
||||
inputs = ['0', '0.5', '1', '2', '1+j']
|
||||
neg_inputs = ['0', '-0.5', '-1', '-2', '-1-j']
|
||||
negate = lambda x: [-k for k in x]
|
||||
|
||||
# sinh is odd
|
||||
sinh_vals = [0, 0.521, 1.175, 3.627, 0.635 + 1.298j]
|
||||
self.assert_function_values('sinh', inputs, sinh_vals)
|
||||
self.assert_function_values('sinh', neg_inputs, negate(sinh_vals))
|
||||
|
||||
# cosh is even - do not negate
|
||||
cosh_vals = [1, 1.128, 1.543, 3.762, 0.834 + 0.989j]
|
||||
self.assert_function_values('cosh', inputs, cosh_vals)
|
||||
self.assert_function_values('cosh', neg_inputs, cosh_vals)
|
||||
|
||||
# tanh is odd
|
||||
tanh_vals = [0, 0.462, 0.762, 0.964, 1.084 + 0.272j]
|
||||
self.assert_function_values('tanh', inputs, tanh_vals)
|
||||
self.assert_function_values('tanh', neg_inputs, negate(tanh_vals))
|
||||
|
||||
# sech is even - do not negate
|
||||
sech_vals = [1, 0.887, 0.648, 0.266, 0.498 - 0.591j]
|
||||
self.assert_function_values('sech', inputs, sech_vals)
|
||||
self.assert_function_values('sech', neg_inputs, sech_vals)
|
||||
|
||||
# the following functions do not have 0 in their domain
|
||||
inputs = inputs[1:]
|
||||
neg_inputs = neg_inputs[1:]
|
||||
|
||||
# csch is odd
|
||||
csch_vals = [1.919, 0.851, 0.276, 0.304 - 0.622j]
|
||||
self.assert_function_values('csch', inputs, csch_vals)
|
||||
self.assert_function_values('csch', neg_inputs, negate(csch_vals))
|
||||
|
||||
# coth is odd
|
||||
coth_vals = [2.164, 1.313, 1.037, 0.868 - 0.218j]
|
||||
self.assert_function_values('coth', inputs, coth_vals)
|
||||
self.assert_function_values('coth', neg_inputs, negate(coth_vals))
|
||||
|
||||
def test_hyperbolic_inverses(self):
|
||||
"""
|
||||
Test the inverse hyperbolic functions
|
||||
|
||||
which are of the form arc[X]h
|
||||
"""
|
||||
results = [0, 0.5, 1, 2, 1 + 1j]
|
||||
|
||||
sinh_vals = ['0', '0.5211', '1.1752', '3.6269', '0.635+1.2985*j']
|
||||
self.assert_function_values('arcsinh', sinh_vals, results)
|
||||
|
||||
cosh_vals = ['1', '1.1276', '1.5431', '3.7622', '0.8337+0.9889*j']
|
||||
self.assert_function_values('arccosh', cosh_vals, results)
|
||||
|
||||
tanh_vals = ['0', '0.4621', '0.7616', '0.964', '1.0839+0.2718*j']
|
||||
self.assert_function_values('arctanh', tanh_vals, results)
|
||||
|
||||
sech_vals = ['1.0', '0.8868', '0.6481', '0.2658', '0.4983-0.5911*j']
|
||||
self.assert_function_values('arcsech', sech_vals, results)
|
||||
|
||||
results = results[1:]
|
||||
csch_vals = ['1.919', '0.8509', '0.2757', '0.3039-0.6215*j']
|
||||
self.assert_function_values('arccsch', csch_vals, results)
|
||||
|
||||
coth_vals = ['2.164', '1.313', '1.0373', '0.868-0.2176*j']
|
||||
self.assert_function_values('arccoth', coth_vals, results)
|
||||
|
||||
def test_other_functions(self):
|
||||
"""
|
||||
Test the non-trig functions provided in calc.py
|
||||
|
||||
@@ -470,6 +470,7 @@ class LoncapaProblem(object):
|
||||
python_path=python_path,
|
||||
cache=self.system.cache,
|
||||
slug=self.problem_id,
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
log.exception("Error while execing script code: " + all_code)
|
||||
|
||||
@@ -144,11 +144,11 @@ class InputTypeBase(object):
|
||||
self.tag = xml.tag
|
||||
self.system = system
|
||||
|
||||
## NOTE: ID should only come from one place. If it comes from multiple,
|
||||
## we use state first, XML second (in case the xml changed, but we have
|
||||
## existing state with an old id). Since we don't make this guarantee,
|
||||
## we can swap this around in the future if there's a more logical
|
||||
## order.
|
||||
# NOTE: ID should only come from one place. If it comes from multiple,
|
||||
# we use state first, XML second (in case the xml changed, but we have
|
||||
# existing state with an old id). Since we don't make this guarantee,
|
||||
# we can swap this around in the future if there's a more logical
|
||||
# order.
|
||||
|
||||
self.input_id = state.get('id', xml.get('id'))
|
||||
if self.input_id is None:
|
||||
@@ -769,7 +769,7 @@ class MatlabInput(CodeInput):
|
||||
|
||||
# construct xqueue headers
|
||||
qinterface = self.system.xqueue['interface']
|
||||
qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat)
|
||||
qtime = datetime.utcnow().strftime(xqueue_interface.dateformat)
|
||||
callback_url = self.system.xqueue['construct_callback']('ungraded_response')
|
||||
anonymous_student_id = self.system.anonymous_student_id
|
||||
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
|
||||
|
||||
@@ -288,7 +288,14 @@ class LoncapaResponse(object):
|
||||
}
|
||||
|
||||
try:
|
||||
safe_exec.safe_exec(code, globals_dict, python_path=self.context['python_path'], slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
code,
|
||||
globals_dict,
|
||||
python_path=self.context['python_path'],
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
msg = 'Error %s in evaluating hint function %s' % (err, hintfn)
|
||||
msg += "\nSee XML source line %s" % getattr(
|
||||
@@ -973,7 +980,14 @@ class CustomResponse(LoncapaResponse):
|
||||
'ans': ans,
|
||||
}
|
||||
globals_dict.update(kwargs)
|
||||
safe_exec.safe_exec(code, globals_dict, python_path=self.context['python_path'], slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
code,
|
||||
globals_dict,
|
||||
python_path=self.context['python_path'],
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
return globals_dict['cfn_return']
|
||||
return check_function
|
||||
|
||||
@@ -1090,7 +1104,14 @@ class CustomResponse(LoncapaResponse):
|
||||
# exec the check function
|
||||
if isinstance(self.code, basestring):
|
||||
try:
|
||||
safe_exec.safe_exec(self.code, self.context, cache=self.system.cache, slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
self.code,
|
||||
self.context,
|
||||
cache=self.system.cache,
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
self._handle_exec_exception(err)
|
||||
|
||||
@@ -1717,6 +1738,7 @@ class FormulaResponse(LoncapaResponse):
|
||||
student_variables = dict()
|
||||
# ranges give numerical ranges for testing
|
||||
for var in ranges:
|
||||
# TODO: allow specified ranges (i.e. integers and complex numbers) for random variables
|
||||
value = random.uniform(*ranges[var])
|
||||
instructor_variables[str(var)] = value
|
||||
student_variables[str(var)] = value
|
||||
@@ -1814,7 +1836,14 @@ class SchematicResponse(LoncapaResponse):
|
||||
]
|
||||
self.context.update({'submission': submission})
|
||||
try:
|
||||
safe_exec.safe_exec(self.code, self.context, cache=self.system.cache, slug=self.id)
|
||||
safe_exec.safe_exec(
|
||||
self.code,
|
||||
self.context,
|
||||
cache=self.system.cache,
|
||||
slug=self.id,
|
||||
random_seed=self.context['seed'],
|
||||
unsafely=self.system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
msg = 'Error %s in evaluating SchematicResponse' % err
|
||||
raise ResponseError(msg)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Capa's specialized use of codejail.safe_exec."""
|
||||
|
||||
from codejail.safe_exec import safe_exec as codejail_safe_exec
|
||||
from codejail.safe_exec import not_safe_exec as codejail_not_safe_exec
|
||||
from codejail.safe_exec import json_safe, SafeExecException
|
||||
from . import lazymod
|
||||
from statsd import statsd
|
||||
@@ -71,7 +72,7 @@ def update_hash(hasher, obj):
|
||||
|
||||
|
||||
@statsd.timed('capa.safe_exec.time')
|
||||
def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None):
|
||||
def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None, slug=None, unsafely=False):
|
||||
"""
|
||||
Execute python code safely.
|
||||
|
||||
@@ -90,6 +91,8 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None
|
||||
`slug` is an arbitrary string, a description that's meaningful to the
|
||||
caller, that will be used in log messages.
|
||||
|
||||
If `unsafely` is true, then the code will actually be executed without sandboxing.
|
||||
|
||||
"""
|
||||
# Check the cache for a previous result.
|
||||
if cache:
|
||||
@@ -111,9 +114,15 @@ def safe_exec(code, globals_dict, random_seed=None, python_path=None, cache=None
|
||||
# Create the complete code we'll run.
|
||||
code_prolog = CODE_PROLOG % random_seed
|
||||
|
||||
# Decide which code executor to use.
|
||||
if unsafely:
|
||||
exec_fn = codejail_not_safe_exec
|
||||
else:
|
||||
exec_fn = codejail_safe_exec
|
||||
|
||||
# Run the code! Results are side effects in globals_dict.
|
||||
try:
|
||||
codejail_safe_exec(
|
||||
exec_fn(
|
||||
code_prolog + LAZY_IMPORTS + code, globals_dict,
|
||||
python_path=python_path, slug=slug,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Test safe_exec.py"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import os.path
|
||||
import random
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
from capa.safe_exec import safe_exec, update_hash
|
||||
from codejail.safe_exec import SafeExecException
|
||||
from codejail.jail_code import is_configured
|
||||
|
||||
|
||||
class TestSafeExec(unittest.TestCase):
|
||||
@@ -68,6 +72,24 @@ class TestSafeExec(unittest.TestCase):
|
||||
self.assertIn("ZeroDivisionError", cm.exception.message)
|
||||
|
||||
|
||||
class TestSafeOrNot(unittest.TestCase):
|
||||
def test_cant_do_something_forbidden(self):
|
||||
# Can't test for forbiddenness if CodeJail isn't configured for python.
|
||||
if not is_configured("python"):
|
||||
raise SkipTest
|
||||
|
||||
g = {}
|
||||
with self.assertRaises(SafeExecException) as cm:
|
||||
safe_exec("import os; files = os.listdir('/')", g)
|
||||
self.assertIn("OSError", cm.exception.message)
|
||||
self.assertIn("Permission denied", cm.exception.message)
|
||||
|
||||
def test_can_do_something_forbidden_if_run_unsafely(self):
|
||||
g = {}
|
||||
safe_exec("import os; files = os.listdir('/')", g, unsafely=True)
|
||||
self.assertEqual(g['files'], os.listdir('/'))
|
||||
|
||||
|
||||
class DictCache(object):
|
||||
"""A cache implementation over a simple dict, for testing."""
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="block block-comment">${comment}</div>
|
||||
|
||||
<div class="block">${comment_prompt}</div>
|
||||
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
|
||||
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment" aria-describedby="answer_${id}">${comment_value|h}</textarea>
|
||||
|
||||
<div class="block">${tag_prompt}</div>
|
||||
<ul class="tags">
|
||||
@@ -22,11 +22,11 @@
|
||||
<li>
|
||||
% if has_options_value:
|
||||
% if all([c == 'correct' for c in option['choice'], status]):
|
||||
<span class="tag-status correct" id="status_${id}"></span>
|
||||
<span class="tag-status correct" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Correct</span></span>
|
||||
% elif all([c == 'partially-correct' for c in option['choice'], status]):
|
||||
<span class="tag-status partially-correct" id="status_${id}"></span>
|
||||
<span class="tag-status partially-correct" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Partially Correct</span></span>
|
||||
% elif all([c == 'incorrect' for c in option['choice'], status]):
|
||||
<span class="tag-status incorrect" id="status_${id}"></span>
|
||||
<span class="tag-status incorrect" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Incorrect</span></span>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
% endif
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Unanswered</span></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Incorrect</span></span>
|
||||
% elif status == 'incorrect' and not has_options_value:
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Incorrect</span></span>
|
||||
% endif
|
||||
|
||||
<p id="answer_${id}" class="answer answer-annotation"></p>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" data-input-id="${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" data-input-id="${id}" value="${value|h}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
% if input_type == 'checkbox' or not value:
|
||||
% if status == 'unsubmitted' or show_correctness == 'never':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"><span class="sr">Status: correct</span></span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}"><span class="sr">Status: incorrect</span></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}"><span class="sr">Status: incomplete</span></span>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
% for choice_id, choice_description in choices:
|
||||
<label for="input_${id}_${choice_id}"
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
<%
|
||||
<%
|
||||
if status == 'correct':
|
||||
correctness = 'correct'
|
||||
elif status == 'incorrect':
|
||||
@@ -31,14 +31,29 @@
|
||||
% endif
|
||||
% endif
|
||||
>
|
||||
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
|
||||
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" aria-describedby="answer_${id}" value="${choice_id}"
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
checked="true"
|
||||
checked="true"
|
||||
% elif input_type != 'radio' and choice_id in value:
|
||||
checked="true"
|
||||
% endif
|
||||
|
||||
/> ${choice_description} </label>
|
||||
/> ${choice_description}
|
||||
|
||||
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
|
||||
<%
|
||||
if status == 'correct':
|
||||
correctness = 'correct'
|
||||
elif status == 'incorrect':
|
||||
correctness = 'incorrect'
|
||||
else:
|
||||
correctness = None
|
||||
%>
|
||||
% if correctness and not show_correctness=='never':
|
||||
<span class="sr" aria-describedby="input_${id}_${choice_id}">Status: ${correctness}</span>
|
||||
% endif
|
||||
% endif
|
||||
</label>
|
||||
% endfor
|
||||
<span id="answer_${id}"></span>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section id="textbox_${id}" class="textbox">
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
<div class="grader-status">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Unanswered</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Correct</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Incorrect</span>
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span class="processing" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
% endif
|
||||
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
|
||||
<input type="text" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
% elif status == 'incomplete':
|
||||
<div class="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
|
||||
<div id="protex_container"></div>
|
||||
<input type="hidden" name="target_shape" id="target_shape" value ="${target_shape}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
% endif
|
||||
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
|
||||
style="display:none;"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
% elif status == 'incomplete':
|
||||
<div class="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
|
||||
<div id="genex_container"></div>
|
||||
<input type="hidden" name="genex_dna_sequence" id="genex_dna_sequence" value ="${genex_dna_sequence}"></input>
|
||||
<input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
<input type="hidden" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"/>
|
||||
|
||||
<button id="reset_${id}" class="reset">Reset</button>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -5,12 +5,20 @@
|
||||
</div>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unanswered</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% endif
|
||||
</span>
|
||||
|
||||
@@ -19,13 +19,21 @@
|
||||
% endif
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unanswered</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% endif
|
||||
% if msg:
|
||||
<br/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section id="textbox_${id}" class="textbox">
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
|
||||
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
<div class="grader-status">
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"><span class="sr">Status: </span>Unanswered</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Correct</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Incorrect</span>
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span class="processing" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
$(parent_elt).find('.action').after(alert_elem);
|
||||
$(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// hook up the plot button
|
||||
var plot = function(event) {
|
||||
@@ -97,10 +97,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
var save_callback = function(response) {
|
||||
var save_callback = function(response) {
|
||||
if(response.success) {
|
||||
// send information to the problem's plot functionality
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
{'submission': submission}, plot_callback);
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<form class="option-input">
|
||||
<select name="input_${id}" id="input_${id}" >
|
||||
<select name="input_${id}" id="input_${id}" aria-describedby="answer_${id}">
|
||||
<option value="option_${id}_dummy_default"> </option>
|
||||
% for option_id, option_description in options:
|
||||
<option value="${option_id}"
|
||||
@@ -13,12 +13,20 @@
|
||||
<span id="answer_${id}"></span>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unsubmitted</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incomplete</span>
|
||||
</span>
|
||||
% endif
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<span>
|
||||
<input type="hidden" class="schematic" height="${height}" width="${width}" parts="${parts}" analyses="${analyses}" name="input_${id}" id="input_${id}" value="" initial_value=""/>
|
||||
<input type="hidden" class="schematic" height="${height}" width="${width}" parts="${parts}" analyses="${analyses}" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="" initial_value=""/>
|
||||
|
||||
<div id="value_${id}" style="display:none">${value}</div>
|
||||
<div id="initial_value_${id}" style="display:none">${initial_value}</div>
|
||||
@@ -13,13 +13,21 @@
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
% if status == 'unsubmitted':
|
||||
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: unsubmitted</span>
|
||||
</span>
|
||||
% elif status == 'correct':
|
||||
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: correct</span>
|
||||
</span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incorrect</span>
|
||||
</span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
|
||||
<span class="sr">Status: incomplete</span>
|
||||
</span>
|
||||
% endif
|
||||
</span>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
|
||||
% if do_math:
|
||||
class="math"
|
||||
% endif
|
||||
@@ -33,7 +33,7 @@
|
||||
/>
|
||||
${trailing_text | h}
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
|
||||
style="display:none;"
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
<p class="status" aria-describedby="input_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
|
||||
@@ -640,6 +640,23 @@ class StringResponseTest(ResponseTest):
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
self.assertEquals(correct_map.get_hint('1_2_1'), "Hello??")
|
||||
|
||||
def test_hint_function_randomization(self):
|
||||
# The hint function should get the seed from the problem.
|
||||
problem = self.build_problem(
|
||||
answer="1",
|
||||
hintfn="gimme_a_random_hint",
|
||||
script=textwrap.dedent("""
|
||||
def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap):
|
||||
answer = str(random.randint(0, 1e9))
|
||||
new_cmap.set_hint_and_mode(answer_ids[0], answer, "always")
|
||||
|
||||
""")
|
||||
)
|
||||
correct_map = problem.grade_answers({'1_2_1': '2'})
|
||||
hint = correct_map.get_hint('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(hint, str(r.randint(0, 1e9)))
|
||||
|
||||
|
||||
class CodeResponseTest(ResponseTest):
|
||||
from response_xml_factory import CodeResponseXMLFactory
|
||||
@@ -948,7 +965,6 @@ class CustomResponseTest(ResponseTest):
|
||||
xml_factory_class = CustomResponseXMLFactory
|
||||
|
||||
def test_inline_code(self):
|
||||
|
||||
# For inline code, we directly modify global context variables
|
||||
# 'answers' is a list of answers provided to us
|
||||
# 'correct' is a list we fill in with True/False
|
||||
@@ -961,15 +977,14 @@ class CustomResponseTest(ResponseTest):
|
||||
self.assert_grade(problem, '0', 'incorrect')
|
||||
|
||||
def test_inline_message(self):
|
||||
|
||||
# Inline code can update the global messages list
|
||||
# to pass messages to the CorrectMap for a particular input
|
||||
# The code can also set the global overall_message (str)
|
||||
# to pass a message that applies to the whole response
|
||||
inline_script = textwrap.dedent("""
|
||||
messages[0] = "Test Message"
|
||||
overall_message = "Overall message"
|
||||
""")
|
||||
messages[0] = "Test Message"
|
||||
overall_message = "Overall message"
|
||||
""")
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
@@ -983,8 +998,19 @@ class CustomResponseTest(ResponseTest):
|
||||
overall_msg = correctmap.get_overall_message()
|
||||
self.assertEqual(overall_msg, "Overall message")
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
def test_inline_randomization(self):
|
||||
# Make sure the seed from the problem gets fed into the script execution.
|
||||
inline_script = """messages[0] = str(random.randint(0, 1e9))"""
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
correctmap = problem.grade_answers(input_dict)
|
||||
|
||||
input_msg = correctmap.get_msg('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(input_msg, str(r.randint(0, 1e9)))
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
# For function code, we pass in these arguments:
|
||||
#
|
||||
# 'expect' is the expect attribute of the <customresponse>
|
||||
@@ -1212,6 +1238,29 @@ class CustomResponseTest(ResponseTest):
|
||||
with self.assertRaises(ResponseError):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
def test_setup_randomization(self):
|
||||
# Ensure that the problem setup script gets the random seed from the problem.
|
||||
script = textwrap.dedent("""
|
||||
num = random.randint(0, 1e9)
|
||||
""")
|
||||
problem = self.build_problem(script=script)
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(r.randint(0, 1e9), problem.context['num'])
|
||||
|
||||
def test_check_function_randomization(self):
|
||||
# The check function should get random-seeded from the problem.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'ok': True, 'msg': str(random.randint(0, 1e9))}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func", expect="42")
|
||||
input_dict = {'1_2_1': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
r = random.Random(problem.seed)
|
||||
self.assertEqual(msg, str(r.randint(0, 1e9)))
|
||||
|
||||
def test_module_imports_inline(self):
|
||||
'''
|
||||
Check that the correct modules are available to custom
|
||||
@@ -1275,7 +1324,6 @@ class SchematicResponseTest(ResponseTest):
|
||||
xml_factory_class = SchematicResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
|
||||
# Most of the schematic-specific work is handled elsewhere
|
||||
# (in client-side JavaScript)
|
||||
# The <schematicresponse> is responsible only for executing the
|
||||
@@ -1290,7 +1338,7 @@ class SchematicResponseTest(ResponseTest):
|
||||
|
||||
# The actual dictionary would contain schematic information
|
||||
# sent from the JavaScript simulation
|
||||
submission_dict = {'test': 'test'}
|
||||
submission_dict = {'test': 'the_answer'}
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
@@ -1299,8 +1347,19 @@ class SchematicResponseTest(ResponseTest):
|
||||
# is what we expect)
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
def test_script_exception(self):
|
||||
def test_check_function_randomization(self):
|
||||
# The check function should get a random seed from the problem.
|
||||
script = "correct = ['correct' if (submission[0]['num'] == random.randint(0, 1e9)) else 'incorrect']"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
r = random.Random(problem.seed)
|
||||
submission_dict = {'num': r.randint(0, 1e9)}
|
||||
input_dict = {'1_2_1': json.dumps(submission_dict)}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
def test_script_exception(self):
|
||||
# Construct a script that will raise an exception
|
||||
script = "raise Exception('test')"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
@@ -6,7 +6,7 @@ from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.exceptions import InvalidDefinitionError
|
||||
from xblock.core import String, Scope, Object
|
||||
from xblock.core import String, Scope, Dict
|
||||
|
||||
DEFAULT = "_DEFAULT_GROUP"
|
||||
|
||||
@@ -32,9 +32,9 @@ def group_from_value(groups, v):
|
||||
|
||||
|
||||
class ABTestFields(object):
|
||||
group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
|
||||
group_assignments = Object(help="What group this user belongs to", scope=Scope.preferences, default={})
|
||||
group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
|
||||
group_portions = Dict(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
|
||||
group_assignments = Dict(help="What group this user belongs to", scope=Scope.preferences, default={})
|
||||
group_content = Dict(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
|
||||
experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content)
|
||||
has_children = True
|
||||
|
||||
|
||||
@@ -125,6 +125,5 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
|
||||
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
|
||||
module_class = AnnotatableModule
|
||||
stores_state = True
|
||||
template_dir_name = "annotatable"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
@@ -11,16 +11,16 @@ import sys
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from capa.responsetypes import StudentInputError,\
|
||||
from capa.responsetypes import StudentInputError, \
|
||||
ResponseError, LoncapaProblemError
|
||||
from capa.util import convert_files_to_filenames
|
||||
from .progress import Progress
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xblock.core import Scope, String, Boolean, Object
|
||||
from .fields import Timedelta, Date, StringyInteger, StringyFloat
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
from xblock.core import Scope, String, Boolean, Dict, Integer, Float
|
||||
from .fields import Timedelta, Date
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -65,11 +65,11 @@ class ComplexEncoder(json.JSONEncoder):
|
||||
|
||||
|
||||
class CapaFields(object):
|
||||
attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state)
|
||||
max_attempts = StringyInteger(
|
||||
attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state)
|
||||
max_attempts = Integer(
|
||||
display_name="Maximum Attempts",
|
||||
help="Defines the number of times a student can try to answer this problem. If the value is not set, infinite attempts are allowed.",
|
||||
values={"min": 1}, scope=Scope.settings
|
||||
values={"min": 0}, scope=Scope.settings
|
||||
)
|
||||
due = Date(help="Date that this problem is due by", scope=Scope.settings)
|
||||
graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings)
|
||||
@@ -95,12 +95,12 @@ class CapaFields(object):
|
||||
{"display_name": "Per Student", "value": "per_student"}]
|
||||
)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={})
|
||||
input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
|
||||
student_answers = Object(help="Dictionary with the current student responses", scope=Scope.user_state)
|
||||
correct_map = Dict(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={})
|
||||
input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
|
||||
student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state)
|
||||
done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state)
|
||||
seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state)
|
||||
weight = StringyFloat(
|
||||
seed = Integer(help="Random seed for this student", scope=Scope.user_state)
|
||||
weight = Float(
|
||||
display_name="Problem Weight",
|
||||
help="Defines the number of points each problem is worth. If the value is not set, each response field in the problem is worth one point.",
|
||||
values={"min": 0, "step": .1},
|
||||
@@ -134,7 +134,7 @@ class CapaModule(CapaFields, XModule):
|
||||
def __init__(self, system, location, descriptor, model_data):
|
||||
XModule.__init__(self, system, location, descriptor, model_data)
|
||||
|
||||
due_date = time_to_datetime(self.due)
|
||||
due_date = self.due
|
||||
|
||||
if self.graceperiod is not None and due_date:
|
||||
self.close_date = due_date + self.graceperiod
|
||||
@@ -315,7 +315,7 @@ class CapaModule(CapaFields, XModule):
|
||||
# If the user has forced the save button to display,
|
||||
# then show it as long as the problem is not closed
|
||||
# (past due / too many attempts)
|
||||
if self.force_save_button == "true":
|
||||
if self.force_save_button:
|
||||
return not self.closed()
|
||||
else:
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
@@ -502,7 +502,7 @@ class CapaModule(CapaFields, XModule):
|
||||
Is it now past this problem's due date, including grace period?
|
||||
"""
|
||||
return (self.close_date is not None and
|
||||
datetime.datetime.utcnow() > self.close_date)
|
||||
datetime.datetime.now(UTC()) > self.close_date)
|
||||
|
||||
def closed(self):
|
||||
''' Is the student still allowed to submit answers? '''
|
||||
@@ -747,7 +747,7 @@ class CapaModule(CapaFields, XModule):
|
||||
|
||||
# Problem queued. Students must wait a specified waittime before they are allowed to submit
|
||||
if self.lcp.is_queued():
|
||||
current_time = datetime.datetime.now()
|
||||
current_time = datetime.datetime.now(UTC())
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
waittime_between_requests = self.system.xqueue['waittime']
|
||||
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
@@ -902,7 +902,6 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
|
||||
module_class = CapaModule
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = 'problem'
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
|
||||
@@ -5,10 +5,10 @@ from pkg_resources import resource_string
|
||||
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from .x_module import XModule
|
||||
from xblock.core import Integer, Scope, String, List
|
||||
from xblock.core import Integer, Scope, String, List, Float, Boolean
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
from collections import namedtuple
|
||||
from .fields import Date, StringyFloat, StringyInteger, StringyBoolean
|
||||
from .fields import Date
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -53,27 +53,27 @@ class CombinedOpenEndedFields(object):
|
||||
help="This name appears in the horizontal navigation at the top of the page.",
|
||||
default="Open Ended Grading", scope=Scope.settings
|
||||
)
|
||||
current_task_number = StringyInteger(help="Current task that the student is on.", default=0, scope=Scope.user_state)
|
||||
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state)
|
||||
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state)
|
||||
state = String(help="Which step within the current task that the student is on.", default="initial",
|
||||
scope=Scope.user_state)
|
||||
student_attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0,
|
||||
student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
|
||||
scope=Scope.user_state)
|
||||
ready_to_reset = StringyBoolean(
|
||||
ready_to_reset = Boolean(
|
||||
help="If the problem is ready to be reset or not.", default=False,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
attempts = StringyInteger(
|
||||
attempts = Integer(
|
||||
display_name="Maximum Attempts",
|
||||
help="The number of times the student can try to answer this problem.", default=1,
|
||||
scope=Scope.settings, values = {"min" : 1 }
|
||||
)
|
||||
is_graded = StringyBoolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
|
||||
accept_file_upload = StringyBoolean(
|
||||
is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
|
||||
accept_file_upload = Boolean(
|
||||
display_name="Allow File Uploads",
|
||||
help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings
|
||||
)
|
||||
skip_spelling_checks = StringyBoolean(
|
||||
skip_spelling_checks = Boolean(
|
||||
display_name="Disable Quality Filter",
|
||||
help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.",
|
||||
default=False, scope=Scope.settings
|
||||
@@ -86,7 +86,7 @@ class CombinedOpenEndedFields(object):
|
||||
)
|
||||
version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
weight = StringyFloat(
|
||||
weight = Float(
|
||||
display_name="Problem Weight",
|
||||
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
|
||||
scope=Scope.settings, values = {"min" : 0 , "step": ".1"}
|
||||
@@ -239,7 +239,6 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
|
||||
mako_template = "widgets/open-ended-edit.html"
|
||||
module_class = CombinedOpenEndedModule
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
always_recalculate_grades = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
@@ -92,7 +92,7 @@ class ConditionalModule(ConditionalFields, XModule):
|
||||
if xml_value and self.required_modules:
|
||||
for module in self.required_modules:
|
||||
if not hasattr(module, attr_name):
|
||||
# We don't throw an exception here because it is possible for
|
||||
# We don't throw an exception here because it is possible for
|
||||
# the descriptor of a required module to have a property but
|
||||
# for the resulting module to be a (flavor of) ErrorModule.
|
||||
# So just log and return false.
|
||||
@@ -161,7 +161,6 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = False
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -4,7 +4,6 @@ from math import exp
|
||||
from lxml import etree
|
||||
from path import path # NOTE (THK): Only used for detecting presence of syllabus
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import dateutil.parser
|
||||
@@ -14,11 +13,12 @@ from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.timeparse import parse_time
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
import json
|
||||
|
||||
from xblock.core import Scope, List, String, Object, Boolean
|
||||
from xblock.core import Scope, List, String, Dict, Boolean
|
||||
from .fields import Date
|
||||
from django.utils.timezone import UTC
|
||||
from xmodule.util import date_utils
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -93,7 +93,7 @@ class Textbook(object):
|
||||
# see if we already fetched this
|
||||
if toc_url in _cached_toc:
|
||||
(table_of_contents, timestamp) = _cached_toc[toc_url]
|
||||
age = datetime.now() - timestamp
|
||||
age = datetime.now(UTC) - timestamp
|
||||
# expire every 10 minutes
|
||||
if age.seconds < 600:
|
||||
return table_of_contents
|
||||
@@ -154,25 +154,25 @@ class CourseFields(object):
|
||||
start = Date(help="Start time when this module is visible", scope=Scope.settings)
|
||||
end = Date(help="Date that this class ends", scope=Scope.settings)
|
||||
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
|
||||
grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content)
|
||||
grading_policy = Dict(help="Grading policy definition for this class", scope=Scope.content)
|
||||
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
|
||||
display_name = String(help="Display name for this module", scope=Scope.settings)
|
||||
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
|
||||
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
|
||||
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
|
||||
discussion_topics = Object(
|
||||
discussion_topics = Dict(
|
||||
help="Map of topics names to ids",
|
||||
scope=Scope.settings
|
||||
)
|
||||
testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings)
|
||||
testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings)
|
||||
announcement = Date(help="Date this course is announced", scope=Scope.settings)
|
||||
cohort_config = Object(help="Dictionary defining cohort configuration", scope=Scope.settings)
|
||||
cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings)
|
||||
is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings)
|
||||
no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings)
|
||||
disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings)
|
||||
pdf_textbooks = List(help="List of dictionaries containing pdf_textbook configuration", scope=Scope.settings)
|
||||
html_textbooks = List(help="List of dictionaries containing html_textbook configuration", scope=Scope.settings)
|
||||
remote_gradebook = Object(scope=Scope.settings)
|
||||
remote_gradebook = Dict(scope=Scope.settings)
|
||||
allow_anonymous = Boolean(scope=Scope.settings, default=True)
|
||||
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
|
||||
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
|
||||
@@ -219,8 +219,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
msg = None
|
||||
if self.start is None:
|
||||
msg = "Course loaded without a valid start date. id = %s" % self.id
|
||||
# hack it -- start in 1970
|
||||
self.start = time.gmtime(0)
|
||||
self.start = datetime.now(UTC())
|
||||
log.critical(msg)
|
||||
self.system.error_tracker(msg)
|
||||
|
||||
@@ -392,7 +391,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
textbook_xml_object.set('book_url', textbook.book_url)
|
||||
|
||||
xml_object.append(textbook_xml_object)
|
||||
|
||||
|
||||
return xml_object
|
||||
|
||||
def has_ended(self):
|
||||
@@ -403,10 +402,10 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
if self.end is None:
|
||||
return False
|
||||
|
||||
return time.gmtime() > self.end
|
||||
return datetime.now(UTC()) > self.end
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
return datetime.now(UTC()) > self.start
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
@@ -547,14 +546,16 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
announcement = self.announcement
|
||||
if announcement is not None:
|
||||
announcement = time_to_datetime(announcement)
|
||||
announcement = announcement
|
||||
|
||||
try:
|
||||
start = dateutil.parser.parse(self.advertised_start)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=UTC())
|
||||
except (ValueError, AttributeError):
|
||||
start = time_to_datetime(self.start)
|
||||
start = self.start
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC())
|
||||
|
||||
return announcement, start, now
|
||||
|
||||
@@ -656,7 +657,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
elif self.advertised_start is None and self.start is None:
|
||||
return 'TBD'
|
||||
else:
|
||||
return time.strftime("%b %d, %Y", self.advertised_start or self.start)
|
||||
return (self.advertised_start or self.start).strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def end_date_text(self):
|
||||
@@ -665,7 +666,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
If the course does not have an end date set (course.end is None), an empty string will be returned.
|
||||
"""
|
||||
return '' if self.end is None else time.strftime("%b %d, %Y", self.end)
|
||||
return '' if self.end is None else self.end.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def forum_posts_allowed(self):
|
||||
@@ -673,7 +674,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
blackout_periods = [(parse_time(start), parse_time(end))
|
||||
for start, end
|
||||
in self.discussion_blackouts]
|
||||
now = time.gmtime()
|
||||
now = datetime.now(UTC())
|
||||
for start, end in blackout_periods:
|
||||
if start <= now <= end:
|
||||
return False
|
||||
@@ -699,7 +700,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date
|
||||
if self.last_eligible_appointment_date is None:
|
||||
raise ValueError("Last appointment date must be specified")
|
||||
self.registration_start_date = self._try_parse_time('Registration_Start_Date') or time.gmtime(0)
|
||||
self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or
|
||||
datetime.utcfromtimestamp(0))
|
||||
self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date
|
||||
# do validation within the exam info:
|
||||
if self.registration_start_date > self.registration_end_date:
|
||||
@@ -725,32 +727,32 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
return None
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.first_eligible_appointment_date
|
||||
return datetime.now(UTC()) > self.first_eligible_appointment_date
|
||||
|
||||
def has_ended(self):
|
||||
return time.gmtime() > self.last_eligible_appointment_date
|
||||
return datetime.now(UTC()) > self.last_eligible_appointment_date
|
||||
|
||||
def has_started_registration(self):
|
||||
return time.gmtime() > self.registration_start_date
|
||||
return datetime.now(UTC()) > self.registration_start_date
|
||||
|
||||
def has_ended_registration(self):
|
||||
return time.gmtime() > self.registration_end_date
|
||||
return datetime.now(UTC()) > self.registration_end_date
|
||||
|
||||
def is_registering(self):
|
||||
now = time.gmtime()
|
||||
now = datetime.now(UTC())
|
||||
return now >= self.registration_start_date and now <= self.registration_end_date
|
||||
|
||||
@property
|
||||
def first_eligible_appointment_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.first_eligible_appointment_date)
|
||||
return self.first_eligible_appointment_date.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def last_eligible_appointment_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.last_eligible_appointment_date)
|
||||
return self.last_eligible_appointment_date.strftime("%b %d, %Y")
|
||||
|
||||
@property
|
||||
def registration_end_date_text(self):
|
||||
return time.strftime("%b %d, %Y at %H:%M UTC", self.registration_end_date)
|
||||
return date_utils.get_default_time_display(self.registration_end_date)
|
||||
|
||||
@property
|
||||
def current_test_center_exam(self):
|
||||
|
||||
@@ -551,10 +551,24 @@ section.problem {
|
||||
section.action {
|
||||
margin-top: 20px;
|
||||
|
||||
input.save {
|
||||
.save, .check, .show {
|
||||
height: ($baseline*2);
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.save {
|
||||
@extend .blue-button;
|
||||
}
|
||||
|
||||
.show {
|
||||
|
||||
.show-label {
|
||||
font-size: 1.0em;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.submission_feedback {
|
||||
// background: #F3F3F3;
|
||||
// border: 1px solid #ddd;
|
||||
@@ -811,13 +825,13 @@ section.problem {
|
||||
}
|
||||
.selected-grade {
|
||||
background: #666;
|
||||
color: white;
|
||||
color: white;
|
||||
}
|
||||
input[type=radio]:checked + label {
|
||||
background: #666;
|
||||
color: white; }
|
||||
input[class='score-selection'] {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,11 +892,11 @@ section.problem {
|
||||
.tag-status, .tag { padding: .25em .5em; }
|
||||
}
|
||||
}
|
||||
textarea.comment {
|
||||
textarea.comment {
|
||||
$num-lines-to-show: 5;
|
||||
$line-height: 1.4em;
|
||||
$padding: .2em;
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
padding: $padding (2 * $padding);
|
||||
line-height: $line-height;
|
||||
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
|
||||
|
||||
@@ -87,7 +87,7 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
|
||||
# but url_names aren't guaranteed to be unique between descriptor types,
|
||||
# and ErrorDescriptor can wrap any type. When the wrapped module is fixed,
|
||||
# it will be written out with the original url_name.
|
||||
name=hashlib.sha1(contents).hexdigest()
|
||||
name=hashlib.sha1(contents.encode('utf8')).hexdigest()
|
||||
)
|
||||
|
||||
# real metadata stays in the content, but add a display name
|
||||
|
||||
@@ -12,3 +12,12 @@ class ProcessingError(Exception):
|
||||
For example: if an exception occurs while checking a capa problem.
|
||||
'''
|
||||
pass
|
||||
|
||||
class InvalidVersionError(Exception):
|
||||
"""
|
||||
Tried to save an item with a location that a store cannot support (e.g., draft version
|
||||
for a non-leaf node)
|
||||
"""
|
||||
def __init__(self, location):
|
||||
super(InvalidVersionError, self).__init__()
|
||||
self.location = location
|
||||
|
||||
@@ -2,19 +2,18 @@ import time
|
||||
import logging
|
||||
import re
|
||||
|
||||
from datetime import timedelta
|
||||
from xblock.core import ModelType
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
|
||||
from xblock.core import Integer, Float, Boolean
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Date(ModelType):
|
||||
'''
|
||||
Date fields know how to parse and produce json (iso) compatible formats.
|
||||
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes.
|
||||
'''
|
||||
def from_json(self, field):
|
||||
"""
|
||||
@@ -27,11 +26,15 @@ class Date(ModelType):
|
||||
elif field is "":
|
||||
return None
|
||||
elif isinstance(field, basestring):
|
||||
d = dateutil.parser.parse(field)
|
||||
return d.utctimetuple()
|
||||
result = dateutil.parser.parse(field)
|
||||
if result.tzinfo is None:
|
||||
result = result.replace(tzinfo=UTC())
|
||||
return result
|
||||
elif isinstance(field, (int, long, float)):
|
||||
return time.gmtime(field / 1000)
|
||||
return datetime.datetime.fromtimestamp(field / 1000, UTC())
|
||||
elif isinstance(field, time.struct_time):
|
||||
return datetime.datetime.fromtimestamp(time.mktime(field), UTC())
|
||||
elif isinstance(field, datetime.datetime):
|
||||
return field
|
||||
else:
|
||||
msg = "Field {0} has bad value '{1}'".format(
|
||||
@@ -49,7 +52,11 @@ class Date(ModelType):
|
||||
# struct_times are always utc
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%SZ', value)
|
||||
elif isinstance(value, datetime.datetime):
|
||||
return value.isoformat() + 'Z'
|
||||
if value.tzinfo is None or value.utcoffset().total_seconds() == 0:
|
||||
# isoformat adds +00:00 rather than Z
|
||||
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
else:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
@@ -66,6 +73,8 @@ class Timedelta(ModelType):
|
||||
|
||||
Returns a datetime.timedelta parsed from the string
|
||||
"""
|
||||
if time_str is None:
|
||||
return None
|
||||
parts = TIMEDELTA_REGEX.match(time_str)
|
||||
if not parts:
|
||||
return
|
||||
@@ -74,7 +83,7 @@ class Timedelta(ModelType):
|
||||
for (name, param) in parts.iteritems():
|
||||
if param:
|
||||
time_params[name] = int(param)
|
||||
return timedelta(**time_params)
|
||||
return datetime.timedelta(**time_params)
|
||||
|
||||
def to_json(self, value):
|
||||
values = []
|
||||
@@ -83,42 +92,3 @@ class Timedelta(ModelType):
|
||||
if cur_value > 0:
|
||||
values.append("%d %s" % (cur_value, attr))
|
||||
return ' '.join(values)
|
||||
|
||||
|
||||
class StringyInteger(Integer):
|
||||
"""
|
||||
A model type that converts from strings to integers when reading from json.
|
||||
If value does not parse as an int, returns None.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
try:
|
||||
return int(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
class StringyFloat(Float):
|
||||
"""
|
||||
A model type that converts from string to floats when reading from json.
|
||||
If value does not parse as a float, returns None.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
try:
|
||||
return float(value)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
class StringyBoolean(Boolean):
|
||||
"""
|
||||
Reads strings from JSON as booleans.
|
||||
|
||||
If the string is 'true' (case insensitive), then return True,
|
||||
otherwise False.
|
||||
|
||||
JSON values that aren't strings are returned as-is.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
if isinstance(value, basestring):
|
||||
return value.lower() == 'true'
|
||||
return value
|
||||
|
||||
@@ -8,7 +8,6 @@ from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xblock.core import Scope, Integer, String
|
||||
from .fields import Date
|
||||
from xmodule.util.date_utils import time_to_datetime
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -31,9 +30,7 @@ class FolditModule(FolditFields, XModule):
|
||||
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
"""
|
||||
|
||||
Example:
|
||||
<foldit show_basic_score="true"
|
||||
required_level="4"
|
||||
@@ -42,8 +39,8 @@ class FolditModule(FolditFields, XModule):
|
||||
required_sublevel_half_credit="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
|
||||
self.due_time = time_to_datetime(self.due)
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
self.due_time = self.due
|
||||
|
||||
def is_complete(self):
|
||||
"""
|
||||
@@ -102,7 +99,7 @@ class FolditModule(FolditFields, XModule):
|
||||
from foldit.models import Score
|
||||
|
||||
leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
leaders.sort(key=lambda x: -x[1])
|
||||
leaders.sort(key=lambda x:-x[1])
|
||||
|
||||
return leaders
|
||||
|
||||
@@ -186,7 +183,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
|
||||
module_class = FolditModule
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "foldit"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<input class="check" type="button" value="Check">
|
||||
<input class="reset" type="button" value="Reset">
|
||||
<input class="save" type="button" value="Save">
|
||||
<input class="show" type="button" value="Show Answer">
|
||||
<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>
|
||||
<a href="/courseware/6.002_Spring_2012/${ explain }" class="new-page">Explanation</a>
|
||||
<section class="submission_feedback"></section>
|
||||
</section>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<div class="course-content">
|
||||
<div id="video_example" class="video">
|
||||
<div class="tc-wrapper">
|
||||
<article class="video-wrapper">
|
||||
<section class="video-player">
|
||||
<div id="example"></div>
|
||||
</section>
|
||||
<section class="video-controls"></section>
|
||||
</article>
|
||||
<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>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@ describe 'Problem', ->
|
||||
|
||||
# note that the fixturesPath is set in spec/helper.coffee
|
||||
loadFixtures 'problem.html'
|
||||
|
||||
|
||||
spyOn Logger, 'log'
|
||||
spyOn($.fn, 'load').andCallFake (url, callback) ->
|
||||
$(@).html readFixtures('problem_content.html')
|
||||
@@ -27,13 +27,13 @@ describe 'Problem', ->
|
||||
it 'set the element from html', ->
|
||||
@problem999 = new Problem ("
|
||||
<section class='xmodule_display xmodule_CapaModule' data-type='Problem'>
|
||||
<section id='problem_999'
|
||||
class='problems-wrapper'
|
||||
data-problem-id='i4x://edX/999/problem/Quiz'
|
||||
<section id='problem_999'
|
||||
class='problems-wrapper'
|
||||
data-problem-id='i4x://edX/999/problem/Quiz'
|
||||
data-url='/problem/quiz/'>
|
||||
</section>
|
||||
</section>
|
||||
")
|
||||
")
|
||||
expect(@problem999.element_id).toBe 'problem_999'
|
||||
|
||||
it 'set the element from loadFixtures', ->
|
||||
@@ -62,7 +62,7 @@ describe 'Problem', ->
|
||||
expect($('section.action input.reset')).toHandleWith 'click', @problem.reset
|
||||
|
||||
it 'bind the show button', ->
|
||||
expect($('section.action input.show')).toHandleWith 'click', @problem.show
|
||||
expect($('section.action button.show')).toHandleWith 'click', @problem.show
|
||||
|
||||
it 'bind the save button', ->
|
||||
expect($('section.action input.save')).toHandleWith 'click', @problem.save
|
||||
@@ -126,14 +126,14 @@ describe 'Problem', ->
|
||||
|
||||
describe 'when the response is correct', ->
|
||||
it 'call render with returned content', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
callback(success: 'correct', contents: 'Correct!')
|
||||
@problem.check()
|
||||
expect(@problem.el.html()).toEqual 'Correct!'
|
||||
|
||||
describe 'when the response is incorrect', ->
|
||||
it 'call render with returned content', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
|
||||
callback(success: 'incorrect', contents: 'Incorrect!')
|
||||
@problem.check()
|
||||
expect(@problem.el.html()).toEqual 'Incorrect!'
|
||||
@@ -159,7 +159,7 @@ describe 'Problem', ->
|
||||
it 'POST to the problem reset page', ->
|
||||
spyOn $, 'postWithPrefix'
|
||||
@problem.reset()
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset',
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset',
|
||||
{ id: 'i4x://edX/101/problem/Problem1' }, jasmine.any(Function)
|
||||
|
||||
it 'render the returned content', ->
|
||||
@@ -179,7 +179,7 @@ describe 'Problem', ->
|
||||
|
||||
it 'log the problem_show event', ->
|
||||
@problem.show()
|
||||
expect(Logger.log).toHaveBeenCalledWith 'problem_show',
|
||||
expect(Logger.log).toHaveBeenCalledWith 'problem_show',
|
||||
problem: 'i4x://edX/101/problem/Problem1'
|
||||
|
||||
it 'fetch the answers', ->
|
||||
@@ -198,7 +198,7 @@ describe 'Problem', ->
|
||||
it 'toggle the show answer button', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {})
|
||||
@problem.show()
|
||||
expect($('.show')).toHaveValue 'Hide Answer'
|
||||
expect($('.show .show-label')).toHaveText 'Hide Answer(s)'
|
||||
|
||||
it 'add the showed class to element', ->
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {})
|
||||
@@ -223,7 +223,7 @@ describe 'Problem', ->
|
||||
expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true'
|
||||
expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true'
|
||||
|
||||
describe 'when the answers are alreay shown', ->
|
||||
describe 'when the answers are already shown', ->
|
||||
beforeEach ->
|
||||
@problem.el.addClass 'showed'
|
||||
@problem.el.prepend '''
|
||||
@@ -243,7 +243,7 @@ describe 'Problem', ->
|
||||
|
||||
it 'toggle the show answer button', ->
|
||||
@problem.show()
|
||||
expect($('.show')).toHaveValue 'Show Answer'
|
||||
expect($('.show .show-label')).toHaveText 'Show Answer(s)'
|
||||
|
||||
it 'remove the showed class from element', ->
|
||||
@problem.show()
|
||||
@@ -261,7 +261,7 @@ describe 'Problem', ->
|
||||
it 'POST to save problem', ->
|
||||
spyOn $, 'postWithPrefix'
|
||||
@problem.save()
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
|
||||
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
|
||||
'foo=1&bar=2', jasmine.any(Function)
|
||||
|
||||
# TODO: figure out why failing
|
||||
|
||||
@@ -28,7 +28,7 @@ jasmine.stubRequests = ->
|
||||
spyOn($, 'ajax').andCallFake (settings) ->
|
||||
if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/
|
||||
settings.success data: jasmine.stubbedMetadata[match[1]]
|
||||
else if match = settings.url.match /static\/subs\/(.+)\.srt\.sjson/
|
||||
else if match = settings.url.match /static(\/.*)?\/subs\/(.+)\.srt\.sjson/
|
||||
settings.success jasmine.stubbedCaption
|
||||
else if settings.url.match /.+\/problem_get$/
|
||||
settings.success html: readFixtures('problem_content.html')
|
||||
@@ -47,19 +47,15 @@ jasmine.stubYoutubePlayer = ->
|
||||
|
||||
jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
enableParts = [enableParts] unless $.isArray(enableParts)
|
||||
|
||||
suite = context.suite
|
||||
currentPartName = suite.description while suite = suite.parentSuite
|
||||
enableParts.push currentPartName
|
||||
|
||||
for part in ['VideoCaption', 'VideoSpeedControl', 'VideoVolumeControl', 'VideoProgressSlider']
|
||||
unless $.inArray(part, enableParts) >= 0
|
||||
spyOn window, part
|
||||
|
||||
loadFixtures 'video.html'
|
||||
jasmine.stubRequests()
|
||||
YT.Player = undefined
|
||||
context.video = new Video 'example', '.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
videosDefinition = '0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
context.video = new Video '#example', videosDefinition
|
||||
jasmine.stubYoutubePlayer()
|
||||
if createPlayer
|
||||
return new VideoPlayer(video: context.video)
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoCaption', ->
|
||||
describe 'VideoCaption', ->
|
||||
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.subtitles').remove()
|
||||
spyOn(VideoCaption.prototype, 'fetchCaption').andCallThrough()
|
||||
spyOn($, 'ajaxWithPrefix').andCallThrough()
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
|
||||
afterEach ->
|
||||
YT.Player = undefined
|
||||
$.fn.scrollTo.reset()
|
||||
$('.subtitles').remove()
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
spyOn($, 'getWithPrefix').andCallThrough()
|
||||
|
||||
describe 'always', ->
|
||||
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'set the youtube id', ->
|
||||
expect(@caption.youtubeId).toEqual 'def456'
|
||||
expect(@caption.youtubeId).toEqual 'normalSpeedYoutubeId'
|
||||
|
||||
it 'create the caption element', ->
|
||||
expect($('.video')).toContain 'ol.subtitles'
|
||||
@@ -26,7 +28,12 @@ xdescribe 'VideoCaption', ->
|
||||
expect($('.video')).toContain 'a.hide-subtitles'
|
||||
|
||||
it 'fetch the caption', ->
|
||||
expect($.getWithPrefix).toHaveBeenCalledWith @caption.captionURL(), jasmine.any(Function)
|
||||
expect(@caption.loaded).toBeTruthy()
|
||||
expect(@caption.fetchCaption).toHaveBeenCalled()
|
||||
expect($.ajaxWithPrefix).toHaveBeenCalledWith
|
||||
url: @caption.captionURL()
|
||||
notifyOnError: false
|
||||
success: jasmine.any(Function)
|
||||
|
||||
it 'bind window resize event', ->
|
||||
expect($(window)).toHandleWith 'resize', @caption.resize
|
||||
@@ -42,17 +49,17 @@ xdescribe 'VideoCaption', ->
|
||||
expect($('.subtitles')).toHandleWith 'DOMMouseScroll', @caption.onMovement
|
||||
|
||||
describe 'when on a non touch-based device', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn false
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'render the caption', ->
|
||||
expect($('.subtitles').html()).toMatch new RegExp('''
|
||||
<li data-index="0" data-start="0">Caption at 0</li>
|
||||
<li data-index="1" data-start="10000">Caption at 10000</li>
|
||||
<li data-index="2" data-start="20000">Caption at 20000</li>
|
||||
<li data-index="3" data-start="30000">Caption at 30000</li>
|
||||
'''.replace(/\n/g, ''))
|
||||
captionsData = jasmine.stubbedCaption
|
||||
$('.subtitles li[data-index]').each (index, link) =>
|
||||
expect($(link)).toHaveData 'index', index
|
||||
expect($(link)).toHaveData 'start', captionsData.start[index]
|
||||
expect($(link)).toHaveText captionsData.text[index]
|
||||
|
||||
it 'add a padding element to caption', ->
|
||||
expect($('.subtitles li:first')).toBe '.spacing'
|
||||
@@ -66,9 +73,11 @@ xdescribe 'VideoCaption', ->
|
||||
expect(@caption.rendered).toBeTruthy()
|
||||
|
||||
describe 'when on a touch-based device', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'show explaination message', ->
|
||||
expect($('.subtitles li')).toHaveHtml "Caption will be displayed when you start playing the video."
|
||||
@@ -77,12 +86,15 @@ xdescribe 'VideoCaption', ->
|
||||
expect(@caption.rendered).toBeFalsy()
|
||||
|
||||
describe 'mouse movement', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(window, 'setTimeout').andReturn 100
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
window.setTimeout.andReturn(100)
|
||||
spyOn window, 'clearTimeout'
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
|
||||
describe 'when cursor is outside of the caption box', ->
|
||||
|
||||
beforeEach ->
|
||||
$(window).trigger jQuery.Event 'mousemove'
|
||||
|
||||
@@ -90,6 +102,7 @@ xdescribe 'VideoCaption', ->
|
||||
expect(@caption.frozen).toBeFalsy()
|
||||
|
||||
describe 'when cursor is in the caption box', ->
|
||||
|
||||
beforeEach ->
|
||||
$('.subtitles').trigger jQuery.Event 'mouseenter'
|
||||
|
||||
@@ -143,8 +156,10 @@ xdescribe 'VideoCaption', ->
|
||||
expect($.fn.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
describe 'search', ->
|
||||
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
|
||||
it 'return a correct caption index', ->
|
||||
expect(@caption.search(0)).toEqual 0
|
||||
@@ -157,17 +172,17 @@ xdescribe 'VideoCaption', ->
|
||||
describe 'play', ->
|
||||
describe 'when the caption was not rendered', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
@caption.play()
|
||||
|
||||
it 'render the caption', ->
|
||||
expect($('.subtitles').html()).toMatch new RegExp(
|
||||
'''<li data-index="0" data-start="0">Caption at 0</li>''' +
|
||||
'''<li data-index="1" data-start="10000">Caption at 10000</li>''' +
|
||||
'''<li data-index="2" data-start="20000">Caption at 20000</li>''' +
|
||||
'''<li data-index="3" data-start="30000">Caption at 30000</li>'''
|
||||
)
|
||||
captionsData = jasmine.stubbedCaption
|
||||
$('.subtitles li[data-index]').each (index, link) =>
|
||||
expect($(link)).toHaveData 'index', index
|
||||
expect($(link)).toHaveData 'start', captionsData.start[index]
|
||||
expect($(link)).toHaveText captionsData.text[index]
|
||||
|
||||
it 'add a padding element to caption', ->
|
||||
expect($('.subtitles li:first')).toBe '.spacing'
|
||||
@@ -185,7 +200,8 @@ xdescribe 'VideoCaption', ->
|
||||
|
||||
describe 'pause', ->
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
@caption.playing = true
|
||||
@caption.pause()
|
||||
|
||||
@@ -193,8 +209,10 @@ xdescribe 'VideoCaption', ->
|
||||
expect(@caption.playing).toBeFalsy()
|
||||
|
||||
describe 'updatePlayTime', ->
|
||||
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
|
||||
describe 'when the video speed is 1.0x', ->
|
||||
beforeEach ->
|
||||
@@ -240,26 +258,29 @@ xdescribe 'VideoCaption', ->
|
||||
expect($('.subtitles li[data-index=1]')).toHaveClass 'current'
|
||||
|
||||
describe 'resize', ->
|
||||
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
@caption.resize()
|
||||
|
||||
it 'set the height of caption container', ->
|
||||
expect(parseInt($('.subtitles').css('maxHeight'))).toEqual $('.video-wrapper').height()
|
||||
expect(parseInt($('.subtitles').css('maxHeight'))).toBeCloseTo $('.video-wrapper').height(), 2
|
||||
|
||||
it 'set the height of caption spacing', ->
|
||||
expect(parseInt($('.subtitles .spacing:first').css('height'))).toEqual(
|
||||
$('.video-wrapper').height() / 2 - $('.subtitles li:not(.spacing):first').height() / 2)
|
||||
expect(parseInt($('.subtitles .spacing:last').css('height'))).toEqual(
|
||||
$('.video-wrapper').height() / 2 - $('.subtitles li:not(.spacing):last').height() / 2)
|
||||
expect(Math.abs(parseInt($('.subtitles .spacing:first').css('height')) - @caption.topSpacingHeight())).toBeLessThan 1
|
||||
expect(Math.abs(parseInt($('.subtitles .spacing:last').css('height')) - @caption.bottomSpacingHeight())).toBeLessThan 1
|
||||
|
||||
|
||||
it 'scroll caption to new position', ->
|
||||
expect($.fn.scrollTo).toHaveBeenCalled()
|
||||
|
||||
describe 'scrollCaption', ->
|
||||
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
|
||||
describe 'when frozen', ->
|
||||
beforeEach ->
|
||||
@@ -291,15 +312,17 @@ xdescribe 'VideoCaption', ->
|
||||
offset: - ($('.video-wrapper').height() / 2 - $('.subtitles .current:first').height() / 2)
|
||||
|
||||
describe 'seekPlayer', ->
|
||||
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
@time = null
|
||||
$(@caption).bind 'seek', (event, time) => @time = time
|
||||
|
||||
describe 'when the video speed is 1.0x', ->
|
||||
beforeEach ->
|
||||
@caption.currentSpeed = '1.0'
|
||||
$('.subtitles li[data-start="30000"]').click()
|
||||
$('.subtitles li[data-start="30000"]').trigger('click')
|
||||
|
||||
it 'trigger seek event with the correct time', ->
|
||||
expect(@time).toEqual 30.000
|
||||
@@ -307,14 +330,15 @@ xdescribe 'VideoCaption', ->
|
||||
describe 'when the video speed is not 1.0x', ->
|
||||
beforeEach ->
|
||||
@caption.currentSpeed = '0.75'
|
||||
$('.subtitles li[data-start="30000"]').click()
|
||||
$('.subtitles li[data-start="30000"]').trigger('click')
|
||||
|
||||
it 'trigger seek event with the correct time', ->
|
||||
expect(@time).toEqual 40.000
|
||||
|
||||
describe 'toggle', ->
|
||||
beforeEach ->
|
||||
@caption = new VideoCaption el: $('.video'), youtubeId: 'def456', currentSpeed: '1.0'
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@caption = @player.caption
|
||||
$('.subtitles li[data-index=1]').addClass 'current'
|
||||
|
||||
describe 'when the caption is visible', ->
|
||||
@@ -325,7 +349,6 @@ xdescribe 'VideoCaption', ->
|
||||
it 'hide the caption', ->
|
||||
expect(@caption.el).toHaveClass 'closed'
|
||||
|
||||
|
||||
describe 'when the caption is hidden', ->
|
||||
beforeEach ->
|
||||
@caption.el.addClass 'closed'
|
||||
|
||||
@@ -1,53 +1,44 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoControl', ->
|
||||
describe 'VideoControl', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
loadFixtures 'video.html'
|
||||
$('.video-controls').html ''
|
||||
|
||||
describe 'constructor', ->
|
||||
|
||||
it 'render the video controls', ->
|
||||
new VideoControl(el: $('.video-controls'))
|
||||
expect($('.video-controls').html()).toContain '''
|
||||
<div class="slider"></div>
|
||||
<div>
|
||||
<ul class="vcr">
|
||||
<li><a class="video_control play" href="#">Play</a></li>
|
||||
<li>
|
||||
<div class="vidtime">0:00 / 0:00</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="secondary-controls">
|
||||
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
@control = new window.VideoControl(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 VideoControl(el: $('.video-controls'))
|
||||
expect($('.video_control')).toHandleWith 'click', control.togglePlayback
|
||||
@control = new window.VideoControl(el: $('.video-controls'))
|
||||
expect($('.video_control')).toHandleWith 'click', @control.togglePlayback
|
||||
|
||||
describe 'when on a touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
@control = new window.VideoControl(el: $('.video-controls'))
|
||||
|
||||
it 'does not add the play class to video control', ->
|
||||
new VideoControl(el: $('.video-controls'))
|
||||
expect($('.video_control')).not.toHaveClass 'play'
|
||||
expect($('.video_control')).not.toHaveHtml 'Play'
|
||||
|
||||
|
||||
describe 'when on a non-touch based device', ->
|
||||
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn false
|
||||
@control = new window.VideoControl(el: $('.video-controls'))
|
||||
|
||||
it 'add the play class to video control', ->
|
||||
new VideoControl(el: $('.video-controls'))
|
||||
expect($('.video_control')).toHaveClass 'play'
|
||||
expect($('.video_control')).toHaveHtml 'Play'
|
||||
|
||||
describe 'play', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new VideoControl(el: $('.video-controls'))
|
||||
@control = new window.VideoControl(el: $('.video-controls'))
|
||||
@control.play()
|
||||
|
||||
it 'switch playback button to play state', ->
|
||||
@@ -56,8 +47,9 @@ xdescribe 'VideoControl', ->
|
||||
expect($('.video_control')).toHaveHtml 'Pause'
|
||||
|
||||
describe 'pause', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new VideoControl(el: $('.video-controls'))
|
||||
@control = new window.VideoControl(el: $('.video-controls'))
|
||||
@control.pause()
|
||||
|
||||
it 'switch playback button to pause state', ->
|
||||
@@ -66,8 +58,9 @@ xdescribe 'VideoControl', ->
|
||||
expect($('.video_control')).toHaveHtml 'Play'
|
||||
|
||||
describe 'togglePlayback', ->
|
||||
|
||||
beforeEach ->
|
||||
@control = new VideoControl(el: $('.video-controls'))
|
||||
@control = new window.VideoControl(el: $('.video-controls'))
|
||||
|
||||
describe 'when the control does not have play or pause class', ->
|
||||
beforeEach ->
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoPlayer', ->
|
||||
describe 'VideoPlayer', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
# It tries to call methods of VideoProgressSlider on Spy
|
||||
for part in ['VideoCaption', 'VideoSpeedControl', 'VideoVolumeControl', 'VideoProgressSlider', 'VideoControl']
|
||||
spyOn(window[part].prototype, 'initialize').andCallThrough()
|
||||
jasmine.stubVideoPlayer @, [], false
|
||||
|
||||
afterEach ->
|
||||
@@ -8,7 +11,6 @@ xdescribe 'VideoPlayer', ->
|
||||
|
||||
describe 'constructor', ->
|
||||
beforeEach ->
|
||||
spyOn window, 'VideoControl'
|
||||
spyOn YT, 'Player'
|
||||
$.fn.qtip.andCallFake ->
|
||||
$(this).data('qtip', true)
|
||||
@@ -22,32 +24,47 @@ xdescribe 'VideoPlayer', ->
|
||||
expect(@player.currentTime).toEqual 0
|
||||
|
||||
it 'set the element', ->
|
||||
expect(@player.el).toBe '#video_example'
|
||||
expect(@player.el).toHaveId 'video_id'
|
||||
|
||||
it 'create video control', ->
|
||||
expect(window.VideoControl).toHaveBeenCalledWith el: $('.video-controls', @player.el)
|
||||
expect(window.VideoControl.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.control).toBeDefined()
|
||||
expect(@player.control.el).toBe $('.video-controls', @player.el)
|
||||
|
||||
it 'create video caption', ->
|
||||
expect(window.VideoCaption).toHaveBeenCalledWith el: @player.el, youtubeId: 'normalSpeedYoutubeId', currentSpeed: '1.0'
|
||||
expect(window.VideoCaption.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.caption).toBeDefined()
|
||||
expect(@player.caption.el).toBe @player.el
|
||||
expect(@player.caption.youtubeId).toEqual 'normalSpeedYoutubeId'
|
||||
expect(@player.caption.currentSpeed).toEqual '1.0'
|
||||
expect(@player.caption.captionAssetPath).toEqual '/static/subs/'
|
||||
|
||||
it 'create video speed control', ->
|
||||
expect(window.VideoSpeedControl).toHaveBeenCalledWith el: $('.secondary-controls', @player.el), speeds: ['0.75', '1.0'], currentSpeed: '1.0'
|
||||
expect(window.VideoSpeedControl.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.speedControl).toBeDefined()
|
||||
expect(@player.speedControl.el).toBe $('.secondary-controls', @player.el)
|
||||
expect(@player.speedControl.speeds).toEqual ['0.75', '1.0']
|
||||
expect(@player.speedControl.currentSpeed).toEqual '1.0'
|
||||
|
||||
it 'create video progress slider', ->
|
||||
expect(window.VideoProgressSlider).toHaveBeenCalledWith el: $('.slider', @player.el)
|
||||
expect(window.VideoSpeedControl.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.progressSlider).toBeDefined()
|
||||
expect(@player.progressSlider.el).toBe $('.slider', @player.el)
|
||||
|
||||
it 'create Youtube player', ->
|
||||
expect(YT.Player).toHaveBeenCalledWith('example', {
|
||||
expect(YT.Player).toHaveBeenCalledWith('id', {
|
||||
playerVars:
|
||||
controls: 0
|
||||
wmode: 'transparent'
|
||||
rel: 0
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
videoId: 'normalSpeedYoutubeId'
|
||||
events:
|
||||
onReady: @player.onReady
|
||||
onStateChange: @player.onStateChange
|
||||
onPlaybackQualityChange: @player.onPlaybackQualityChange
|
||||
})
|
||||
|
||||
it 'bind to video control play event', ->
|
||||
@@ -69,14 +86,13 @@ xdescribe 'VideoPlayer', ->
|
||||
expect($(@player.volumeControl)).toHandleWith 'volumeChange', @player.onVolumeChange
|
||||
|
||||
it 'bind to key press', ->
|
||||
expect($(document)).toHandleWith 'keyup', @player.bindExitFullScreen
|
||||
expect($(document.documentElement)).toHandleWith 'keyup', @player.bindExitFullScreen
|
||||
|
||||
it 'bind to fullscreen switching button', ->
|
||||
expect($('.add-fullscreen')).toHandleWith 'click', @player.toggleFullScreen
|
||||
|
||||
describe 'when not on a touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn false
|
||||
$('.add-fullscreen, .hide-subtitles').removeData 'qtip'
|
||||
@player = new VideoPlayer video: @video
|
||||
|
||||
@@ -85,11 +101,13 @@ xdescribe 'VideoPlayer', ->
|
||||
expect($('.hide-subtitles')).toHaveData 'qtip'
|
||||
|
||||
it 'create video volume control', ->
|
||||
expect(window.VideoVolumeControl).toHaveBeenCalledWith el: $('.secondary-controls', @player.el)
|
||||
expect(window.VideoVolumeControl.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.volumeControl).toBeDefined()
|
||||
expect(@player.volumeControl.el).toBe $('.secondary-controls', @player.el)
|
||||
|
||||
describe 'when on a touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
$('.add-fullscreen, .hide-subtitles').removeData 'qtip'
|
||||
@player = new VideoPlayer video: @video
|
||||
|
||||
@@ -98,7 +116,8 @@ xdescribe 'VideoPlayer', ->
|
||||
expect($('.hide-subtitles')).not.toHaveData 'qtip'
|
||||
|
||||
it 'does not create video volume control', ->
|
||||
expect(window.VideoVolumeControl).not.toHaveBeenCalled()
|
||||
expect(window.VideoVolumeControl.prototype.initialize).not.toHaveBeenCalled()
|
||||
expect(@player.volumeControl).not.toBeDefined()
|
||||
|
||||
describe 'onReady', ->
|
||||
beforeEach ->
|
||||
@@ -110,7 +129,6 @@ xdescribe 'VideoPlayer', ->
|
||||
|
||||
describe 'when not on a touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn false
|
||||
spyOn @player, 'play'
|
||||
@player.onReady()
|
||||
|
||||
@@ -119,7 +137,7 @@ xdescribe 'VideoPlayer', ->
|
||||
|
||||
describe 'when on a touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
spyOn @player, 'play'
|
||||
@player.onReady()
|
||||
|
||||
@@ -347,9 +365,6 @@ xdescribe 'VideoPlayer', ->
|
||||
it 'replace the full screen button tooltip', ->
|
||||
expect($('.add-fullscreen')).toHaveAttr 'title', 'Exit fill browser'
|
||||
|
||||
it 'add a new exit from fullscreen button', ->
|
||||
expect(@player.el).toContain 'a.exit'
|
||||
|
||||
it 'add the fullscreen class', ->
|
||||
expect(@player.el).toHaveClass 'fullscreen'
|
||||
|
||||
@@ -438,7 +453,7 @@ xdescribe 'VideoPlayer', ->
|
||||
|
||||
describe 'volume', ->
|
||||
beforeEach ->
|
||||
@player = new VideoPlayer @video
|
||||
@player = new VideoPlayer video: @video
|
||||
@player.player.getVolume.andReturn 42
|
||||
|
||||
describe 'without value', ->
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoProgressSlider', ->
|
||||
describe 'VideoProgressSlider', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
|
||||
describe 'constructor', ->
|
||||
describe 'on a non-touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn($.fn, 'slider').andCallThrough()
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn false
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
|
||||
it 'build the slider', ->
|
||||
expect(@slider.slider).toBe '.slider'
|
||||
expect(@progressSlider.slider).toBe '.slider'
|
||||
expect($.fn.slider).toHaveBeenCalledWith
|
||||
range: 'min'
|
||||
change: @slider.onChange
|
||||
slide: @slider.onSlide
|
||||
stop: @slider.onStop
|
||||
change: @progressSlider.onChange
|
||||
slide: @progressSlider.onSlide
|
||||
stop: @progressSlider.onStop
|
||||
|
||||
it 'build the seek handle', ->
|
||||
expect(@slider.handle).toBe '.slider .ui-slider-handle'
|
||||
expect(@progressSlider.handle).toBe '.slider .ui-slider-handle'
|
||||
expect($.fn.qtip).toHaveBeenCalledWith
|
||||
content: "0:00"
|
||||
position:
|
||||
my: 'bottom center'
|
||||
at: 'top center'
|
||||
container: @slider.handle
|
||||
container: @progressSlider.handle
|
||||
hide:
|
||||
delay: 700
|
||||
style:
|
||||
@@ -34,47 +33,51 @@ xdescribe 'VideoProgressSlider', ->
|
||||
|
||||
describe 'on a touch-based device', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
spyOn($.fn, 'slider').andCallThrough()
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
|
||||
it 'does not build the slider', ->
|
||||
expect(@slider.slider).toBeUndefined
|
||||
expect(@progressSlider.slider).toBeUndefined
|
||||
expect($.fn.slider).not.toHaveBeenCalled()
|
||||
|
||||
describe 'play', ->
|
||||
beforeEach ->
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
spyOn($.fn, 'slider').andCallThrough()
|
||||
spyOn(VideoProgressSlider.prototype, 'buildSlider').andCallThrough()
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
|
||||
describe 'when the slider was already built', ->
|
||||
|
||||
beforeEach ->
|
||||
@slider.play()
|
||||
@progressSlider.play()
|
||||
|
||||
it 'does not build the slider', ->
|
||||
expect($.fn.slider).not.toHaveBeenCalled
|
||||
expect(@progressSlider.buildSlider.calls.length).toEqual 1
|
||||
|
||||
describe 'when the slider was not already built', ->
|
||||
beforeEach ->
|
||||
@slider.slider = null
|
||||
@slider.play()
|
||||
spyOn($.fn, 'slider').andCallThrough()
|
||||
@progressSlider.slider = null
|
||||
@progressSlider.play()
|
||||
|
||||
it 'build the slider', ->
|
||||
expect(@slider.slider).toBe '.slider'
|
||||
expect(@progressSlider.slider).toBe '.slider'
|
||||
expect($.fn.slider).toHaveBeenCalledWith
|
||||
range: 'min'
|
||||
change: @slider.onChange
|
||||
slide: @slider.onSlide
|
||||
stop: @slider.onStop
|
||||
change: @progressSlider.onChange
|
||||
slide: @progressSlider.onSlide
|
||||
stop: @progressSlider.onStop
|
||||
|
||||
it 'build the seek handle', ->
|
||||
expect(@slider.handle).toBe '.ui-slider-handle'
|
||||
expect(@progressSlider.handle).toBe '.ui-slider-handle'
|
||||
expect($.fn.qtip).toHaveBeenCalledWith
|
||||
content: "0:00"
|
||||
position:
|
||||
my: 'bottom center'
|
||||
at: 'top center'
|
||||
container: @slider.handle
|
||||
container: @progressSlider.handle
|
||||
hide:
|
||||
delay: 700
|
||||
style:
|
||||
@@ -83,21 +86,23 @@ xdescribe 'VideoProgressSlider', ->
|
||||
|
||||
describe 'updatePlayTime', ->
|
||||
beforeEach ->
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
spyOn($.fn, 'slider').andCallThrough()
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
|
||||
describe 'when frozen', ->
|
||||
beforeEach ->
|
||||
@slider.frozen = true
|
||||
@slider.updatePlayTime 20, 120
|
||||
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 ->
|
||||
@slider.frozen = false
|
||||
@slider.updatePlayTime 20, 120
|
||||
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
|
||||
@@ -107,55 +112,58 @@ xdescribe 'VideoProgressSlider', ->
|
||||
|
||||
describe 'onSlide', ->
|
||||
beforeEach ->
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
@time = null
|
||||
$(@slider).bind 'seek', (event, time) => @time = time
|
||||
spyOnEvent @slider, 'seek'
|
||||
@slider.onSlide {}, value: 20
|
||||
$(@progressSlider).bind 'seek', (event, time) => @time = time
|
||||
spyOnEvent @progressSlider, 'seek'
|
||||
@progressSlider.onSlide {}, value: 20
|
||||
|
||||
it 'freeze the slider', ->
|
||||
expect(@slider.frozen).toBeTruthy()
|
||||
expect(@progressSlider.frozen).toBeTruthy()
|
||||
|
||||
it 'update the tooltip', ->
|
||||
expect($.fn.qtip).toHaveBeenCalled()
|
||||
|
||||
it 'trigger seek event', ->
|
||||
expect('seek').toHaveBeenTriggeredOn @slider
|
||||
expect('seek').toHaveBeenTriggeredOn @progressSlider
|
||||
expect(@time).toEqual 20
|
||||
|
||||
describe 'onChange', ->
|
||||
beforeEach ->
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
@slider.onChange {}, value: 20
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
@progressSlider.onChange {}, value: 20
|
||||
|
||||
it 'update the tooltip', ->
|
||||
expect($.fn.qtip).toHaveBeenCalled()
|
||||
|
||||
describe 'onStop', ->
|
||||
beforeEach ->
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
@time = null
|
||||
$(@slider).bind 'seek', (event, time) => @time = time
|
||||
spyOnEvent @slider, 'seek'
|
||||
spyOn(window, 'setTimeout')
|
||||
@slider.onStop {}, value: 20
|
||||
$(@progressSlider).bind 'seek', (event, time) => @time = time
|
||||
spyOnEvent @progressSlider, 'seek'
|
||||
@progressSlider.onStop {}, value: 20
|
||||
|
||||
it 'freeze the slider', ->
|
||||
expect(@slider.frozen).toBeTruthy()
|
||||
expect(@progressSlider.frozen).toBeTruthy()
|
||||
|
||||
it 'trigger seek event', ->
|
||||
expect('seek').toHaveBeenTriggeredOn @slider
|
||||
expect('seek').toHaveBeenTriggeredOn @progressSlider
|
||||
expect(@time).toEqual 20
|
||||
|
||||
it 'set timeout to unfreeze the slider', ->
|
||||
expect(window.setTimeout).toHaveBeenCalledWith jasmine.any(Function), 200
|
||||
window.setTimeout.mostRecentCall.args[0]()
|
||||
expect(@slider.frozen).toBeFalsy()
|
||||
expect(@progressSlider.frozen).toBeFalsy()
|
||||
|
||||
describe 'updateTooltip', ->
|
||||
beforeEach ->
|
||||
@slider = new VideoProgressSlider el: $('.slider')
|
||||
@slider.updateTooltip 90
|
||||
@player = jasmine.stubVideoPlayer @
|
||||
@progressSlider = @player.progressSlider
|
||||
@progressSlider.updateTooltip 90
|
||||
|
||||
it 'set the tooltip value', ->
|
||||
expect($.fn.qtip).toHaveBeenCalledWith 'option', 'content.text', '1:30'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoSpeedControl', ->
|
||||
describe 'VideoSpeedControl', ->
|
||||
beforeEach ->
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn false
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.speeds').remove()
|
||||
|
||||
@@ -10,22 +10,23 @@ xdescribe 'VideoSpeedControl', ->
|
||||
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
|
||||
|
||||
it 'add the video speed control to player', ->
|
||||
expect($('.secondary-controls').html()).toContain '''
|
||||
<div class="speeds">
|
||||
<a href="#">
|
||||
<h3>Speed</h3>
|
||||
<p class="active">1.0x</p>
|
||||
</a>
|
||||
<ol class="video_speeds"><li data-speed="1.0" class="active"><a href="#">1.0x</a></li><li data-speed="0.75"><a href="#">0.75x</a></li></ol>
|
||||
</div>
|
||||
'''
|
||||
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 ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn true
|
||||
window.onTouchBasedDevice.andReturn true
|
||||
$('.speeds').removeClass 'open'
|
||||
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
|
||||
|
||||
@@ -37,7 +38,6 @@ xdescribe 'VideoSpeedControl', ->
|
||||
|
||||
describe 'when running on non-touch based device', ->
|
||||
beforeEach ->
|
||||
spyOn(window, 'onTouchBasedDevice').andReturn false
|
||||
$('.speeds').removeClass 'open'
|
||||
@speedControl = new VideoSpeedControl el: $('.secondary-controls'), speeds: @video.speeds, currentSpeed: '1.0'
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'VideoVolumeControl', ->
|
||||
describe 'VideoVolumeControl', ->
|
||||
beforeEach ->
|
||||
jasmine.stubVideoPlayer @
|
||||
$('.volume').remove()
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# TODO: figure out why failing
|
||||
xdescribe 'Video', ->
|
||||
describe 'Video', ->
|
||||
metadata = undefined
|
||||
|
||||
beforeEach ->
|
||||
loadFixtures 'video.html'
|
||||
jasmine.stubRequests()
|
||||
|
||||
@videosDefinition = '.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
@videosDefinition = '0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
@slowerSpeedYoutubeId = 'slowerSpeedYoutubeId'
|
||||
@normalSpeedYoutubeId = 'normalSpeedYoutubeId'
|
||||
metadata =
|
||||
slowerSpeedYoutubeId:
|
||||
id: @slowerSpeedYoutubeId
|
||||
duration: 300
|
||||
normalSpeedYoutubeId:
|
||||
id: @normalSpeedYoutubeId
|
||||
duration: 200
|
||||
|
||||
afterEach ->
|
||||
window.player = undefined
|
||||
@@ -16,17 +24,18 @@ xdescribe 'Video', ->
|
||||
beforeEach ->
|
||||
@stubVideoPlayer = jasmine.createSpy('VideoPlayer')
|
||||
$.cookie.andReturn '0.75'
|
||||
window.player = 100
|
||||
window.player = undefined
|
||||
|
||||
describe 'by default', ->
|
||||
beforeEach ->
|
||||
@video = new Video 'example', @videosDefinition
|
||||
|
||||
spyOn(window.Video.prototype, 'fetchMetadata').andCallFake ->
|
||||
@metadata = metadata
|
||||
@video = new Video '#example', @videosDefinition
|
||||
it 'reset the current video player', ->
|
||||
expect(window.player).toBeNull()
|
||||
|
||||
it 'set the elements', ->
|
||||
expect(@video.el).toBe '#video_example'
|
||||
expect(@video.el).toBe '#video_id'
|
||||
|
||||
it 'parse the videos', ->
|
||||
expect(@video.videos).toEqual
|
||||
@@ -34,13 +43,8 @@ xdescribe 'Video', ->
|
||||
'1.0': @normalSpeedYoutubeId
|
||||
|
||||
it 'fetch the video metadata', ->
|
||||
expect(@video.metadata).toEqual
|
||||
slowerSpeedYoutubeId:
|
||||
id: @slowerSpeedYoutubeId
|
||||
duration: 300
|
||||
normalSpeedYoutubeId:
|
||||
id: @normalSpeedYoutubeId
|
||||
duration: 200
|
||||
expect(@video.fetchMetadata).toHaveBeenCalled
|
||||
expect(@video.metadata).toEqual metadata
|
||||
|
||||
it 'parse available video speeds', ->
|
||||
expect(@video.speeds).toEqual ['0.75', '1.0']
|
||||
@@ -56,7 +60,7 @@ xdescribe 'Video', ->
|
||||
@originalYT = window.YT
|
||||
window.YT = { Player: true }
|
||||
spyOn(window, 'VideoPlayer').andReturn(@stubVideoPlayer)
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
|
||||
afterEach ->
|
||||
window.YT = @originalYT
|
||||
@@ -69,7 +73,7 @@ xdescribe 'Video', ->
|
||||
beforeEach ->
|
||||
@originalYT = window.YT
|
||||
window.YT = {}
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
|
||||
afterEach ->
|
||||
window.YT = @originalYT
|
||||
@@ -82,7 +86,7 @@ xdescribe 'Video', ->
|
||||
@originalYT = window.YT
|
||||
window.YT = {}
|
||||
spyOn(window, 'VideoPlayer').andReturn(@stubVideoPlayer)
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
window.onYouTubePlayerAPIReady()
|
||||
|
||||
afterEach ->
|
||||
@@ -95,7 +99,7 @@ xdescribe 'Video', ->
|
||||
describe 'youtubeId', ->
|
||||
beforeEach ->
|
||||
$.cookie.andReturn '1.0'
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
|
||||
describe 'with speed', ->
|
||||
it 'return the video id for given speed', ->
|
||||
@@ -108,7 +112,7 @@ xdescribe 'Video', ->
|
||||
|
||||
describe 'setSpeed', ->
|
||||
beforeEach ->
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
|
||||
describe 'when new speed is available', ->
|
||||
beforeEach ->
|
||||
@@ -129,14 +133,14 @@ xdescribe 'Video', ->
|
||||
|
||||
describe 'getDuration', ->
|
||||
beforeEach ->
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
|
||||
it 'return duration for current video', ->
|
||||
expect(@video.getDuration()).toEqual 200
|
||||
|
||||
describe 'log', ->
|
||||
beforeEach ->
|
||||
@video = new Video 'example', @videosDefinition
|
||||
@video = new Video '#example', @videosDefinition
|
||||
@video.setSpeed '1.0'
|
||||
spyOn Logger, 'log'
|
||||
@video.player = { currentTime: 25 }
|
||||
@@ -144,7 +148,7 @@ xdescribe 'Video', ->
|
||||
|
||||
it 'call the logger with valid parameters', ->
|
||||
expect(Logger.log).toHaveBeenCalledWith 'someEvent',
|
||||
id: 'example'
|
||||
id: 'id'
|
||||
code: @normalSpeedYoutubeId
|
||||
currentTime: 25
|
||||
speed: '1.0'
|
||||
|
||||
@@ -19,12 +19,12 @@ class @Problem
|
||||
|
||||
problem_prefix = @element_id.replace(/problem_/,'')
|
||||
@inputs = @$("[id^=input_#{problem_prefix}_]")
|
||||
|
||||
|
||||
@$('section.action input:button').click @refreshAnswers
|
||||
@$('section.action input.check').click @check_fd
|
||||
#@$('section.action input.check').click @check
|
||||
@$('section.action input.reset').click @reset
|
||||
@$('section.action input.show').click @show
|
||||
@$('section.action button.show').click @show
|
||||
@$('section.action input.save').click @save
|
||||
|
||||
# Collapsibles
|
||||
@@ -44,7 +44,7 @@ class @Problem
|
||||
forceUpdate: (response) =>
|
||||
@el.attr progress: response.progress_status
|
||||
@el.trigger('progressChanged')
|
||||
|
||||
|
||||
|
||||
queueing: =>
|
||||
@queued_items = @$(".xqueue")
|
||||
@@ -59,11 +59,11 @@ class @Problem
|
||||
get_queuelen: =>
|
||||
minlen = Infinity
|
||||
@queued_items.each (index, qitem) ->
|
||||
len = parseInt($.text(qitem))
|
||||
len = parseInt($.text(qitem))
|
||||
if len < minlen
|
||||
minlen = len
|
||||
return minlen
|
||||
|
||||
|
||||
poll: =>
|
||||
$.postWithPrefix "#{@url}/problem_get", (response) =>
|
||||
# If queueing status changed, then render
|
||||
@@ -73,9 +73,9 @@ class @Problem
|
||||
JavascriptLoader.executeModuleScripts @el, () =>
|
||||
@setupInputTypes()
|
||||
@bind()
|
||||
|
||||
|
||||
@num_queued_items = @new_queued_items.length
|
||||
if @num_queued_items == 0
|
||||
if @num_queued_items == 0
|
||||
@forceUpdate response
|
||||
delete window.queuePollerID
|
||||
else
|
||||
@@ -83,12 +83,12 @@ class @Problem
|
||||
window.queuePollerID = window.setTimeout(@poll, 1000)
|
||||
|
||||
|
||||
# Use this if you want to make an ajax call on the input type object
|
||||
# Use this if you want to make an ajax call on the input type object
|
||||
# static method so you don't have to instantiate a Problem in order to use it
|
||||
# Input:
|
||||
# url: the AJAX url of the problem
|
||||
# url: the AJAX url of the problem
|
||||
# input_id: the input_id of the input you would like to make the call on
|
||||
# NOTE: the id is the ${id} part of "input_${id}" during rendering
|
||||
# NOTE: the id is the ${id} part of "input_${id}" during rendering
|
||||
# If this function is passed the entire prefixed id, the backend may have trouble
|
||||
# finding the correct input
|
||||
# dispatch: string that indicates how this data should be handled by the inputtype
|
||||
@@ -98,7 +98,7 @@ class @Problem
|
||||
data['dispatch'] = dispatch
|
||||
data['input_id'] = input_id
|
||||
$.postWithPrefix "#{url}/input_ajax", data, callback
|
||||
|
||||
|
||||
|
||||
render: (content) ->
|
||||
if content
|
||||
@@ -141,7 +141,7 @@ class @Problem
|
||||
Logger.log 'problem_check', @answers
|
||||
|
||||
# If there are no file inputs in the problem, we can fall back on @check
|
||||
if $('input:file').length == 0
|
||||
if $('input:file').length == 0
|
||||
@check()
|
||||
return
|
||||
|
||||
@@ -150,7 +150,7 @@ class @Problem
|
||||
return
|
||||
|
||||
fd = new FormData()
|
||||
|
||||
|
||||
# Sanity checks on submission
|
||||
max_filesize = 4*1000*1000 # 4 MB
|
||||
file_too_large = false
|
||||
@@ -195,19 +195,19 @@ class @Problem
|
||||
|
||||
abort_submission = file_too_large or file_not_selected or unallowed_file_submitted or required_files_not_submitted
|
||||
|
||||
settings =
|
||||
settings =
|
||||
type: "POST"
|
||||
data: fd
|
||||
processData: false
|
||||
contentType: false
|
||||
success: (response) =>
|
||||
success: (response) =>
|
||||
switch response.success
|
||||
when 'incorrect', 'correct'
|
||||
@render(response.contents)
|
||||
@updateProgress response
|
||||
else
|
||||
@gentle_alert response.success
|
||||
|
||||
|
||||
if not abort_submission
|
||||
$.ajaxWithPrefix("#{@url}/problem_check", settings)
|
||||
|
||||
@@ -260,14 +260,14 @@ class @Problem
|
||||
@el.find('.problem > div').each (index, element) =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
|
||||
|
||||
@$('.show').val 'Hide Answer'
|
||||
@$('.show-label').text 'Hide Answer(s)'
|
||||
@el.addClass 'showed'
|
||||
@updateProgress response
|
||||
else
|
||||
@$('[id^=answer_], [id^=solution_]').text ''
|
||||
@$('[correct_answer]').attr correct_answer: null
|
||||
@el.removeClass 'showed'
|
||||
@$('.show').val 'Show Answer'
|
||||
@$('.show-label').text 'Show Answer(s)'
|
||||
|
||||
@el.find(".capa_inputtype").each (index, inputtype) =>
|
||||
display = @inputtypeDisplays[$(inputtype).attr('id')]
|
||||
@@ -306,7 +306,7 @@ class @Problem
|
||||
MathJax.Hub.Queue(['Text', jax, eqn], [@updateMathML, jax, element])
|
||||
|
||||
return # Explicit return for CoffeeScript
|
||||
|
||||
|
||||
updateMathML: (jax, element) =>
|
||||
try
|
||||
$("##{element.id}_dynamath").val(jax.root.toMathML '')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user