Merge peter-fogg/fix-video-captions-setting.

This commit is contained in:
Peter Fogg
2013-06-11 15:11:14 -04:00
149 changed files with 2955 additions and 1351 deletions

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,3 +8,8 @@ Feature: Video Component
Scenario: Creating a video takes a single click
Given I have clicked the new unit button
Then creating a video takes a single click
Scenario: Captions are shown correctly
Given I have created a Video component
And I have hidden captions
Then when I view the video it does not show the captions

View File

@@ -16,3 +16,13 @@ def video_takes_a_single_click(step):
assert(not world.is_css_present('.xmodule_VideoModule'))
world.css_click("a[data-location='i4x://edx/templates/video/default']")
assert(world.is_css_present('.xmodule_VideoModule'))
@step('I have hidden captions')
def set_show_captions_false(step):
world.css_click('a.hide-subtitles')
@step('when I view the video it does not show the captions')
def does_not_show_captions(step):
assert world.css_find('.video')[0].has_class('closed')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
<div class="block block-comment">${comment}</div>
<div class="block">${comment_prompt}</div>
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment" aria-describedby="answer_${id}">${comment_value|h}</textarea>
<div class="block">${tag_prompt}</div>
<ul class="tags">
@@ -22,11 +22,11 @@
<li>
% if has_options_value:
% if all([c == 'correct' for c in option['choice'], status]):
<span class="tag-status correct" id="status_${id}"></span>
<span class="tag-status correct" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Correct</span></span>
% elif all([c == 'partially-correct' for c in option['choice'], status]):
<span class="tag-status partially-correct" id="status_${id}"></span>
<span class="tag-status partially-correct" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Partially Correct</span></span>
% elif all([c == 'incorrect' for c in option['choice'], status]):
<span class="tag-status incorrect" id="status_${id}"></span>
<span class="tag-status incorrect" id="status_${id}" aria-describedby="input_${id}_comment"><span class="sr">Status: Incorrect</span></span>
% endif
% endif
@@ -53,11 +53,11 @@
% endif
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Unanswered</span></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Incorrect</span></span>
% elif status == 'incorrect' and not has_options_value:
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: Incorrect</span></span>
% endif
<p id="answer_${id}" class="answer answer-annotation"></p>

View File

@@ -11,13 +11,13 @@
<div class="incorrect" id="status_${id}">
% endif
<input type="text" name="input_${id}" id="input_${id}" data-input-id="${id}" value="${value|h}"
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" data-input-id="${id}" value="${value|h}"
% if size:
size="${size}"
% endif
/>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -3,12 +3,12 @@
% if input_type == 'checkbox' or not value:
% if status == 'unsubmitted' or show_correctness == 'never':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
<span class="correct" id="status_${id}"></span>
% elif status == 'correct':
<span class="correct" id="status_${id}"><span class="sr">Status: correct</span></span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}"><span class="sr">Status: incorrect</span></span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}"><span class="sr">Status: incomplete</span></span>
% endif
% endif
</div>
@@ -18,7 +18,7 @@
% for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}"
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
<%
<%
if status == 'correct':
correctness = 'correct'
elif status == 'incorrect':
@@ -31,14 +31,29 @@
% endif
% endif
>
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
<input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" aria-describedby="answer_${id}" value="${choice_id}"
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
checked="true"
checked="true"
% elif input_type != 'radio' and choice_id in value:
checked="true"
% endif
/> ${choice_description} </label>
/> ${choice_description}
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
<%
if status == 'correct':
correctness = 'correct'
elif status == 'incorrect':
correctness = 'incorrect'
else:
correctness = None
%>
% if correctness and not show_correctness=='never':
<span class="sr" aria-describedby="input_${id}_${choice_id}">Status: ${correctness}</span>
% endif
% endif
</label>
% endfor
<span id="answer_${id}"></span>
</fieldset>

View File

@@ -1,5 +1,5 @@
<section id="textbox_${id}" class="textbox">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}"
% if hidden:
style="display:none;"
% endif
@@ -7,13 +7,13 @@
<div class="grader-status">
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Unanswered</span>
% elif status == 'correct':
<span class="correct" id="status_${id}">Correct</span>
<span class="correct" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Correct</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Incorrect</span>
% elif status == 'queued':
<span class="processing" id="status_${id}">Queued</span>
<span class="processing" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Queued</span>
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
% endif

View File

@@ -20,9 +20,9 @@
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
<input type="text" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" value="${value|h}" style="display:none;"/>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -11,12 +11,12 @@
% elif status == 'incomplete':
<div class="incomplete" id="status_${id}">
% endif
<div id="protex_container"></div>
<input type="hidden" name="target_shape" id="target_shape" value ="${target_shape}"></input>
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
<input type="hidden" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"/>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -19,10 +19,10 @@
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
style="display:none;"/>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -11,13 +11,13 @@
% elif status == 'incomplete':
<div class="incomplete" id="status_${id}">
% endif
<div id="genex_container"></div>
<input type="hidden" name="genex_dna_sequence" id="genex_dna_sequence" value ="${genex_dna_sequence}"></input>
<input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
<input type="hidden" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}" value="${value|h}"/>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -16,13 +16,13 @@
<br/>
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
<input type="hidden" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"/>
<button id="reset_${id}" class="reset">Reset</button>
<p id="answer_${id}" class="answer"></p>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -5,12 +5,20 @@
</div>
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
<span class="correct" id="status_${id}"></span>
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: unanswered</span>
</span>
% elif status == 'correct':
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: correct</span>
</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incorrect</span>
</span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incorrect</span>
</span>
% endif
</span>

View File

@@ -19,13 +19,21 @@
% endif
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: unanswered</span>
</span>
% elif status == 'correct':
<span class="correct" id="status_${id}"></span>
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: correct</span>
</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incorrect</span>
</span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incorrect</span>
</span>
% endif
% if msg:
<br/>

View File

@@ -1,5 +1,5 @@
<section id="textbox_${id}" class="textbox">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
<textarea rows="${rows}" cols="${cols}" name="input_${id}" aria-describedby="answer_${id}" id="input_${id}"
% if hidden:
style="display:none;"
% endif
@@ -7,13 +7,13 @@
<div class="grader-status">
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
<span class="unanswered" style="display:inline-block;" id="status_${id}"><span class="sr">Status: </span>Unanswered</span>
% elif status == 'correct':
<span class="correct" id="status_${id}">Correct</span>
<span class="correct" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Correct</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Incorrect</span>
% elif status == 'queued':
<span class="processing" id="status_${id}">Queued</span>
<span class="processing" id="status_${id}" aria-describedby="input_${id}"><span class="sr">Status: </span>Queued</span>
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
% endif
@@ -71,7 +71,7 @@
$(parent_elt).find('.action').after(alert_elem);
$(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700);
}
// hook up the plot button
var plot = function(event) {
@@ -97,10 +97,10 @@
}
}
var save_callback = function(response) {
var save_callback = function(response) {
if(response.success) {
// send information to the problem's plot functionality
Problem.inputAjax(url, input_id, 'plot',
Problem.inputAjax(url, input_id, 'plot',
{'submission': submission}, plot_callback);
}
else {

View File

@@ -1,5 +1,5 @@
<form class="option-input">
<select name="input_${id}" id="input_${id}" >
<select name="input_${id}" id="input_${id}" aria-describedby="answer_${id}">
<option value="option_${id}_dummy_default"> </option>
% for option_id, option_description in options:
<option value="${option_id}"
@@ -13,12 +13,20 @@
<span id="answer_${id}"></span>
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
<span class="correct" id="status_${id}"></span>
<span class="unanswered" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: unsubmitted</span>
</span>
% elif status == 'correct':
<span class="correct" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: correct</span>
</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incorrect</span>
</span>
% elif status == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incomplete</span>
</span>
% endif
</form>

View File

@@ -1,5 +1,5 @@
<span>
<input type="hidden" class="schematic" height="${height}" width="${width}" parts="${parts}" analyses="${analyses}" name="input_${id}" id="input_${id}" value="" initial_value=""/>
<input type="hidden" class="schematic" height="${height}" width="${width}" parts="${parts}" analyses="${analyses}" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="" initial_value=""/>
<div id="value_${id}" style="display:none">${value}</div>
<div id="initial_value_${id}" style="display:none">${initial_value}</div>
@@ -13,13 +13,21 @@
<span id="answer_${id}"></span>
% if status == 'unsubmitted':
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}"></span>
% elif status == 'correct':
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}"></span>
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: unsubmitted</span>
</span>
% elif status == 'correct':
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: correct</span>
</span>
% elif status == 'incorrect':
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incorrect</span>
</span>
% elif status == 'incomplete':
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}" aria-describedby="input_${id}">
<span class="sr">Status: incomplete</span>
</span>
% endif
</span>

View File

@@ -20,7 +20,7 @@
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
% if do_math:
class="math"
% endif
@@ -33,7 +33,7 @@
/>
${trailing_text | h}
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

@@ -21,11 +21,11 @@
<div class="incorrect" id="status_${id}">
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
<input type="text" name="input_${id}" id="input_${id}" aria-describedby="answer_${id}" value="${value|h}"
style="display:none;"
/>
<p class="status">
<p class="status" aria-describedby="input_${id}">
% if status == 'unsubmitted':
unanswered
% elif status == 'correct':

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
<input class="check" type="button" value="Check">
<input class="reset" type="button" value="Reset">
<input class="save" type="button" value="Save">
<input class="show" type="button" value="Show Answer">
<button class="show"><span class="show-label">Show Answer(s)</span> <span class="sr">(for question(s) above - adjacent to each field)</span></button>
<a href="/courseware/6.002_Spring_2012/${ explain }" class="new-page">Explanation</a>
<section class="submission_feedback"></section>
</section>

View File

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

View File

@@ -16,7 +16,7 @@ describe 'Problem', ->
# note that the fixturesPath is set in spec/helper.coffee
loadFixtures 'problem.html'
spyOn Logger, 'log'
spyOn($.fn, 'load').andCallFake (url, callback) ->
$(@).html readFixtures('problem_content.html')
@@ -27,13 +27,13 @@ describe 'Problem', ->
it 'set the element from html', ->
@problem999 = new Problem ("
<section class='xmodule_display xmodule_CapaModule' data-type='Problem'>
<section id='problem_999'
class='problems-wrapper'
data-problem-id='i4x://edX/999/problem/Quiz'
<section id='problem_999'
class='problems-wrapper'
data-problem-id='i4x://edX/999/problem/Quiz'
data-url='/problem/quiz/'>
</section>
</section>
")
")
expect(@problem999.element_id).toBe 'problem_999'
it 'set the element from loadFixtures', ->
@@ -62,7 +62,7 @@ describe 'Problem', ->
expect($('section.action input.reset')).toHandleWith 'click', @problem.reset
it 'bind the show button', ->
expect($('section.action input.show')).toHandleWith 'click', @problem.show
expect($('section.action button.show')).toHandleWith 'click', @problem.show
it 'bind the save button', ->
expect($('section.action input.save')).toHandleWith 'click', @problem.save
@@ -126,14 +126,14 @@ describe 'Problem', ->
describe 'when the response is correct', ->
it 'call render with returned content', ->
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
callback(success: 'correct', contents: 'Correct!')
@problem.check()
expect(@problem.el.html()).toEqual 'Correct!'
describe 'when the response is incorrect', ->
it 'call render with returned content', ->
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
callback(success: 'incorrect', contents: 'Incorrect!')
@problem.check()
expect(@problem.el.html()).toEqual 'Incorrect!'
@@ -159,7 +159,7 @@ describe 'Problem', ->
it 'POST to the problem reset page', ->
spyOn $, 'postWithPrefix'
@problem.reset()
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset',
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_reset',
{ id: 'i4x://edX/101/problem/Problem1' }, jasmine.any(Function)
it 'render the returned content', ->
@@ -179,7 +179,7 @@ describe 'Problem', ->
it 'log the problem_show event', ->
@problem.show()
expect(Logger.log).toHaveBeenCalledWith 'problem_show',
expect(Logger.log).toHaveBeenCalledWith 'problem_show',
problem: 'i4x://edX/101/problem/Problem1'
it 'fetch the answers', ->
@@ -198,7 +198,7 @@ describe 'Problem', ->
it 'toggle the show answer button', ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {})
@problem.show()
expect($('.show')).toHaveValue 'Hide Answer'
expect($('.show .show-label')).toHaveText 'Hide Answer(s)'
it 'add the showed class to element', ->
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(answers: {})
@@ -223,7 +223,7 @@ describe 'Problem', ->
expect($('label[for="input_1_1_3"]')).toHaveAttr 'correct_answer', 'true'
expect($('label[for="input_1_2_1"]')).not.toHaveAttr 'correct_answer', 'true'
describe 'when the answers are alreay shown', ->
describe 'when the answers are already shown', ->
beforeEach ->
@problem.el.addClass 'showed'
@problem.el.prepend '''
@@ -243,7 +243,7 @@ describe 'Problem', ->
it 'toggle the show answer button', ->
@problem.show()
expect($('.show')).toHaveValue 'Show Answer'
expect($('.show .show-label')).toHaveText 'Show Answer(s)'
it 'remove the showed class from element', ->
@problem.show()
@@ -261,7 +261,7 @@ describe 'Problem', ->
it 'POST to save problem', ->
spyOn $, 'postWithPrefix'
@problem.save()
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
'foo=1&bar=2', jasmine.any(Function)
# TODO: figure out why failing

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
# TODO: figure out why failing
xdescribe 'VideoVolumeControl', ->
describe 'VideoVolumeControl', ->
beforeEach ->
jasmine.stubVideoPlayer @
$('.volume').remove()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -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('<', '&lt;').replace('>', '&gt;')
# 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('<','&lt;')
msg = '<pre>%s</pre>' % msg.replace('<', '&lt;')
return msg

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -209,7 +209,7 @@ mark {
}
.sr {
@include text-sr();
@extend .text-sr;
}
.help-tab {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
footer {
border: $courseware-footer-border;
box-shadow: $courseware-footer-shadow;
margin-top: $courseware-footer-margin;
}

View File

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

View File

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