Merge peter-fogg/fix-video-captions-setting.
This commit is contained in:
3
.gitignore
vendored
3
.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
|
||||
@@ -27,6 +29,7 @@ conf/locale/en/LC_MESSAGES/*.po
|
||||
!messages.po
|
||||
lms/static/sass/*.css
|
||||
lms/static/sass/application.scss
|
||||
lms/static/sass/course.scss
|
||||
cms/static/sass/*.css
|
||||
lms/lib/comment_client/python
|
||||
nosetests.xml
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,8 +69,8 @@ def i_see_complete_section_name_with_quote_in_editor(step):
|
||||
|
||||
@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)
|
||||
css = 'h3[data-name="My Section"]'
|
||||
assert world.is_css_not_present(css)
|
||||
|
||||
|
||||
@step('I see a release date for my section$')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
@@ -486,6 +526,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# check for custom_tags
|
||||
self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template')
|
||||
|
||||
# check for about content
|
||||
self.verify_content_existence(module_store, root_dir, location, 'about', 'about', '.html')
|
||||
|
||||
# check for graiding_policy.json
|
||||
filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012')
|
||||
self.assertTrue(filesystem.exists('grading_policy.json'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -95,13 +95,15 @@ SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can
|
||||
# happen with some browsers (e.g. Firefox)
|
||||
if ENV_TOKENS.get('SESSION_COOKIE_NAME', None):
|
||||
SESSION_COOKIE_NAME = ENV_TOKENS.get('SESSION_COOKIE_NAME')
|
||||
# NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str()
|
||||
SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME'))
|
||||
|
||||
#Email overrides
|
||||
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)
|
||||
|
||||
@@ -335,3 +335,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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,9 @@ def marketing_link(name):
|
||||
return settings.MKTG_URLS.get('ROOT') + settings.MKTG_URLS.get(name)
|
||||
# only link to the old pages when the marketing site isn't on
|
||||
elif not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE') and name in link_map:
|
||||
return reverse(link_map[name])
|
||||
# don't try to reverse disabled marketing links
|
||||
if link_map[name] is not None:
|
||||
return reverse(link_map[name])
|
||||
else:
|
||||
log.warning("Cannot find corresponding link for name: {name}".format(name=name))
|
||||
return '#'
|
||||
|
||||
@@ -4,13 +4,15 @@ from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from mitxmako.shortcuts import marketing_link
|
||||
from mock import patch
|
||||
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
class ShortcutsTests(TestCase):
|
||||
"""
|
||||
Test the mitxmako shortcuts file
|
||||
"""
|
||||
|
||||
# TODO: fix this test. It is causing intermittent test failures on
|
||||
# subsequent tests due to the way urls are loaded
|
||||
raise SkipTest()
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'})
|
||||
@override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'})
|
||||
def test_marketing_link(self):
|
||||
|
||||
@@ -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']
|
||||
@@ -662,6 +662,7 @@ 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -264,7 +264,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 = {}
|
||||
@@ -355,7 +354,7 @@ 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'):
|
||||
@@ -363,9 +362,9 @@ def change_enrollment(request):
|
||||
|
||||
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 +381,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 +453,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 +513,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.
|
||||
@@ -698,7 +696,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 +705,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 +845,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 +867,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 +910,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:
|
||||
@@ -1194,6 +1198,10 @@ def accept_name_change(request):
|
||||
def _get_news(top=None):
|
||||
"Return the n top news items on settings.RSS_URL"
|
||||
|
||||
# Don't return anything if we're in a themed site
|
||||
if settings.MITX_FEATURES["USE_CUSTOM_THEME"]:
|
||||
return None
|
||||
|
||||
feed_data = cache.get("students_index_rss_feed_data")
|
||||
if feed_data is None:
|
||||
if hasattr(settings, 'RSS_URL'):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -69,7 +69,7 @@ class CapaFields(object):
|
||||
max_attempts = StringyInteger(
|
||||
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)
|
||||
|
||||
@@ -555,6 +555,15 @@ section.problem {
|
||||
@extend .blue-button;
|
||||
}
|
||||
|
||||
button.show {
|
||||
height: ($baseline*2);
|
||||
|
||||
span {
|
||||
font-size: 1.0em;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.submission_feedback {
|
||||
// background: #F3F3F3;
|
||||
// border: 1px solid #ddd;
|
||||
@@ -811,13 +820,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 +887,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
|
||||
|
||||
@@ -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 '')
|
||||
|
||||
@@ -98,8 +98,10 @@ define('ElOutput', ['logme'], function (logme) {
|
||||
);
|
||||
logme('Error message: "' + err.message + '".');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
paramNames.pop();
|
||||
|
||||
|
||||
@@ -87,8 +87,10 @@ define('GLabelElOutput', ['logme'], function (logme) {
|
||||
);
|
||||
logme('Error message: "' + err.message + '".');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
paramNames.pop();
|
||||
|
||||
|
||||
@@ -242,8 +242,10 @@ define('Graph', ['logme'], function (logme) {
|
||||
);
|
||||
logme('Error message: "' + err.message + '"');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from the string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from the string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
paramNames.pop();
|
||||
|
||||
@@ -709,15 +711,17 @@ define('Graph', ['logme'], function (logme) {
|
||||
);
|
||||
logme('Error message: "' + err.message + '"');
|
||||
|
||||
$('#' + gstId).html(
|
||||
'<div style="color: red;">' + 'ERROR IN ' +
|
||||
'XML: Could not create a function from the string "' +
|
||||
funcString + '" for xrange.min.' + '</div>'
|
||||
);
|
||||
$('#' + gstId).append(
|
||||
'<div style="color: red;">' + 'Error ' +
|
||||
'message: "' + err.message + '".' + '</div>'
|
||||
);
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html(
|
||||
'<div style="color: red;">' + 'ERROR IN ' +
|
||||
'XML: Could not create a function from the string "' +
|
||||
funcString + '" for xrange.min.' + '</div>'
|
||||
);
|
||||
$('#' + gstId).append(
|
||||
'<div style="color: red;">' + 'Error ' +
|
||||
'message: "' + err.message + '".' + '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -790,15 +794,17 @@ define('Graph', ['logme'], function (logme) {
|
||||
);
|
||||
logme('Error message: "' + err.message + '"');
|
||||
|
||||
$('#' + gstId).html(
|
||||
'<div style="color: red;">' + 'ERROR IN ' +
|
||||
'XML: Could not create a function from the string "' +
|
||||
funcString + '" for xrange.max.' + '</div>'
|
||||
);
|
||||
$('#' + gstId).append(
|
||||
'<div style="color: red;">' + 'Error message: "' +
|
||||
err.message + '".' + '</div>'
|
||||
);
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html(
|
||||
'<div style="color: red;">' + 'ERROR IN ' +
|
||||
'XML: Could not create a function from the string "' +
|
||||
funcString + '" for xrange.max.' + '</div>'
|
||||
);
|
||||
$('#' + gstId).append(
|
||||
'<div style="color: red;">' + 'Error message: "' +
|
||||
err.message + '".' + '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1006,8 +1012,10 @@ define('Graph', ['logme'], function (logme) {
|
||||
);
|
||||
logme('Error message: "' + err.message + '"');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from the string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from the string "' + funcString + '".' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
paramNames.pop();
|
||||
paramNames.pop();
|
||||
@@ -1133,8 +1141,10 @@ define('Graph', ['logme'], function (logme) {
|
||||
logme('ERROR: Could not determine xrange start.');
|
||||
logme('Error message: "' + err.message + '".');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not determine xrange start from defined function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not determine xrange start from defined function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1144,8 +1154,10 @@ define('Graph', ['logme'], function (logme) {
|
||||
logme('ERROR: Could not determine xrange end.');
|
||||
logme('Error message: "' + err.message + '".');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not determine xrange end from defined function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not determine xrange end from defined function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1175,8 +1187,10 @@ define('Graph', ['logme'], function (logme) {
|
||||
logme('ERROR: Could not generate data.');
|
||||
logme('Error message: "' + err.message + '".');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not generate data from defined function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not generate data from defined function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1204,8 +1218,10 @@ define('Graph', ['logme'], function (logme) {
|
||||
logme('ERROR: Could not generate data.');
|
||||
logme('Error message: "' + err.message + '".');
|
||||
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not generate data from function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
if (state.showDebugInfo) {
|
||||
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not generate data from function.' + '</div>');
|
||||
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ define(
|
||||
// state object.
|
||||
state = State(gstId, config);
|
||||
|
||||
state.showDebugInfo = false;
|
||||
|
||||
// It is possible that something goes wrong while extracting parameters
|
||||
// from the JSON config object. In this case, we will not continue.
|
||||
if (state === undefined) {
|
||||
|
||||
@@ -5,7 +5,7 @@ class @Video
|
||||
@start = @el.data('start')
|
||||
@end = @el.data('end')
|
||||
@caption_asset_path = @el.data('caption-asset-path')
|
||||
@show_captions = @el.data('show-captions') == "true"
|
||||
@show_captions = @el.data('show-captions')
|
||||
window.player = null
|
||||
@el = $("#video_#{@id}")
|
||||
@parseVideos()
|
||||
@@ -13,7 +13,7 @@ class @Video
|
||||
@parseSpeed()
|
||||
$("#video_#{@id}").data('video', this).addClass('video-load-complete')
|
||||
|
||||
@hide_captions = $.cookie('hide_captions') == 'true'
|
||||
@hide_captions = $.cookie('hide_captions') == 'true' or (not @show_captions)
|
||||
|
||||
if YT.Player
|
||||
@embed()
|
||||
|
||||
@@ -37,7 +37,7 @@ class @VideoCaption extends Subview
|
||||
@loaded = true
|
||||
|
||||
if onTouchBasedDevice()
|
||||
$('.subtitles li').html "Caption will be displayed when you start playing the video."
|
||||
$('.subtitles').html "<li>Caption will be displayed when you start playing the video.</li>"
|
||||
else
|
||||
@renderCaption()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class @VideoPlayer extends Subview
|
||||
$(@progressSlider).bind('seek', @onSeek)
|
||||
if @volumeControl
|
||||
$(@volumeControl).bind('volumeChange', @onVolumeChange)
|
||||
$(document).keyup @bindExitFullScreen
|
||||
$(document.documentElement).keyup @bindExitFullScreen
|
||||
|
||||
@$('.add-fullscreen').click @toggleFullScreen
|
||||
@addToolTip() unless onTouchBasedDevice()
|
||||
|
||||
@@ -11,7 +11,7 @@ class @VideoProgressSlider extends Subview
|
||||
@buildHandle()
|
||||
|
||||
buildHandle: ->
|
||||
@handle = @$('.slider .ui-slider-handle')
|
||||
@handle = @$('.ui-slider-handle')
|
||||
@handle.qtip
|
||||
content: "#{Time.format(@slider.slider('value'))}"
|
||||
position:
|
||||
|
||||
@@ -91,12 +91,17 @@ class @VideoAlpha
|
||||
getDuration: ->
|
||||
@metadata[@youtubeId()].duration
|
||||
|
||||
log: (eventName)->
|
||||
log: (eventName, data)->
|
||||
# Default parameters that always get logged.
|
||||
logInfo =
|
||||
id: @id
|
||||
code: @youtubeId()
|
||||
currentTime: @player.currentTime
|
||||
speed: @speed
|
||||
|
||||
# If extra parameters were passed to the log.
|
||||
if data
|
||||
$.each data, (paramName, value) ->
|
||||
logInfo[paramName] = value
|
||||
|
||||
if @videoType is "youtube"
|
||||
logInfo.code = @youtubeId()
|
||||
else logInfo.code = "html5" if @videoType is "html5"
|
||||
|
||||
@@ -120,7 +120,7 @@ class @VideoCaptionAlpha extends SubviewAlpha
|
||||
seekPlayer: (event) =>
|
||||
event.preventDefault()
|
||||
time = Math.round(Time.convert($(event.target).data('start'), '1.0', @currentSpeed) / 1000)
|
||||
$(@).trigger('seek', time)
|
||||
$(@).trigger('caption_seek', time)
|
||||
|
||||
calculateOffset: (element) ->
|
||||
@captionHeight() / 2 - element.height() / 2
|
||||
|
||||
@@ -24,9 +24,9 @@ class @VideoPlayerAlpha extends SubviewAlpha
|
||||
if @video.videoType is 'youtube'
|
||||
$(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange)
|
||||
if @video.show_captions is true
|
||||
$(@caption).bind('seek', @onSeek)
|
||||
$(@caption).bind('caption_seek', @onSeek)
|
||||
$(@speedControl).bind('speedChange', @onSpeedChange)
|
||||
$(@progressSlider).bind('seek', @onSeek)
|
||||
$(@progressSlider).bind('slide_seek', @onSeek)
|
||||
if @volumeControl
|
||||
$(@volumeControl).bind('volumeChange', @onVolumeChange)
|
||||
$(document).keyup @bindExitFullScreen
|
||||
@@ -96,6 +96,7 @@ class @VideoPlayerAlpha extends SubviewAlpha
|
||||
at: 'top center'
|
||||
|
||||
onReady: (event) =>
|
||||
@video.log 'load_video'
|
||||
if @video.videoType is 'html5'
|
||||
@player.setPlaybackRate @video.speed
|
||||
unless onTouchBasedDevice()
|
||||
@@ -184,7 +185,8 @@ class @VideoPlayerAlpha extends SubviewAlpha
|
||||
@caption.pause()
|
||||
|
||||
onPlay: =>
|
||||
@video.log 'play_video'
|
||||
@video.log 'play_video',
|
||||
currentTime: @currentTime
|
||||
unless @player.interval
|
||||
@player.interval = setInterval(@update, 200)
|
||||
if @video.show_captions is true
|
||||
@@ -193,7 +195,8 @@ class @VideoPlayerAlpha extends SubviewAlpha
|
||||
@progressSlider.play()
|
||||
|
||||
onPause: =>
|
||||
@video.log 'pause_video'
|
||||
@video.log 'pause_video',
|
||||
currentTime: @currentTime
|
||||
clearInterval(@player.interval)
|
||||
@player.interval = null
|
||||
if @video.show_captions is true
|
||||
@@ -206,6 +209,10 @@ class @VideoPlayerAlpha extends SubviewAlpha
|
||||
@caption.pause()
|
||||
|
||||
onSeek: (event, time) =>
|
||||
@video.log 'seek_video',
|
||||
old_time: @currentTime
|
||||
new_time: time
|
||||
type: event.type
|
||||
@player.seekTo(time, true)
|
||||
if @isPlaying()
|
||||
clearInterval(@player.interval)
|
||||
@@ -218,6 +225,12 @@ class @VideoPlayerAlpha extends SubviewAlpha
|
||||
if @video.videoType is 'youtube'
|
||||
@currentTime = Time.convert(@currentTime, parseFloat(@currentSpeed()), newSpeed)
|
||||
newSpeed = parseFloat(newSpeed).toFixed(2).replace /\.00$/, '.0'
|
||||
|
||||
@video.log 'speed_change_video',
|
||||
currentTime: @currentTime
|
||||
old_speed: @currentSpeed()
|
||||
new_speed: newSpeed
|
||||
|
||||
@video.setSpeed newSpeed, updateCookie
|
||||
if @video.videoType is 'youtube'
|
||||
if @video.show_captions is true
|
||||
|
||||
@@ -6,6 +6,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha
|
||||
@slider = @el.slider
|
||||
range: 'min'
|
||||
change: @onChange
|
||||
|
||||
slide: @onSlide
|
||||
stop: @onStop
|
||||
@buildHandle()
|
||||
@@ -35,7 +36,7 @@ class @VideoProgressSliderAlpha extends SubviewAlpha
|
||||
onSlide: (event, ui) =>
|
||||
@frozen = true
|
||||
@updateTooltip(ui.value)
|
||||
$(@).trigger('seek', ui.value)
|
||||
$(@).trigger('slide_seek', ui.value)
|
||||
|
||||
onChange: (event, ui) =>
|
||||
@updateTooltip(ui.value)
|
||||
|
||||
@@ -3,8 +3,11 @@ from datetime import datetime
|
||||
from . import ModuleStoreBase, Location, namedtuple_to_son
|
||||
from .exceptions import ItemNotFoundError
|
||||
from .inheritance import own_metadata
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
|
||||
DRAFT = 'draft'
|
||||
# Things w/ these categories should never be marked as version='draft'
|
||||
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
def as_draft(location):
|
||||
@@ -111,6 +114,8 @@ class DraftModuleStore(ModuleStoreBase):
|
||||
Clone a new item that is a copy of the item at the location `source`
|
||||
and writes it to `location`
|
||||
"""
|
||||
if Location(location).category in DIRECT_ONLY_CATEGORIES:
|
||||
raise InvalidVersionError(location)
|
||||
return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location)))
|
||||
|
||||
def update_item(self, location, data, allow_not_found=False):
|
||||
@@ -203,6 +208,8 @@ class DraftModuleStore(ModuleStoreBase):
|
||||
"""
|
||||
Turn the published version into a draft, removing the published version
|
||||
"""
|
||||
if Location(location).category in DIRECT_ONLY_CATEGORIES:
|
||||
raise InvalidVersionError(location)
|
||||
super(DraftModuleStore, self).clone_item(location, as_draft(location))
|
||||
super(DraftModuleStore, self).delete_item(location)
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from xmodule.modulestore import Location
|
||||
import os.path
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
|
||||
from nose.tools import assert_raises
|
||||
|
||||
from .test_modulestore import check_path_to_location
|
||||
from . import DATA_DIR
|
||||
@@ -15,3 +18,22 @@ class TestXMLModuleStore(object):
|
||||
print "finished import"
|
||||
|
||||
check_path_to_location(modulestore)
|
||||
|
||||
def test_unicode_chars_in_xml_content(self):
|
||||
# edX/full/6.002_Spring_2012 has non-ASCII chars, and during
|
||||
# uniquification of names, would raise a UnicodeError. It no longer does.
|
||||
|
||||
# Ensure that there really is a non-ASCII character in the course.
|
||||
with open(os.path.join(DATA_DIR, "full/sequential/Administrivia_and_Circuit_Elements.xml")) as xmlf:
|
||||
xml = xmlf.read()
|
||||
with assert_raises(UnicodeDecodeError):
|
||||
xml.decode('ascii')
|
||||
|
||||
# Load the course, but don't make error modules. This will succeed,
|
||||
# but will record the errors.
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['full'], load_error_modules=False)
|
||||
|
||||
# Look up the errors during load. There should be none.
|
||||
location = CourseDescriptor.id_to_location("edX/full/6.002_Spring_2012")
|
||||
errors = modulestore.get_item_errors(location)
|
||||
assert errors == []
|
||||
|
||||
@@ -108,7 +108,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
|
||||
orig_name = orig_name[len(tag) + 1:-12]
|
||||
# append the hash of the content--the first 12 bytes should be plenty.
|
||||
orig_name = "_" + orig_name if orig_name not in (None, "") else ""
|
||||
return tag + orig_name + "_" + hashlib.sha1(xml).hexdigest()[:12]
|
||||
xml_bytes = xml.encode('utf8')
|
||||
return tag + orig_name + "_" + hashlib.sha1(xml_bytes).hexdigest()[:12]
|
||||
|
||||
# Fallback if there was nothing we could use:
|
||||
if url_name is None or url_name == "":
|
||||
@@ -322,7 +323,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
'''
|
||||
String representation - for debugging
|
||||
'''
|
||||
return '<XMLModuleStore>data_dir=%s, %d courses, %d modules' % (
|
||||
return '<XMLModuleStore data_dir=%r, %d courses, %d modules>' % (
|
||||
self.data_dir, len(self.courses), len(self.modules))
|
||||
|
||||
def load_policy(self, policy_path, tracker):
|
||||
|
||||
@@ -28,6 +28,9 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
|
||||
# export the 'about' data (e.g. overview, etc.)
|
||||
export_extra_content(export_fs, modulestore, course_location, 'about', 'about', '.html')
|
||||
|
||||
# export the grading policy
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
|
||||
|
||||
@@ -58,7 +58,12 @@ def _ensure_dir(dir_):
|
||||
|
||||
|
||||
def _write_styles(selector, output_root, classes):
|
||||
_ensure_dir(output_root)
|
||||
"""
|
||||
Write the css fragments from all XModules in `classes`
|
||||
into `output_root` as individual files, hashed by the contents to remove
|
||||
duplicates
|
||||
"""
|
||||
contents = {}
|
||||
|
||||
css_fragments = defaultdict(set)
|
||||
for class_ in classes:
|
||||
@@ -73,25 +78,34 @@ def _write_styles(selector, output_root, classes):
|
||||
hash=hashlib.md5(fragment).hexdigest(),
|
||||
type=filetype)
|
||||
# Prepend _ so that sass just includes the files into a single file
|
||||
with open(output_root / '_' + fragment_name, 'w') as css_file:
|
||||
css_file.write(fragment)
|
||||
filename = '_' + fragment_name
|
||||
contents[filename] = fragment
|
||||
|
||||
for class_ in classes:
|
||||
css_imports[class_].add(fragment_name)
|
||||
|
||||
with open(output_root / '_module-styles.scss', 'w') as module_styles:
|
||||
module_styles_lines = []
|
||||
module_styles_lines.append("@import 'bourbon/bourbon';")
|
||||
module_styles_lines.append("@import 'bourbon/addons/button';")
|
||||
for class_, fragment_names in css_imports.items():
|
||||
module_styles_lines.append("""{selector}.xmodule_{class_} {{""".format(
|
||||
class_=class_, selector=selector
|
||||
))
|
||||
module_styles_lines.extend(' @import "{0}";'.format(name) for name in fragment_names)
|
||||
module_styles_lines.append('}')
|
||||
|
||||
module_styles.write("@import 'bourbon/bourbon';\n")
|
||||
module_styles.write("@import 'bourbon/addons/button';\n")
|
||||
for class_, fragment_names in css_imports.items():
|
||||
imports = "\n".join('@import "{0}";'.format(name) for name in fragment_names)
|
||||
module_styles.write("""{selector}.xmodule_{class_} {{ {imports} }}\n""".format(
|
||||
class_=class_, imports=imports, selector=selector
|
||||
))
|
||||
contents['_module-styles.scss'] = '\n'.join(module_styles_lines)
|
||||
|
||||
_write_files(output_root, contents)
|
||||
|
||||
|
||||
def _write_js(output_root, classes):
|
||||
_ensure_dir(output_root)
|
||||
"""
|
||||
Write the javascript fragments from all XModules in `classes`
|
||||
into `output_root` as individual files, hashed by the contents to remove
|
||||
duplicates
|
||||
"""
|
||||
contents = {}
|
||||
|
||||
js_fragments = set()
|
||||
for class_ in classes:
|
||||
@@ -100,18 +114,25 @@ def _write_js(output_root, classes):
|
||||
for idx, fragment in enumerate(module_js.get(filetype, [])):
|
||||
js_fragments.add((idx, filetype, fragment))
|
||||
|
||||
module_js = []
|
||||
for idx, filetype, fragment in sorted(js_fragments):
|
||||
path = output_root / "{idx:0=3d}-{hash}.{type}".format(
|
||||
filename = "{idx:0=3d}-{hash}.{type}".format(
|
||||
idx=idx,
|
||||
hash=hashlib.md5(fragment).hexdigest(),
|
||||
type=filetype)
|
||||
with open(path, 'w') as js_file:
|
||||
js_file.write(fragment)
|
||||
contents[filename] = fragment
|
||||
|
||||
module_js.append(path)
|
||||
_write_files(output_root, contents)
|
||||
|
||||
return module_js
|
||||
return [output_root / filename for filename in contents.keys()]
|
||||
|
||||
|
||||
def _write_files(output_root, contents):
|
||||
_ensure_dir(output_root)
|
||||
for extra_file in set(output_root.files()) - set(contents.keys()):
|
||||
extra_file.remove()
|
||||
|
||||
for filename, file_content in contents.iteritems():
|
||||
(output_root / filename).write_bytes(file_content)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -122,7 +143,6 @@ def main():
|
||||
args = docopt(main.__doc__)
|
||||
root = path(args['<output_root>'])
|
||||
|
||||
root.rmtree(ignore_errors=True)
|
||||
write_descriptor_js(root / 'descriptors/js')
|
||||
write_descriptor_styles(root / 'descriptors/css')
|
||||
write_module_js(root / 'modules/js')
|
||||
|
||||
@@ -33,8 +33,8 @@ def test_system():
|
||||
"""
|
||||
Construct a test ModuleSystem instance.
|
||||
|
||||
By default, the render_template() method simply returns the context it is
|
||||
passed as a string. You can override this behavior by monkey patching::
|
||||
By default, the render_template() method simply returns the repr of the
|
||||
context it is passed. You can override this behavior by monkey patching::
|
||||
|
||||
system = test_system()
|
||||
system.render_template = my_render_func
|
||||
@@ -46,7 +46,7 @@ def test_system():
|
||||
ajax_url='courses/course_id/modx/a_location',
|
||||
track_function=Mock(),
|
||||
get_module=Mock(),
|
||||
render_template=lambda template, context: str(context),
|
||||
render_template=lambda template, context: repr(context),
|
||||
replace_urls=lambda html: str(html),
|
||||
user=Mock(is_staff=False),
|
||||
filestore=Mock(),
|
||||
|
||||
@@ -18,8 +18,7 @@ class TestErrorModule(unittest.TestCase):
|
||||
self.org = "org"
|
||||
self.course = "course"
|
||||
self.location = Location(['i4x', self.org, self.course, None, None])
|
||||
self.valid_xml = "<problem />"
|
||||
self.broken_xml = "<problem>"
|
||||
self.valid_xml = u"<problem>ABC \N{SNOWMAN}</problem>"
|
||||
self.error_msg = "Error"
|
||||
|
||||
def test_error_module_xml_rendering(self):
|
||||
@@ -27,9 +26,9 @@ class TestErrorModule(unittest.TestCase):
|
||||
self.valid_xml, self.system, self.org, self.course, self.error_msg)
|
||||
self.assertTrue(isinstance(descriptor, error_module.ErrorDescriptor))
|
||||
module = descriptor.xmodule(self.system)
|
||||
rendered_html = module.get_html()
|
||||
self.assertIn(self.error_msg, rendered_html)
|
||||
self.assertIn(self.valid_xml, rendered_html)
|
||||
context_repr = module.get_html()
|
||||
self.assertIn(self.error_msg, context_repr)
|
||||
self.assertIn(repr(self.valid_xml), context_repr)
|
||||
|
||||
def test_error_module_from_descriptor(self):
|
||||
descriptor = MagicMock([XModuleDescriptor],
|
||||
@@ -41,9 +40,9 @@ class TestErrorModule(unittest.TestCase):
|
||||
descriptor, self.error_msg)
|
||||
self.assertTrue(isinstance(error_descriptor, error_module.ErrorDescriptor))
|
||||
module = error_descriptor.xmodule(self.system)
|
||||
rendered_html = module.get_html()
|
||||
self.assertIn(self.error_msg, rendered_html)
|
||||
self.assertIn(str(descriptor), rendered_html)
|
||||
context_repr = module.get_html()
|
||||
self.assertIn(self.error_msg, context_repr)
|
||||
self.assertIn(repr(descriptor), context_repr)
|
||||
|
||||
|
||||
class TestNonStaffErrorModule(TestErrorModule):
|
||||
@@ -60,9 +59,9 @@ class TestNonStaffErrorModule(TestErrorModule):
|
||||
descriptor = error_module.NonStaffErrorDescriptor.from_xml(
|
||||
self.valid_xml, self.system, self.org, self.course)
|
||||
module = descriptor.xmodule(self.system)
|
||||
rendered_html = module.get_html()
|
||||
self.assertNotIn(self.error_msg, rendered_html)
|
||||
self.assertNotIn(self.valid_xml, rendered_html)
|
||||
context_repr = module.get_html()
|
||||
self.assertNotIn(self.error_msg, context_repr)
|
||||
self.assertNotIn(repr(self.valid_xml), context_repr)
|
||||
|
||||
def test_error_module_from_descriptor(self):
|
||||
descriptor = MagicMock([XModuleDescriptor],
|
||||
@@ -74,6 +73,6 @@ class TestNonStaffErrorModule(TestErrorModule):
|
||||
descriptor, self.error_msg)
|
||||
self.assertTrue(isinstance(error_descriptor, error_module.ErrorDescriptor))
|
||||
module = error_descriptor.xmodule(self.system)
|
||||
rendered_html = module.get_html()
|
||||
self.assertNotIn(self.error_msg, rendered_html)
|
||||
self.assertNotIn(str(descriptor), rendered_html)
|
||||
context_repr = module.get_html()
|
||||
self.assertNotIn(self.error_msg, context_repr)
|
||||
self.assertNotIn(str(descriptor), context_repr)
|
||||
|
||||
@@ -41,7 +41,7 @@ class DummySystem(ImportSystem):
|
||||
)
|
||||
|
||||
def render_template(self, template, context):
|
||||
raise Exception("Shouldn't be called")
|
||||
raise Exception("Shouldn't be called")
|
||||
|
||||
|
||||
class BaseCourseTestCase(unittest.TestCase):
|
||||
@@ -66,13 +66,13 @@ class ImportTestCase(BaseCourseTestCase):
|
||||
def test_fallback(self):
|
||||
'''Check that malformed xml loads as an ErrorDescriptor.'''
|
||||
|
||||
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
|
||||
# Use an exotic character to also flush out Unicode issues.
|
||||
bad_xml = u'''<sequential display_name="oops\N{SNOWMAN}"><video url="hi"></sequential>'''
|
||||
system = self.get_system()
|
||||
|
||||
descriptor = system.process_xml(bad_xml)
|
||||
|
||||
self.assertEqual(descriptor.__class__.__name__,
|
||||
'ErrorDescriptor')
|
||||
self.assertEqual(descriptor.__class__.__name__, 'ErrorDescriptor')
|
||||
|
||||
def test_unique_url_names(self):
|
||||
'''Check that each error gets its very own url_name'''
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# pylint: disable=W0223
|
||||
"""Video is ungraded Xmodule for support video content."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
@@ -15,6 +18,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoFields(object):
|
||||
"""Fields for `VideoModule` and `VideoDescriptor`."""
|
||||
position = Integer(help="Current position in the video", scope=Scope.user_state, default=0)
|
||||
show_captions = Boolean(help="Whether or not captions are shown", display_name="Show Captions", scope=Scope.settings, default=True)
|
||||
youtube_id_1_0 = String(help="Youtube ID for normal speed video", display_name="Normal Speed", scope=Scope.settings, default="OEoXaMPEzfM")
|
||||
@@ -28,16 +32,20 @@ class VideoFields(object):
|
||||
|
||||
|
||||
class VideoModule(VideoFields, XModule):
|
||||
"""Video Xmodule."""
|
||||
video_time = 0
|
||||
icon_class = 'video'
|
||||
|
||||
js = {'coffee':
|
||||
[resource_string(__name__, 'js/src/time.coffee'),
|
||||
resource_string(__name__, 'js/src/video/display.coffee')] +
|
||||
js = {
|
||||
'coffee': [
|
||||
resource_string(__name__, 'js/src/time.coffee'),
|
||||
resource_string(__name__, 'js/src/video/display.coffee')
|
||||
] +
|
||||
[resource_string(__name__, 'js/src/video/display/' + filename)
|
||||
for filename
|
||||
in sorted(resource_listdir(__name__, 'js/src/video/display'))
|
||||
if filename.endswith('.coffee')]}
|
||||
if filename.endswith('.coffee')]
|
||||
}
|
||||
css = {'scss': [resource_string(__name__, 'css/video/display.scss')]}
|
||||
js_module_name = "Video"
|
||||
|
||||
@@ -45,31 +53,13 @@ class VideoModule(VideoFields, XModule):
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
Handle ajax calls to this video.
|
||||
TODO (vshnayder): This is not being called right now, so the position
|
||||
is not being saved.
|
||||
'''
|
||||
"""This is not being called right now and we raise 404 error."""
|
||||
log.debug(u"GET {0}".format(get))
|
||||
log.debug(u"DISPATCH {0}".format(dispatch))
|
||||
if dispatch == 'goto_position':
|
||||
self.position = int(float(get['position']))
|
||||
log.info(u"NEW POSITION {0}".format(self.position))
|
||||
return json.dumps({'success': True})
|
||||
raise Http404()
|
||||
|
||||
def get_progress(self):
|
||||
''' TODO (vshnayder): Get and save duration of youtube video, then return
|
||||
fraction watched.
|
||||
(Be careful to notice when video link changes and update)
|
||||
|
||||
For now, we have no way of knowing if the video has even been watched, so
|
||||
just return None.
|
||||
'''
|
||||
return None
|
||||
|
||||
def get_instance_state(self):
|
||||
#log.debug(u"STATE POSITION {0}".format(self.position))
|
||||
"""Return information about state (position)."""
|
||||
return json.dumps({'position': self.position})
|
||||
|
||||
def get_html(self):
|
||||
|
||||
@@ -141,21 +141,36 @@ Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you i
|
||||
|
||||
### Running Javascript Unit Tests
|
||||
|
||||
These commands start a development server with jasmine testing enabled, and launch your default browser
|
||||
pointing to those tests
|
||||
To run all of the javascript unit tests, use
|
||||
|
||||
rake browse_jasmine_{lms,cms}
|
||||
rake jasmine
|
||||
|
||||
To run the tests headless, you must install [phantomjs](http://phantomjs.org/download.html), then run:
|
||||
If the `phantomjs` binary is on the path, or the `PHANTOMJS_PATH` environment variable is
|
||||
set to point to it, then the tests will be run headless. Otherwise, they will be run in
|
||||
your default browser
|
||||
|
||||
rake phantomjs_jasmine_{lms,cms}
|
||||
export PATH=/path/to/phantomjs:$PATH
|
||||
rake jasmine # Runs headless
|
||||
|
||||
If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environment variable to point to it
|
||||
or
|
||||
|
||||
PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms}
|
||||
PHANTOMJS_PATH=/path/to/phantomjs rake jasmine # Runs headless
|
||||
|
||||
Once you have run the `rake` command, your browser should open to
|
||||
to `http://localhost/_jasmine/`, which displays the test results.
|
||||
or
|
||||
|
||||
rake jasmine # Runs in browser
|
||||
|
||||
You can also force a run using phantomjs or the browser using the commands
|
||||
|
||||
rake jasmine:browser # Runs in browser
|
||||
rake jasmine:phantomjs # Runs headless
|
||||
|
||||
You can run tests for a specific subsystems as well
|
||||
|
||||
rake jasmine:lms # Runs all lms javascript unit tests using the default method
|
||||
rake jasmine:cms:browser # Runs all cms javascript unit tests in the browser
|
||||
|
||||
Use `rake -T` to get a list of all available subsystems
|
||||
|
||||
**Troubleshooting**: If you get an error message while running the `rake` task,
|
||||
try running `bundle install` to install the required ruby gems.
|
||||
@@ -202,9 +217,10 @@ To view test coverage:
|
||||
|
||||
2. Generate reports:
|
||||
|
||||
rake coverage:html
|
||||
rake coverage
|
||||
|
||||
3. HTML reports are located in the `reports` folder.
|
||||
3. Reports are located in the `reports` folder. The command
|
||||
generates HTML and XML (Cobertura format) reports.
|
||||
|
||||
|
||||
## Testing using queue servers
|
||||
|
||||
@@ -70,23 +70,12 @@ rake clobber
|
||||
rake pep8 > pep8.log || cat pep8.log
|
||||
rake pylint > pylint.log || cat pylint.log
|
||||
|
||||
TESTS_FAILED=0
|
||||
# Run the unit tests (use phantomjs for javascript unit tests)
|
||||
rake test
|
||||
|
||||
# Run the python unit tests
|
||||
rake test_cms || TESTS_FAILED=1
|
||||
rake test_lms || TESTS_FAILED=1
|
||||
rake test_common/lib/capa || TESTS_FAILED=1
|
||||
rake test_common/lib/xmodule || TESTS_FAILED=1
|
||||
# Generate coverage reports
|
||||
rake coverage
|
||||
|
||||
# Run the javascript unit tests
|
||||
rake phantomjs_jasmine_lms || TESTS_FAILED=1
|
||||
rake phantomjs_jasmine_cms || TESTS_FAILED=1
|
||||
rake phantomjs_jasmine_common/lib/xmodule || TESTS_FAILED=1
|
||||
rake phantomjs_jasmine_common/static/coffee || TESTS_FAILED=1
|
||||
|
||||
rake coverage:xml coverage:html
|
||||
|
||||
[ $TESTS_FAILED == '0' ]
|
||||
rake autodeploy_properties
|
||||
|
||||
github_status state:success "passed"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
integration tests for xmodule
|
||||
|
||||
Contains:
|
||||
|
||||
1. BaseTestXmodule class provides course and users
|
||||
for testing Xmodules with mongo store.
|
||||
"""
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.client import Client
|
||||
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from xmodule.tests import test_system
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class BaseTestXmodule(ModuleStoreTestCase):
|
||||
"""Base class for testing Xmodules with mongo store.
|
||||
|
||||
This class prepares course and users for tests:
|
||||
1. create test course
|
||||
2. create, enrol and login users for this course
|
||||
|
||||
Any xmodule should overwrite only next parameters for test:
|
||||
1. TEMPLATE_NAME
|
||||
2. DATA
|
||||
3. MODEL_DATA
|
||||
4. COURSE_DATA and USER_COUNT if needed
|
||||
|
||||
This class should not contain any tests, because TEMPLATE_NAME
|
||||
should be defined in child class.
|
||||
"""
|
||||
USER_COUNT = 2
|
||||
COURSE_DATA = {}
|
||||
|
||||
# Data from YAML common/lib/xmodule/xmodule/templates/NAME/default.yaml
|
||||
TEMPLATE_NAME = ""
|
||||
DATA = ''
|
||||
MODEL_DATA = {'data': '<some_module></some_module>'}
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.course = CourseFactory.create(data=self.COURSE_DATA)
|
||||
|
||||
# Turn off cache.
|
||||
modulestore().request_cache = None
|
||||
modulestore().metadata_inheritance_cache_subsystem = None
|
||||
|
||||
chapter = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
template="i4x://edx/templates/sequential/Empty",
|
||||
)
|
||||
section = ItemFactory.create(
|
||||
parent_location=chapter.location,
|
||||
template="i4x://edx/templates/sequential/Empty"
|
||||
)
|
||||
|
||||
# username = robot{0}, password = 'test'
|
||||
self.users = [
|
||||
UserFactory.create(username='robot%d' % i, email='robot+test+%d@edx.org' % i)
|
||||
for i in range(self.USER_COUNT)
|
||||
]
|
||||
|
||||
for user in self.users:
|
||||
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
|
||||
|
||||
self.item_descriptor = ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
template=self.TEMPLATE_NAME,
|
||||
data=self.DATA
|
||||
)
|
||||
|
||||
location = self.item_descriptor.location
|
||||
system = test_system()
|
||||
system.render_template = lambda template, context: context
|
||||
|
||||
self.item_module = self.item_descriptor.module_class(
|
||||
system, location, self.item_descriptor, self.MODEL_DATA
|
||||
)
|
||||
self.item_url = Location(location).url()
|
||||
|
||||
# login all users for acces to Xmodule
|
||||
self.clients = {user.username: Client() for user in self.users}
|
||||
self.login_statuses = [
|
||||
self.clients[user.username].login(
|
||||
username=user.username, password='test')
|
||||
for user in self.users
|
||||
]
|
||||
|
||||
self.assertTrue(all(self.login_statuses))
|
||||
|
||||
def get_url(self, dispatch):
|
||||
"""Return item url with dispatch."""
|
||||
return reverse(
|
||||
'modx_dispatch',
|
||||
args=(self.course.id, self.item_url, dispatch)
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
for user in self.users:
|
||||
user.delete()
|
||||
|
||||
@@ -64,7 +64,7 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
|
||||
def test_staff_debug_for_staff(self):
|
||||
resp = self.get_cw_section()
|
||||
sdebug = '<div><a href="#i4x_edX_graded_problem_H1P1_debug" id="i4x_edX_graded_problem_H1P1_trig">Staff Debug Info</a></div>'
|
||||
|
||||
|
||||
self.assertTrue(sdebug in resp.content)
|
||||
|
||||
|
||||
@@ -84,9 +84,9 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
|
||||
|
||||
resp = self.get_cw_section()
|
||||
sdebug = '<div><a href="#i4x_edX_graded_problem_H1P1_debug" id="i4x_edX_graded_problem_H1P1_trig">Staff Debug Info</a></div>'
|
||||
|
||||
|
||||
self.assertFalse(sdebug in resp.content)
|
||||
|
||||
|
||||
def get_problem(self):
|
||||
pun = 'H1P1'
|
||||
problem_location = "i4x://edX/graded/problem/%s" % pun
|
||||
@@ -105,7 +105,7 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
|
||||
resp = self.get_problem()
|
||||
html = json.loads(resp.content)['html']
|
||||
print html
|
||||
sabut = '<input class="show" type="button" value="Show Answer">'
|
||||
sabut = '<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>'
|
||||
self.assertTrue(sabut in html)
|
||||
|
||||
def test_no_showanswer_for_student(self):
|
||||
@@ -116,5 +116,5 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase):
|
||||
resp = self.get_problem()
|
||||
html = json.loads(resp.content)['html']
|
||||
print html
|
||||
sabut = '<input class="show" type="button" value="Show Answer">'
|
||||
sabut = '<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>'
|
||||
self.assertFalse(sabut in html)
|
||||
|
||||
27
lms/djangoapps/courseware/tests/test_video_mongo.py
Normal file
27
lms/djangoapps/courseware/tests/test_video_mongo.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Video xmodule tests in mongo."""
|
||||
|
||||
from . import BaseTestXmodule
|
||||
|
||||
|
||||
class TestVideo(BaseTestXmodule):
|
||||
"""Integration tests: web client + mongo."""
|
||||
|
||||
TEMPLATE_NAME = "i4x://edx/templates/video/default"
|
||||
DATA = '<video youtube="0.75:JMD_ifUUfsU,1.0:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY"/>'
|
||||
|
||||
def test_handle_ajax_dispatch(self):
|
||||
responses = {
|
||||
user.username: self.clients[user.username].post(
|
||||
self.get_url('whatever'),
|
||||
{},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
for user in self.users
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
set([
|
||||
response.status_code
|
||||
for _, response in responses.items()
|
||||
]).pop(),
|
||||
404)
|
||||
135
lms/djangoapps/courseware/tests/test_video_xml.py
Normal file
135
lms/djangoapps/courseware/tests/test_video_xml.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test for Video Xmodule functional logic.
|
||||
These tests data readed from xml, not from mongo.
|
||||
|
||||
We have a ModuleStoreTestCase class defined in
|
||||
common/lib/xmodule/xmodule/modulestore/tests/django_utils.py.
|
||||
You can search for usages of this in the cms and lms tests for examples.
|
||||
You use this so that it will do things like point the modulestore
|
||||
setting to mongo, flush the contentstore before and after, load the
|
||||
templates, etc.
|
||||
You can then use the CourseFactory and XModuleItemFactory as defined in
|
||||
common/lib/xmodule/xmodule/modulestore/tests/factories.py to create the
|
||||
course, section, subsection, unit, etc.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from mock import Mock
|
||||
from lxml import etree
|
||||
|
||||
from xmodule.video_module import VideoDescriptor, VideoModule
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.tests import test_system
|
||||
from xmodule.tests.test_logic import LogicTest
|
||||
|
||||
|
||||
class VideoFactory(object):
|
||||
"""A helper class to create video modules with various parameters
|
||||
for testing.
|
||||
"""
|
||||
|
||||
# tag that uses youtube videos
|
||||
sample_problem_xml_youtube = """
|
||||
<video show_captions="true"
|
||||
youtube="0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg"
|
||||
data_dir=""
|
||||
caption_asset_path=""
|
||||
autoplay="true"
|
||||
from="01:00:03" to="01:00:10"
|
||||
>
|
||||
<source src=".../mit-3091x/M-3091X-FA12-L21-3_100.mp4"/>
|
||||
</video>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create():
|
||||
"""Method return Video Xmodule instance."""
|
||||
location = Location(["i4x", "edX", "video", "default",
|
||||
"SampleProblem1"])
|
||||
model_data = {'data': VideoFactory.sample_problem_xml_youtube}
|
||||
|
||||
descriptor = Mock(weight="1")
|
||||
|
||||
system = test_system()
|
||||
system.render_template = lambda template, context: context
|
||||
module = VideoModule(system, location, descriptor, model_data)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
class VideoModuleLogicTest(LogicTest):
|
||||
"""Tests for logic of Video Xmodule."""
|
||||
|
||||
descriptor_class = VideoDescriptor
|
||||
|
||||
raw_model_data = {
|
||||
'data': '<video />'
|
||||
}
|
||||
|
||||
def test_get_timeframe_no_parameters(self):
|
||||
"""Make sure that timeframe() works correctly w/o parameters"""
|
||||
xmltree = etree.fromstring('<video>test</video>')
|
||||
output = self.xmodule.get_timeframe(xmltree)
|
||||
self.assertEqual(output, ('', ''))
|
||||
|
||||
def test_get_timeframe_with_one_parameter(self):
|
||||
"""Make sure that timeframe() works correctly with one parameter"""
|
||||
xmltree = etree.fromstring(
|
||||
'<video from="00:04:07">test</video>'
|
||||
)
|
||||
output = self.xmodule.get_timeframe(xmltree)
|
||||
self.assertEqual(output, (247, ''))
|
||||
|
||||
def test_get_timeframe_with_two_parameters(self):
|
||||
"""Make sure that timeframe() works correctly with two parameters"""
|
||||
xmltree = etree.fromstring(
|
||||
'''<video
|
||||
from="00:04:07"
|
||||
to="13:04:39"
|
||||
>test</video>'''
|
||||
)
|
||||
output = self.xmodule.get_timeframe(xmltree)
|
||||
self.assertEqual(output, (247, 47079))
|
||||
|
||||
|
||||
class VideoModuleUnitTest(unittest.TestCase):
|
||||
"""Unit tests for Video Xmodule."""
|
||||
|
||||
def test_video_constructor(self):
|
||||
"""Make sure that all parameters extracted correclty from xml"""
|
||||
module = VideoFactory.create()
|
||||
|
||||
# `get_html` return only context, cause we
|
||||
# overwrite `system.render_template`
|
||||
context = module.get_html()
|
||||
expected_context = {
|
||||
'track': None,
|
||||
'show_captions': 'true',
|
||||
'display_name': 'SampleProblem1',
|
||||
'id': module.location.html_id(),
|
||||
'end': 3610.0,
|
||||
'caption_asset_path': '/static/subs/',
|
||||
'source': '.../mit-3091x/M-3091X-FA12-L21-3_100.mp4',
|
||||
'streams': '0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg',
|
||||
'normal_speed_video_id': 'ZwkTiUPN0mg',
|
||||
'position': 0,
|
||||
'start': 3603.0
|
||||
}
|
||||
self.assertDictEqual(context, expected_context)
|
||||
|
||||
self.assertEqual(
|
||||
module.youtube,
|
||||
'0.75:jNCf2gIqpeE,1.0:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg')
|
||||
|
||||
self.assertEqual(
|
||||
module.video_list(),
|
||||
module.youtube)
|
||||
|
||||
self.assertEqual(
|
||||
module.position,
|
||||
0)
|
||||
|
||||
self.assertDictEqual(
|
||||
json.loads(module.get_instance_state()),
|
||||
{'position': 0})
|
||||
@@ -620,7 +620,7 @@ def upload(request, course_id): # ajax upload file to a question or answer
|
||||
raise exceptions.PermissionDenied(msg)
|
||||
|
||||
except exceptions.PermissionDenied, err:
|
||||
error = unicode(e)
|
||||
error = unicode(err)
|
||||
except Exception, err:
|
||||
print err
|
||||
logging.critical(unicode(err))
|
||||
|
||||
178
lms/djangoapps/instructor/tests/test_enrollment.py
Normal file
178
lms/djangoapps/instructor/tests/test_enrollment.py
Normal file
@@ -0,0 +1,178 @@
|
||||
'''
|
||||
Unit tests for enrollment methods in views.py
|
||||
|
||||
'''
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.urlresolvers import reverse
|
||||
from courseware.access import _course_staff_group_name
|
||||
from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user
|
||||
from xmodule.modulestore.django import modulestore
|
||||
import xmodule.modulestore.django
|
||||
from student.models import CourseEnrollment, CourseEnrollmentAllowed
|
||||
from instructor.views import get_and_clean_student_list
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestInstructorEnrollsStudent(LoginEnrollmentTestCase):
|
||||
'''
|
||||
Check Enrollment/Unenrollment with/without auto-enrollment on activation
|
||||
'''
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.full = modulestore().get_course("edX/full/6.002_Spring_2012")
|
||||
self.toy = modulestore().get_course("edX/toy/2012_Fall")
|
||||
|
||||
#Create instructor and student accounts
|
||||
self.instructor = 'instructor1@test.com'
|
||||
self.student1 = 'student1@test.com'
|
||||
self.student2 = 'student2@test.com'
|
||||
self.password = 'foo'
|
||||
self.create_account('it1', self.instructor, self.password)
|
||||
self.create_account('st1', self.student1, self.password)
|
||||
self.create_account('st2', self.student2, self.password)
|
||||
self.activate_user(self.instructor)
|
||||
self.activate_user(self.student1)
|
||||
self.activate_user(self.student2)
|
||||
|
||||
def make_instructor(course):
|
||||
group_name = _course_staff_group_name(course.location)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(get_user(self.instructor))
|
||||
|
||||
make_instructor(self.toy)
|
||||
|
||||
#Enroll Students
|
||||
self.logout()
|
||||
self.login(self.student1, self.password)
|
||||
self.enroll(self.toy)
|
||||
|
||||
self.logout()
|
||||
self.login(self.student2, self.password)
|
||||
self.enroll(self.toy)
|
||||
|
||||
#Enroll Instructor
|
||||
self.logout()
|
||||
self.login(self.instructor, self.password)
|
||||
self.enroll(self.toy)
|
||||
|
||||
def test_unenrollment(self):
|
||||
'''
|
||||
Do un-enrollment test
|
||||
'''
|
||||
|
||||
course = self.toy
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
|
||||
response = self.client.post(url, {'action': 'Unenroll multiple students', 'multiple_students': 'student1@test.com, student2@test.com'})
|
||||
|
||||
#Check the page output
|
||||
self.assertContains(response, '<td>student1@test.com</td>')
|
||||
self.assertContains(response, '<td>student2@test.com</td>')
|
||||
self.assertContains(response, '<td>un-enrolled</td>')
|
||||
|
||||
#Check the enrollment table
|
||||
user = User.objects.get(email='student1@test.com')
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
|
||||
self.assertEqual(0, len(ce))
|
||||
|
||||
user = User.objects.get(email='student2@test.com')
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
|
||||
self.assertEqual(0, len(ce))
|
||||
|
||||
def test_enrollment_new_student_autoenroll_on(self):
|
||||
'''
|
||||
Do auto-enroll on test
|
||||
'''
|
||||
|
||||
#Run the Enroll students command
|
||||
course = self.toy
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
|
||||
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test1_1@student.com, test1_2@student.com', 'auto_enroll': 'on'})
|
||||
|
||||
#Check the page output
|
||||
self.assertContains(response, '<td>test1_1@student.com</td>')
|
||||
self.assertContains(response, '<td>test1_2@student.com</td>')
|
||||
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment on</td>')
|
||||
|
||||
#Check the enrollmentallowed db entries
|
||||
cea = CourseEnrollmentAllowed.objects.filter(email='test1_1@student.com', course_id=course.id)
|
||||
self.assertEqual(1, cea[0].auto_enroll)
|
||||
cea = CourseEnrollmentAllowed.objects.filter(email='test1_2@student.com', course_id=course.id)
|
||||
self.assertEqual(1, cea[0].auto_enroll)
|
||||
|
||||
#Check there is no enrollment db entry other than for the setup instructor and students
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id)
|
||||
self.assertEqual(3, len(ce))
|
||||
|
||||
#Create and activate student accounts with same email
|
||||
self.student1 = 'test1_1@student.com'
|
||||
self.password = 'bar'
|
||||
self.create_account('s1_1', self.student1, self.password)
|
||||
self.activate_user(self.student1)
|
||||
|
||||
self.student2 = 'test1_2@student.com'
|
||||
self.create_account('s1_2', self.student2, self.password)
|
||||
self.activate_user(self.student2)
|
||||
|
||||
#Check students are enrolled
|
||||
user = User.objects.get(email='test1_1@student.com')
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
|
||||
self.assertEqual(1, len(ce))
|
||||
|
||||
user = User.objects.get(email='test1_2@student.com')
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
|
||||
self.assertEqual(1, len(ce))
|
||||
|
||||
def test_enrollmemt_new_student_autoenroll_off(self):
|
||||
'''
|
||||
Do auto-enroll off test
|
||||
'''
|
||||
|
||||
#Run the Enroll students command
|
||||
course = self.toy
|
||||
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
|
||||
response = self.client.post(url, {'action': 'Enroll multiple students', 'multiple_students': 'test2_1@student.com, test2_2@student.com'})
|
||||
|
||||
#Check the page output
|
||||
self.assertContains(response, '<td>test2_1@student.com</td>')
|
||||
self.assertContains(response, '<td>test2_2@student.com</td>')
|
||||
self.assertContains(response, '<td>user does not exist, enrollment allowed, pending with auto enrollment off</td>')
|
||||
|
||||
#Check the enrollmentallowed db entries
|
||||
cea = CourseEnrollmentAllowed.objects.filter(email='test2_1@student.com', course_id=course.id)
|
||||
self.assertEqual(0, cea[0].auto_enroll)
|
||||
cea = CourseEnrollmentAllowed.objects.filter(email='test2_2@student.com', course_id=course.id)
|
||||
self.assertEqual(0, cea[0].auto_enroll)
|
||||
|
||||
#Check there is no enrollment db entry other than for the setup instructor and students
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id)
|
||||
self.assertEqual(3, len(ce))
|
||||
|
||||
#Create and activate student accounts with same email
|
||||
self.student = 'test2_1@student.com'
|
||||
self.password = 'bar'
|
||||
self.create_account('s2_1', self.student, self.password)
|
||||
self.activate_user(self.student)
|
||||
|
||||
self.student = 'test2_2@student.com'
|
||||
self.create_account('s2_2', self.student, self.password)
|
||||
self.activate_user(self.student)
|
||||
|
||||
#Check students are not enrolled
|
||||
user = User.objects.get(email='test2_1@student.com')
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
|
||||
self.assertEqual(0, len(ce))
|
||||
user = User.objects.get(email='test2_2@student.com')
|
||||
ce = CourseEnrollment.objects.filter(course_id=course.id, user=user)
|
||||
self.assertEqual(0, len(ce))
|
||||
|
||||
def test_get_and_clean_student_list(self):
|
||||
'''
|
||||
Clean user input test
|
||||
'''
|
||||
|
||||
string = "abc@test.com, def@test.com ghi@test.com \n \n jkl@test.com "
|
||||
cleaned_string, cleaned_string_lc = get_and_clean_student_list(string)
|
||||
self.assertEqual(cleaned_string, ['abc@test.com', 'def@test.com', 'ghi@test.com', 'jkl@test.com'])
|
||||
63
lms/djangoapps/instructor/tests/test_xss.py
Normal file
63
lms/djangoapps/instructor/tests/test_xss.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Tests of various instructor dashboard features that include lists of students
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from markupsafe import escape
|
||||
|
||||
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from instructor import views
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestXss(ModuleStoreTestCase):
|
||||
def setUp(self):
|
||||
self._request_factory = RequestFactory()
|
||||
self._course = CourseFactory.create()
|
||||
self._evil_student = UserFactory.create(
|
||||
email="robot+evil@edx.org",
|
||||
username="evil-robot",
|
||||
profile__name='<span id="evil">Evil Robot</span>',
|
||||
)
|
||||
self._instructor = UserFactory.create(
|
||||
email="robot+instructor@edx.org",
|
||||
username="instructor",
|
||||
is_staff=True
|
||||
)
|
||||
CourseEnrollmentFactory.create(
|
||||
user=self._evil_student,
|
||||
course_id=self._course.id
|
||||
)
|
||||
|
||||
def _test_action(self, action):
|
||||
"""
|
||||
Test for XSS vulnerability in the given action
|
||||
|
||||
Build a request with the given action, call the instructor dashboard
|
||||
view, and check that HTML code in a user's name is properly escaped.
|
||||
"""
|
||||
req = self._request_factory.post(
|
||||
"dummy_url",
|
||||
data={"action": action}
|
||||
)
|
||||
req.user = self._instructor
|
||||
req.session = {}
|
||||
resp = views.instructor_dashboard(req, self._course.id)
|
||||
respUnicode = resp.content.decode(settings.DEFAULT_CHARSET)
|
||||
self.assertNotIn(self._evil_student.profile.name, respUnicode)
|
||||
self.assertIn(escape(self._evil_student.profile.name), respUnicode)
|
||||
|
||||
def test_list_enrolled(self):
|
||||
self._test_action("List enrolled students")
|
||||
|
||||
def test_dump_list_of_enrolled(self):
|
||||
self._test_action("Dump list of enrolled students")
|
||||
|
||||
def test_dump_grades(self):
|
||||
self._test_action("Dump Grades for all students in this course")
|
||||
@@ -5,6 +5,7 @@ from collections import defaultdict
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from markupsafe import escape
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
@@ -76,10 +77,6 @@ def instructor_dashboard(request, course_id):
|
||||
else:
|
||||
idash_mode = request.session.get('idash_mode', 'Grades')
|
||||
|
||||
def escape(s):
|
||||
"""escape HTML special characters in string"""
|
||||
return str(s).replace('<', '<').replace('>', '>')
|
||||
|
||||
# assemble some course statistics for output to instructor
|
||||
datatable = {'header': ['Statistic', 'Value'],
|
||||
'title': 'Course Statistics At A Glance',
|
||||
@@ -230,13 +227,13 @@ def instructor_dashboard(request, course_id):
|
||||
if student_to_reset is not None:
|
||||
# find the module in question
|
||||
if '/' not in problem_to_reset: # allow state of modules other than problem to be reset
|
||||
problem_to_reset = "problem/" + problem_to_reset # but problem is the default
|
||||
problem_to_reset = "problem/" + problem_to_reset # but problem is the default
|
||||
try:
|
||||
(org, course_name, _) = course_id.split("/")
|
||||
module_state_key = "i4x://" + org + "/" + course_name + "/" + problem_to_reset
|
||||
module_to_reset = StudentModule.objects.get(student_id=student_to_reset.id,
|
||||
course_id=course_id,
|
||||
module_state_key=module_state_key)
|
||||
course_id=course_id,
|
||||
module_state_key=module_state_key)
|
||||
msg += "Found module to reset. "
|
||||
except Exception:
|
||||
msg += "<font color='red'>Couldn't find module with that urlname. </font>"
|
||||
@@ -260,19 +257,18 @@ def instructor_dashboard(request, course_id):
|
||||
module_to_reset.state = json.dumps(problem_state)
|
||||
module_to_reset.save()
|
||||
track.views.server_track(request,
|
||||
'{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
|
||||
old_attempts=old_number_of_attempts,
|
||||
student=student_to_reset,
|
||||
problem=module_to_reset.module_state_key,
|
||||
instructor=request.user,
|
||||
course=course_id),
|
||||
{},
|
||||
page='idashboard')
|
||||
'{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
|
||||
old_attempts=old_number_of_attempts,
|
||||
student=student_to_reset,
|
||||
problem=module_to_reset.module_state_key,
|
||||
instructor=request.user,
|
||||
course=course_id),
|
||||
{},
|
||||
page='idashboard')
|
||||
msg += "<font color='green'>Module state successfully reset!</font>"
|
||||
except:
|
||||
msg += "<font color='red'>Couldn't reset module state. </font>"
|
||||
|
||||
|
||||
elif "Get link to student's progress page" in action:
|
||||
unique_student_identifier = request.POST.get('unique_student_identifier', '')
|
||||
try:
|
||||
@@ -282,12 +278,12 @@ def instructor_dashboard(request, course_id):
|
||||
student_to_reset = User.objects.get(username=unique_student_identifier)
|
||||
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': student_to_reset.id})
|
||||
track.views.server_track(request,
|
||||
'{instructor} requested progress page for {student} in {course}'.format(
|
||||
student=student_to_reset,
|
||||
instructor=request.user,
|
||||
course=course_id),
|
||||
{},
|
||||
page='idashboard')
|
||||
'{instructor} requested progress page for {student} in {course}'.format(
|
||||
student=student_to_reset,
|
||||
instructor=request.user,
|
||||
course=course_id),
|
||||
{},
|
||||
page='idashboard')
|
||||
msg += "<a href='{0}' target='_blank'> Progress page for username: {1} with email address: {2}</a>.".format(progress_url, student_to_reset.username, student_to_reset.email)
|
||||
except:
|
||||
msg += "<font color='red'>Couldn't find student with that username. </font>"
|
||||
@@ -315,8 +311,9 @@ def instructor_dashboard(request, course_id):
|
||||
msg2, rg_stud_data = _do_remote_gradebook(request.user, course, 'get-membership')
|
||||
datatable = {'header': ['Student email', 'Match?']}
|
||||
rg_students = [x['email'] for x in rg_stud_data['retdata']]
|
||||
|
||||
def domatch(x):
|
||||
return '<font color="green">yes</font>' if x.email in rg_students else '<font color="red">No</font>'
|
||||
return 'yes' if x.email in rg_students else 'No'
|
||||
datatable['data'] = [[x.email, domatch(x)] for x in stud_data['students']]
|
||||
datatable['title'] = action
|
||||
|
||||
@@ -350,7 +347,6 @@ def instructor_dashboard(request, course_id):
|
||||
msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files)
|
||||
msg += msg2
|
||||
|
||||
|
||||
#----------------------------------------
|
||||
# Admin
|
||||
|
||||
@@ -416,6 +412,7 @@ def instructor_dashboard(request, course_id):
|
||||
profkeys = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education',
|
||||
'mailing_address', 'goals']
|
||||
datatable = {'header': ['username', 'email'] + profkeys}
|
||||
|
||||
def getdat(u):
|
||||
p = u.profile
|
||||
return [u.username, u.email] + [getattr(p, x, '') for x in profkeys]
|
||||
@@ -424,9 +421,8 @@ def instructor_dashboard(request, course_id):
|
||||
datatable['title'] = 'Student profile data for course %s' % course_id
|
||||
return return_csv('profiledata_%s.csv' % course_id, datatable)
|
||||
|
||||
|
||||
elif 'Download CSV of all responses to problem' in action:
|
||||
problem_to_dump = request.POST.get('problem_to_dump','')
|
||||
problem_to_dump = request.POST.get('problem_to_dump', '')
|
||||
|
||||
if problem_to_dump[-4:] == ".xml":
|
||||
problem_to_dump = problem_to_dump[:-4]
|
||||
@@ -444,7 +440,7 @@ def instructor_dashboard(request, course_id):
|
||||
|
||||
if smdat:
|
||||
datatable = {'header': ['username', 'state']}
|
||||
datatable['data'] = [ [x.student.username, x.state] for x in smdat ]
|
||||
datatable['data'] = [[x.student.username, x.state] for x in smdat]
|
||||
datatable['title'] = 'Student state for problem %s' % problem_to_dump
|
||||
return return_csv('student_state_from_%s.csv' % problem_to_dump, datatable)
|
||||
|
||||
@@ -481,7 +477,6 @@ def instructor_dashboard(request, course_id):
|
||||
msg += _list_course_forum_members(course_id, rolename, datatable)
|
||||
track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard')
|
||||
|
||||
|
||||
elif action == 'Remove forum admin':
|
||||
uname = request.POST['forumadmin']
|
||||
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE)
|
||||
@@ -539,35 +534,17 @@ def instructor_dashboard(request, course_id):
|
||||
datatable['data'] = [[x.email] for x in ceaset]
|
||||
datatable['title'] = action
|
||||
|
||||
elif action == 'Enroll student':
|
||||
|
||||
student = request.POST.get('enstudent', '')
|
||||
ret = _do_enroll_students(course, course_id, student)
|
||||
datatable = ret['datatable']
|
||||
|
||||
elif action == 'Un-enroll student':
|
||||
|
||||
student = request.POST.get('enstudent', '')
|
||||
datatable = {}
|
||||
isok = False
|
||||
cea = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=student)
|
||||
if cea:
|
||||
cea.delete()
|
||||
msg += "Un-enrolled student with email '%s'" % student
|
||||
isok = True
|
||||
try:
|
||||
nce = CourseEnrollment.objects.get(user=User.objects.get(email=student), course_id=course_id)
|
||||
nce.delete()
|
||||
msg += "Un-enrolled student with email '%s'" % student
|
||||
except Exception as err:
|
||||
if not isok:
|
||||
msg += "Error! Failed to un-enroll student with email '%s'\n" % student
|
||||
msg += str(err) + '\n'
|
||||
|
||||
elif action == 'Enroll multiple students':
|
||||
|
||||
students = request.POST.get('enroll_multiple', '')
|
||||
ret = _do_enroll_students(course, course_id, students)
|
||||
students = request.POST.get('multiple_students', '')
|
||||
auto_enroll = bool(request.POST.get('auto_enroll'))
|
||||
ret = _do_enroll_students(course, course_id, students, auto_enroll=auto_enroll)
|
||||
datatable = ret['datatable']
|
||||
|
||||
elif action == 'Unenroll multiple students':
|
||||
|
||||
students = request.POST.get('multiple_students', '')
|
||||
ret = _do_unenroll_students(course_id, students)
|
||||
datatable = ret['datatable']
|
||||
|
||||
elif action == 'List sections available in remote gradebook':
|
||||
@@ -589,7 +566,6 @@ def instructor_dashboard(request, course_id):
|
||||
ret = _do_enroll_students(course, course_id, students, overload=overload)
|
||||
datatable = ret['datatable']
|
||||
|
||||
|
||||
#----------------------------------------
|
||||
# psychometrics
|
||||
|
||||
@@ -609,9 +585,9 @@ def instructor_dashboard(request, course_id):
|
||||
logs and swallows errors.
|
||||
"""
|
||||
url = settings.ANALYTICS_SERVER_URL + \
|
||||
"get?aname={}&course_id={}&apikey={}".format(analytics_name,
|
||||
course_id,
|
||||
settings.ANALYTICS_API_KEY)
|
||||
"get?aname={}&course_id={}&apikey={}".format(analytics_name,
|
||||
course_id,
|
||||
settings.ANALYTICS_API_KEY)
|
||||
try:
|
||||
res = requests.get(url)
|
||||
except Exception:
|
||||
@@ -670,7 +646,7 @@ def instructor_dashboard(request, course_id):
|
||||
'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}),
|
||||
|
||||
'analytics_results': analytics_results,
|
||||
}
|
||||
}
|
||||
|
||||
return render_to_response('courseware/instructor_dashboard.html', context)
|
||||
|
||||
@@ -833,7 +809,7 @@ def _add_or_remove_user_group(request, username_or_email, group, group_title, ev
|
||||
action = "Added" if do_add else "Removed"
|
||||
prep = "to" if do_add else "from"
|
||||
msg = '<font color="green">{action} {0} {prep} {1} group = {2}</font>'.format(user, group_title, group.name,
|
||||
action=action, prep=prep)
|
||||
action=action, prep=prep)
|
||||
if do_add:
|
||||
user.groups.add(group)
|
||||
else:
|
||||
@@ -959,7 +935,7 @@ def gradebook(request, course_id):
|
||||
'grade_summary': student_grades(student, request, course),
|
||||
'realname': student.profile.name,
|
||||
}
|
||||
for student in enrolled_students]
|
||||
for student in enrolled_students]
|
||||
|
||||
return render_to_response('courseware/gradebook.html', {
|
||||
'students': student_info,
|
||||
@@ -985,17 +961,11 @@ def grade_summary(request, course_id):
|
||||
#-----------------------------------------------------------------------------
|
||||
# enrollment
|
||||
|
||||
def _do_enroll_students(course, course_id, students, overload=False):
|
||||
def _do_enroll_students(course, course_id, students, overload=False, auto_enroll=False):
|
||||
"""Do the actual work of enrolling multiple students, presented as a string
|
||||
of emails separated by commas or returns"""
|
||||
|
||||
new_students = split_by_comma_and_whitespace(students)
|
||||
new_students = [str(s.strip()) for s in new_students]
|
||||
new_students_lc = [x.lower() for x in new_students]
|
||||
|
||||
if '' in new_students:
|
||||
new_students.remove('')
|
||||
|
||||
new_students, new_students_lc = get_and_clean_student_list(students)
|
||||
status = dict([x, 'unprocessed'] for x in new_students)
|
||||
|
||||
if overload: # delete all but staff
|
||||
@@ -1015,27 +985,35 @@ def _do_enroll_students(course, course_id, students, overload=False):
|
||||
try:
|
||||
user = User.objects.get(email=student)
|
||||
except User.DoesNotExist:
|
||||
# user not signed up yet, put in pending enrollment allowed table
|
||||
if CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id):
|
||||
status[student] = 'user does not exist, enrollment already allowed, pending'
|
||||
|
||||
#User not signed up yet, put in pending enrollment allowed table
|
||||
cea = CourseEnrollmentAllowed.objects.filter(email=student, course_id=course_id)
|
||||
|
||||
#If enrollmentallowed already exists, update auto_enroll flag to however it was set in UI
|
||||
#Will be 0 or 1 records as there is a unique key on email + course_id
|
||||
if cea:
|
||||
cea[0].auto_enroll = auto_enroll
|
||||
cea[0].save()
|
||||
status[student] = 'user does not exist, enrollment already allowed, pending with auto enrollment ' \
|
||||
+ ('on' if auto_enroll else 'off')
|
||||
continue
|
||||
cea = CourseEnrollmentAllowed(email=student, course_id=course_id)
|
||||
cea = CourseEnrollmentAllowed(email=student, course_id=course_id, auto_enroll=auto_enroll)
|
||||
cea.save()
|
||||
status[student] = 'user does not exist, enrollment allowed, pending'
|
||||
status[student] = 'user does not exist, enrollment allowed, pending with auto enrollment ' + ('on' if auto_enroll else 'off')
|
||||
continue
|
||||
|
||||
if CourseEnrollment.objects.filter(user=user, course_id=course_id):
|
||||
status[student] = 'already enrolled'
|
||||
continue
|
||||
try:
|
||||
nce = CourseEnrollment(user=user, course_id=course_id)
|
||||
nce.save()
|
||||
ce = CourseEnrollment(user=user, course_id=course_id)
|
||||
ce.save()
|
||||
status[student] = 'added'
|
||||
except:
|
||||
status[student] = 'rejected'
|
||||
|
||||
datatable = {'header': ['StudentEmail', 'action']}
|
||||
datatable['data'] = [[x, status[x]] for x in status]
|
||||
datatable['data'] = [[x, status[x]] for x in sorted(status)]
|
||||
datatable['title'] = 'Enrollment of students'
|
||||
|
||||
def sf(stat):
|
||||
@@ -1047,39 +1025,69 @@ def _do_enroll_students(course, course_id, students, overload=False):
|
||||
return data
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def enroll_students(request, course_id):
|
||||
"""Allows a staff member to enroll students in a course.
|
||||
#Unenrollment
|
||||
def _do_unenroll_students(course_id, students):
|
||||
"""Do the actual work of un-enrolling multiple students, presented as a string
|
||||
of emails separated by commas or returns"""
|
||||
|
||||
This is a short-term hack for Berkeley courses launching fall
|
||||
2012. In the long term, we would like functionality like this, but
|
||||
we would like both the instructor and the student to agree. Right
|
||||
now, this allows any instructor to add students to their course,
|
||||
which we do not want.
|
||||
old_students, old_students_lc = get_and_clean_student_list(students)
|
||||
status = dict([x, 'unprocessed'] for x in old_students)
|
||||
|
||||
It is poorly written and poorly tested, but it's designed to be
|
||||
stripped out.
|
||||
for student in old_students:
|
||||
|
||||
isok = False
|
||||
cea = CourseEnrollmentAllowed.objects.filter(course_id=course_id, email=student)
|
||||
#Will be 0 or 1 records as there is a unique key on email + course_id
|
||||
if cea:
|
||||
cea[0].delete()
|
||||
status[student] = "un-enrolled"
|
||||
isok = True
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=student)
|
||||
except User.DoesNotExist:
|
||||
continue
|
||||
|
||||
ce = CourseEnrollment.objects.filter(user=user, course_id=course_id)
|
||||
#Will be 0 or 1 records as there is a unique key on user + course_id
|
||||
if ce:
|
||||
try:
|
||||
ce[0].delete()
|
||||
status[student] = "un-enrolled"
|
||||
except Exception as err:
|
||||
if not isok:
|
||||
status[student] = "Error! Failed to un-enroll"
|
||||
|
||||
datatable = {'header': ['StudentEmail', 'action']}
|
||||
datatable['data'] = [[x, status[x]] for x in sorted(status)]
|
||||
datatable['title'] = 'Un-enrollment of students'
|
||||
|
||||
data = dict(datatable=datatable)
|
||||
return data
|
||||
|
||||
|
||||
def get_and_clean_student_list(students):
|
||||
"""
|
||||
Separate out individual student email from the comma, or space separated string.
|
||||
|
||||
In:
|
||||
students: string coming from the input text area
|
||||
Return:
|
||||
students: list of cleaned student emails
|
||||
students_lc: list of lower case cleaned student emails
|
||||
"""
|
||||
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
existing_students = [ce.user.email for ce in CourseEnrollment.objects.filter(course_id=course_id)]
|
||||
|
||||
new_students = request.POST.get('new_students')
|
||||
ret = _do_enroll_students(course, course_id, new_students)
|
||||
added_students = ret['added']
|
||||
rejected_students = ret['rejected']
|
||||
|
||||
return render_to_response("enroll_students.html", {'course': course_id,
|
||||
'existing_students': existing_students,
|
||||
'added_students': added_students,
|
||||
'rejected_students': rejected_students,
|
||||
'debug': new_students})
|
||||
students = split_by_comma_and_whitespace(students)
|
||||
students = [str(s.strip()) for s in students]
|
||||
students = [s for s in students if s != '']
|
||||
students_lc = [x.lower() for x in students]
|
||||
|
||||
return students, students_lc
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# answer distribution
|
||||
|
||||
|
||||
def get_answers_distribution(request, course_id):
|
||||
"""
|
||||
Get the distribution of answers for all graded problems in the course.
|
||||
@@ -1171,5 +1179,5 @@ def dump_grading_context(course):
|
||||
msg += " %s (format=%s, Assignment=%s%s)\n" % (s.display_name, format, aname, notes)
|
||||
msg += "all descriptors:\n"
|
||||
msg += "length=%d\n" % len(gc['all_descriptors'])
|
||||
msg = '<pre>%s</pre>' % msg.replace('<','<')
|
||||
msg = '<pre>%s</pre>' % msg.replace('<', '<')
|
||||
return msg
|
||||
|
||||
@@ -99,6 +99,8 @@ CELERY_QUEUES = {
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file:
|
||||
ENV_TOKENS = json.load(env_file)
|
||||
|
||||
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME)
|
||||
|
||||
SITE_NAME = ENV_TOKENS['SITE_NAME']
|
||||
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
|
||||
@@ -106,7 +108,8 @@ SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can
|
||||
# happen with some browsers (e.g. Firefox)
|
||||
if ENV_TOKENS.get('SESSION_COOKIE_NAME', None):
|
||||
SESSION_COOKIE_NAME = ENV_TOKENS.get('SESSION_COOKIE_NAME')
|
||||
# NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str()
|
||||
SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME'))
|
||||
|
||||
BOOK_URL = ENV_TOKENS['BOOK_URL']
|
||||
MEDIA_URL = ENV_TOKENS['MEDIA_URL']
|
||||
@@ -119,11 +122,18 @@ 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)
|
||||
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
|
||||
CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL)
|
||||
BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL)
|
||||
|
||||
#Theme overrides
|
||||
THEME_NAME = ENV_TOKENS.get('THEME_NAME', None)
|
||||
if not THEME_NAME is None:
|
||||
enable_theme(THEME_NAME)
|
||||
FAVICON_PATH = 'themes/%s/images/favicon.ico' % THEME_NAME
|
||||
|
||||
# Marketing link overrides
|
||||
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
|
||||
|
||||
#Timezone overrides
|
||||
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
|
||||
@@ -162,6 +172,11 @@ for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items():
|
||||
|
||||
COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])
|
||||
|
||||
# If segment.io key specified, load it and turn on segment IO if the feature flag is set
|
||||
SEGMENT_IO_LMS_KEY = ENV_TOKENS.get('SEGMENT_IO_LMS_KEY')
|
||||
if SEGMENT_IO_LMS_KEY:
|
||||
MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False)
|
||||
|
||||
############################## SECURE AUTH ITEMS ###############
|
||||
# Secret things: passwords, access keys, etc.
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ from path import path
|
||||
from .discussionsettings import *
|
||||
|
||||
################################### FEATURES ###################################
|
||||
# The display name of the platform to be used in templates/emails/etc.
|
||||
PLATFORM_NAME = "edX"
|
||||
|
||||
COURSEWARE_ENABLED = True
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
@@ -99,6 +102,9 @@ MITX_FEATURES = {
|
||||
# Staff Debug tool.
|
||||
'ENABLE_STUDENT_HISTORY_VIEW': True,
|
||||
|
||||
# segment.io for LMS--need to explicitly turn it on on production.
|
||||
'SEGMENT_IO_LMS': False,
|
||||
|
||||
# Enables the student notes API and UI.
|
||||
'ENABLE_STUDENT_NOTES': True,
|
||||
|
||||
@@ -315,6 +321,9 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
DEFAULT_FROM_EMAIL = 'registration@edx.org'
|
||||
DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org'
|
||||
SERVER_EMAIL = 'devops@edx.org'
|
||||
TECH_SUPPORT_EMAIL = 'technical@edx.org'
|
||||
CONTACT_EMAIL = 'info@edx.org'
|
||||
BUGS_EMAIL = 'bugs@edx.org'
|
||||
ADMINS = (
|
||||
('edX Admins', 'admin@edx.org'),
|
||||
)
|
||||
@@ -330,6 +339,8 @@ STATICFILES_DIRS = [
|
||||
PROJECT_ROOT / "static",
|
||||
]
|
||||
|
||||
FAVICON_PATH = 'images/favicon.ico'
|
||||
|
||||
# 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
|
||||
|
||||
@@ -243,3 +243,18 @@ MITX_FEATURES['ENABLE_PEARSON_LOGIN'] = False
|
||||
|
||||
ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
|
||||
ANALYTICS_API_KEY = ""
|
||||
|
||||
##### segment-io ######
|
||||
|
||||
# If there's an environment variable set, grab it and turn on segment io
|
||||
SEGMENT_IO_LMS_KEY = os.environ.get('SEGMENT_IO_LMS_KEY')
|
||||
if SEGMENT_IO_LMS_KEY:
|
||||
MITX_FEATURES['SEGMENT_IO_LMS'] = True
|
||||
|
||||
|
||||
#####################################################################
|
||||
# Lastly, see if the developer has any local overrides.
|
||||
try:
|
||||
from .private import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -98,3 +98,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------
|
||||
// The Following is to enable themes to
|
||||
// display H1s on login and register pages
|
||||
//--------------------------------------
|
||||
.view-login .introduction header h1,
|
||||
.view-register .introduction header h1 {
|
||||
@include login_register_h1_style;
|
||||
}
|
||||
|
||||
footer .references {
|
||||
@include footer_references_style;
|
||||
}
|
||||
@@ -4,6 +4,20 @@
|
||||
@import 'base/font_face';
|
||||
@import 'base/mixins';
|
||||
@import 'base/variables';
|
||||
|
||||
## THEMING
|
||||
## -------
|
||||
## Set up this file to import an edX theme library if the environment
|
||||
## indicates that a theme should be used. The assumption is that the
|
||||
## theme resides outside of this main edX repository, in a directory
|
||||
## called themes/<theme-name>/, with its base Sass file in
|
||||
## themes/<theme-name>/static/sass/_<theme-name>.scss. That one entry
|
||||
## point can be used to @import in as many other things as needed.
|
||||
% if env.get('THEME_NAME') is not None:
|
||||
// import theme's Sass overrides
|
||||
@import '${env.get('THEME_NAME')}';
|
||||
% endif
|
||||
|
||||
@import 'base/base';
|
||||
@import 'base/extends';
|
||||
@import 'base/animations';
|
||||
@@ -36,16 +50,3 @@
|
||||
@import 'news';
|
||||
|
||||
@import 'shame';
|
||||
|
||||
## THEMING
|
||||
## -------
|
||||
## Set up this file to import an edX theme library if the environment
|
||||
## indicates that a theme should be used. The assumption is that the
|
||||
## theme resides outside of this main edX repository, in a directory
|
||||
## called themes/<theme-name>/, with its base Sass file in
|
||||
## themes/<theme-name>/static/sass/_<theme-name>.scss. That one entry
|
||||
## point can be used to @import in as many other things as needed.
|
||||
% if env.get('THEME_NAME') is not None:
|
||||
// import theme's Sass overrides
|
||||
@import '${env.get('THEME_NAME')}'
|
||||
% endif
|
||||
|
||||
@@ -209,7 +209,7 @@ mark {
|
||||
}
|
||||
|
||||
.sr {
|
||||
@include text-sr();
|
||||
@extend .text-sr;
|
||||
}
|
||||
|
||||
.help-tab {
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// hidden elems - screenreaders
|
||||
@mixin text-sr() {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
@mixin vertically-and-horizontally-centered ( $height, $width ) {
|
||||
left: 50%;
|
||||
margin-left: -$width / 2;
|
||||
@@ -42,3 +30,25 @@
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
//-----------------
|
||||
// Theme Mixin Styles
|
||||
//-----------------
|
||||
@mixin login_register_h1_style {}
|
||||
|
||||
@mixin footer_references_style {}
|
||||
|
||||
// ====================
|
||||
|
||||
// extends -hidden elems - screenreaders
|
||||
.text-sr {
|
||||
border: 0;
|
||||
clip: rect(1px 1px 1px 1px);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
@@ -62,11 +62,12 @@ $lighter-base-font-color: rgb(100,100,100);
|
||||
$text-color: $dark-gray;
|
||||
|
||||
$body-bg: rgb(250,250,250);
|
||||
$container-bg: $white;
|
||||
$header-image: linear-gradient(-90deg, rgba(255,255,255, 1), rgba(230,230,230, 0.9));
|
||||
$header-bg: transparent;
|
||||
$header-bg: $white;
|
||||
$courseware-header-image: linear-gradient(top, #fff, #eee);
|
||||
$courseware-header-bg: transparent;
|
||||
$footer-bg: transparent;
|
||||
$footer-bg: $white;
|
||||
$courseware-footer-border: none;
|
||||
$courseware-footer-shadow: none;
|
||||
$courseware-footer-margin: 0px;
|
||||
@@ -87,9 +88,10 @@ $dashboard-profile-header-color: transparent;
|
||||
$dashboard-profile-color: rgb(252,252,252);
|
||||
$dot-color: $light-gray;
|
||||
|
||||
$content-wrapper-bg: rgb(255,255,255);
|
||||
$content-wrapper-bg: $white;
|
||||
$course-bg-color: #d6d6d6;
|
||||
$course-bg-image: url(../images/bg-texture.png);
|
||||
$account-content-wrapper-bg: shade($body-bg, 2%);
|
||||
|
||||
$course-profile-bg: rgb(245,245,245);
|
||||
$course-header-bg: rgba(255,255,255, 0.93);
|
||||
@@ -100,6 +102,7 @@ $border-color-3: rgb(100,100,100);
|
||||
$border-color-4: rgb(252,252,252);
|
||||
|
||||
$link-color: $blue;
|
||||
$link-color-d1: $m-blue;
|
||||
$link-hover: $pink;
|
||||
$selection-color-1: $pink;
|
||||
$selection-color-2: #444;
|
||||
@@ -118,9 +121,18 @@ $sidebar-active-image: linear-gradient(top, #e6e6e6, #d6d6d6);
|
||||
$form-bg-color: #fff;
|
||||
$modal-bg-color: rgb(245,245,245);
|
||||
|
||||
//TOP HEADER IMAGE MARGIN
|
||||
$header_image_margin: -69px;
|
||||
|
||||
//FOOTER MARGIN
|
||||
$footer_margin: ($baseline/4) 0 ($baseline*1.5) 0;
|
||||
|
||||
//-----------------
|
||||
// CSS BG Images
|
||||
//-----------------
|
||||
$homepage-bg-image: '../images/homepage-bg.jpg';
|
||||
|
||||
$login-banner-image: url(../images/bg-banner-login.png);
|
||||
$register-banner-image: url(../images/bg-banner-register.png);
|
||||
|
||||
$video-thumb-url: '../images/courses/video-thumb.jpg';
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
@import 'base/font_face';
|
||||
@import 'base/mixins';
|
||||
@import 'base/variables';
|
||||
|
||||
## THEMING
|
||||
## -------
|
||||
## Set up this file to import an edX theme library if the environment
|
||||
## indicates that a theme should be used. The assumption is that the
|
||||
## theme resides outside of this main edX repository, in a directory
|
||||
## called themes/<theme-name>/, with its base Sass file in
|
||||
## themes/<theme-name>/static/sass/_<theme-name>.scss. That one entry
|
||||
## point can be used to @import in as many other things as needed.
|
||||
% if env.get('THEME_NAME') is not None:
|
||||
// import theme's Sass overrides
|
||||
@import '${env.get('THEME_NAME')}';
|
||||
% endif
|
||||
|
||||
@import 'base/base';
|
||||
@import 'base/extends';
|
||||
@import 'base/animations';
|
||||
@@ -35,7 +35,7 @@ a {
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
border: 1px solid $outer-border-color;
|
||||
background: $body-bg;
|
||||
background: $container-bg;
|
||||
@include box-shadow(0 1px 2px rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ textarea,
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"] {
|
||||
background: $body-bg;
|
||||
background: $white;
|
||||
border: 1px solid $border-color-2;
|
||||
@include border-radius(0);
|
||||
@include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1));
|
||||
|
||||
@@ -65,7 +65,7 @@ header.global.slim {
|
||||
height: auto;
|
||||
padding: 5px 0 10px 0;
|
||||
border-bottom: 1px solid $outer-border-color;
|
||||
background: $white;
|
||||
background: $header-bg;
|
||||
|
||||
.guest .secondary {
|
||||
margin-right: 0;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
footer {
|
||||
border: $courseware-footer-border;
|
||||
box-shadow: $courseware-footer-shadow;
|
||||
margin-top: $courseware-footer-margin;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
// page-level
|
||||
.view-register, .view-login, .view-passwordreset {
|
||||
background: $white;
|
||||
background: $container-bg;
|
||||
|
||||
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
margin: 0 0 $baseline 0;
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
color: $m-blue;
|
||||
color: $link-color-d1;
|
||||
}
|
||||
|
||||
.heading-3 {
|
||||
font-size: 21px;
|
||||
margin: 0 0 $baseline 0;
|
||||
font-weight: 300;
|
||||
color: $m-gray-d2;
|
||||
color: $base-font-color;
|
||||
}
|
||||
|
||||
.heading-4 {
|
||||
@@ -37,7 +37,7 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0 !important;
|
||||
color: $m-blue-s1;
|
||||
color: saturate($link-color-d1,15%);
|
||||
}
|
||||
|
||||
.heading-5 {
|
||||
@@ -48,7 +48,7 @@
|
||||
font-size: 18px;
|
||||
margin: 0 0 $baseline 0;
|
||||
font-weight: 300;
|
||||
color: $m-gray-a1;
|
||||
color: $base-font-color;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
line-height: lh(1.1);
|
||||
}
|
||||
@@ -56,18 +56,18 @@
|
||||
.body-text {
|
||||
font-size: 15px;
|
||||
margin: 0 0 $baseline 0;
|
||||
color: $m-gray-a1;
|
||||
color: $base-font-color;
|
||||
line-height: lh(1);
|
||||
}
|
||||
|
||||
// specific examples - buttons
|
||||
.button-primary {
|
||||
@include border-radius(0);
|
||||
@include linear-gradient($m-blue-s1 5%, $m-blue-d1 95%);
|
||||
@include linear-gradient(saturate($link-color-d1,15%) 5%, shade($link-color-d1,15%) 95%);
|
||||
display: inline-block;
|
||||
padding: $baseline/2 $baseline*2.5;
|
||||
text-transform: lowercase;
|
||||
color: $white;
|
||||
color: $very-light-text;
|
||||
letter-spacing: 0.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
@@ -80,11 +80,11 @@
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
@include linear-gradient($m-gray 5%, $m-gray-d1 95%);
|
||||
@include linear-gradient($outer-border-color 5%, $lighter-base-font-color 95%);
|
||||
display: inline-block;
|
||||
padding: $baseline/2 $baseline*2.5;
|
||||
text-transform: lowercase;
|
||||
color: $white;
|
||||
color: $very-light-text;
|
||||
letter-spacing: 0.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -98,7 +98,7 @@
|
||||
|
||||
// layout
|
||||
.content-wrapper {
|
||||
background: $m-gray-l2;
|
||||
background: $account-content-wrapper-bg;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
@include clearfix;
|
||||
margin: 0 auto;
|
||||
width: 960px;
|
||||
background: $white;
|
||||
background: $container-bg;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -142,14 +142,15 @@
|
||||
@include transition(color 0.15s ease-in-out, border 0.15s ease-in-out);
|
||||
|
||||
&:link, &:visited, &:hover, &:active {
|
||||
color: $m-blue;
|
||||
text-decoration: none !important;
|
||||
color: $link-color-d1;
|
||||
font-weight: 400;
|
||||
text-decoration: none !important; // needed but nasty
|
||||
font-family: $sans-serif;
|
||||
}
|
||||
|
||||
&:hover, &:active {
|
||||
border-bottom: 1px dotted $m-blue-l1;
|
||||
color: $m-blue-l1;
|
||||
text-decoration: none !important; // needed but nasty
|
||||
border-bottom: 1px dotted $link-color-d1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +255,7 @@
|
||||
font-family: $sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
color: $m-gray-d2;
|
||||
color: $base-font-color;
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -267,7 +268,7 @@
|
||||
@include transition(color 0.15s ease-in-out);
|
||||
display: block;
|
||||
margin-top: ($baseline/4);
|
||||
color: tint($m-gray, 50%);
|
||||
color: tint($outer-border-color, 50%);
|
||||
font-size: em(13);
|
||||
}
|
||||
|
||||
@@ -330,7 +331,7 @@
|
||||
}
|
||||
|
||||
textarea, input {
|
||||
background: $white;
|
||||
background: $container-bg;
|
||||
color: rgba(0,0,0,.25);
|
||||
}
|
||||
}
|
||||
@@ -339,11 +340,11 @@
|
||||
&.is-focused {
|
||||
|
||||
label {
|
||||
color: $m-blue-l1;
|
||||
color: saturate($link-color-d1,15%);
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: $m-blue-l1;
|
||||
color: saturate($link-color-d1,15%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +462,7 @@
|
||||
|
||||
// misc
|
||||
.orn-plus {
|
||||
color: $white;
|
||||
color: $very-light-text;
|
||||
padding: 0 $baseline/4;
|
||||
}
|
||||
|
||||
@@ -492,7 +493,7 @@
|
||||
header {
|
||||
height: 120px;
|
||||
border-bottom: 1px solid $m-gray;
|
||||
background: transparent url("../images/bg-banner-login.png") 0 0 no-repeat;
|
||||
background: transparent $login-banner-image 0 0 no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,14 +507,14 @@
|
||||
header {
|
||||
height: 120px;
|
||||
border-bottom: 1px solid $m-gray;
|
||||
background: transparent url("../images/bg-banner-register.png") 0 0 no-repeat;
|
||||
background: transparent $register-banner-image 0 0 no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// password reset
|
||||
.view-passwordreset {
|
||||
background: $m-gray-l2;
|
||||
background: $sidebar-color;
|
||||
|
||||
header.global {
|
||||
|
||||
@@ -543,7 +544,7 @@
|
||||
|
||||
.inner-wrapper {
|
||||
@include border-radius(2px);
|
||||
background: $white;
|
||||
background: $body-bg;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
border-bottom: 1px solid $border-color-3;
|
||||
@include box-shadow(inset 0 1px 5px 0 rgba(0,0,0, 0.1));
|
||||
height: 280px;
|
||||
margin-top: -69px;
|
||||
margin-top: $header_image_margin;
|
||||
padding-top: 150px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user