Commit 9098cc11 by Matt Drayer

Merge branch 'master' of git://github.com/edx/edx-platform into feature/mattdrayer/authors-addition

parents 67666a0d 5d63cd0e
...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Studio: Newly-created courses default to being published on Jan 1, 2030
Studio: Added pagination to the Files & Uploads page. Studio: Added pagination to the Files & Uploads page.
Blades: Video player improvements: Blades: Video player improvements:
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
from lettuce import world from lettuce import world
from nose.tools import assert_equal, assert_in # pylint: disable=E0611 from nose.tools import assert_equal, assert_in # pylint: disable=E0611
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
from common import type_in_codemirror
@world.absorb @world.absorb
...@@ -114,6 +115,16 @@ def edit_component(): ...@@ -114,6 +115,16 @@ def edit_component():
world.css_click('a.edit-button') world.css_click('a.edit-button')
def enter_xml_in_advanced_problem(step, text):
"""
Edits an advanced problem (assumes only on page),
types the provided XML, and saves the component.
"""
world.edit_component()
type_in_codemirror(0, text)
world.save_component(step)
@world.absorb @world.absorb
def verify_setting_entry(setting, display_name, value, explicitly_set): def verify_setting_entry(setting, display_name, value, explicitly_set):
""" """
......
...@@ -9,3 +9,11 @@ Feature: Course export ...@@ -9,3 +9,11 @@ Feature: Course export
And I export the course And I export the course
Then I get an error dialog Then I get an error dialog
And I can click to go to the unit with the error And I can click to go to the unit with the error
Scenario: User is directed to problem with & in it when export fails
Given I am in Studio editing a new unit
When I add a "Blank Advanced Problem" "Advanced Problem" component
And I edit and enter an ampersand
And I export the course
Then I get an error dialog
And I can click to go to the unit with the error
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
#pylint: disable=C0111 #pylint: disable=C0111
from lettuce import world, step from lettuce import world, step
from common import type_in_codemirror from component_settings_editor_helpers import enter_xml_in_advanced_problem
from nose.tools import assert_true, assert_equal from nose.tools import assert_true, assert_equal
...@@ -16,9 +16,7 @@ def i_export_the_course(step): ...@@ -16,9 +16,7 @@ def i_export_the_course(step):
@step('I edit and enter bad XML$') @step('I edit and enter bad XML$')
def i_enter_bad_xml(step): def i_enter_bad_xml(step):
world.edit_component() enter_xml_in_advanced_problem(step,
type_in_codemirror(
0,
"""<problem><h1>Smallest Canvas</h1> """<problem><h1>Smallest Canvas</h1>
<p>You want to make the smallest canvas you can.</p> <p>You want to make the smallest canvas you can.</p>
<multiplechoiceresponse> <multiplechoiceresponse>
...@@ -29,7 +27,11 @@ def i_enter_bad_xml(step): ...@@ -29,7 +27,11 @@ def i_enter_bad_xml(step):
</multiplechoiceresponse> </multiplechoiceresponse>
</problem>""" </problem>"""
) )
world.save_component(step)
@step('I edit and enter an ampersand$')
def i_enter_bad_xml(step):
enter_xml_in_advanced_problem(step, "<problem>&</problem>")
@step('I get an error dialog$') @step('I get an error dialog$')
......
...@@ -24,11 +24,21 @@ from xmodule.modulestore.mongo.base import location_to_query ...@@ -24,11 +24,21 @@ from xmodule.modulestore.mongo.base import location_to_query
class AssetsTestCase(CourseTestCase): class AssetsTestCase(CourseTestCase):
"""
Parent class for all asset tests.
"""
def setUp(self): def setUp(self):
super(AssetsTestCase, self).setUp() super(AssetsTestCase, self).setUp()
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True) location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.url = location.url_reverse('assets/', '') self.url = location.url_reverse('assets/', '')
def upload_asset(self, name="asset-1"):
f = BytesIO(name)
f.name = name + ".txt"
return self.client.post(self.url, {"name": name, "file": f})
class BasicAssetsTestCase(AssetsTestCase):
def test_basic(self): def test_basic(self):
resp = self.client.get(self.url, HTTP_ACCEPT='text/html') resp = self.client.get(self.url, HTTP_ACCEPT='text/html')
self.assertEquals(resp.status_code, 200) self.assertEquals(resp.status_code, 200)
...@@ -38,12 +48,7 @@ class AssetsTestCase(CourseTestCase): ...@@ -38,12 +48,7 @@ class AssetsTestCase(CourseTestCase):
path = StaticContent.get_static_path_from_location(location) path = StaticContent.get_static_path_from_location(location)
self.assertEquals(path, '/static/my_file_name.jpg') self.assertEquals(path, '/static/my_file_name.jpg')
def test_pdf_asset(self):
class AssetsToyCourseTestCase(CourseTestCase):
"""
Tests the assets returned from assets_handler for the toy test course.
"""
def test_toy_assets(self):
module_store = modulestore('direct') module_store = modulestore('direct')
_, course_items = import_from_xml( _, course_items = import_from_xml(
module_store, module_store,
...@@ -56,9 +61,35 @@ class AssetsToyCourseTestCase(CourseTestCase): ...@@ -56,9 +61,35 @@ class AssetsToyCourseTestCase(CourseTestCase):
location = loc_mapper().translate_location(course.location.course_id, course.location, False, True) location = loc_mapper().translate_location(course.location.course_id, course.location, False, True)
url = location.url_reverse('assets/', '') url = location.url_reverse('assets/', '')
self.assert_correct_asset_response(url, 0, 3, 3) # Test valid contentType for pdf asset (textbook.pdf)
self.assert_correct_asset_response(url + "?page_size=2", 0, 2, 3) resp = self.client.get(url, HTTP_ACCEPT='application/json')
self.assert_correct_asset_response(url + "?page_size=2&page=1", 2, 1, 3) self.assertContains(resp, "/c4x/edX/toy/asset/textbook.pdf")
asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/textbook.pdf')
content = contentstore().find(asset_location)
# Check after import textbook.pdf has valid contentType ('application/pdf')
# Note: Actual contentType for textbook.pdf in asset.json is 'text/pdf'
self.assertEqual(content.content_type, 'application/pdf')
class PaginationTestCase(AssetsTestCase):
"""
Tests the pagination of assets returned from the REST API.
"""
def test_json_responses(self):
self.upload_asset("asset-1")
self.upload_asset("asset-2")
self.upload_asset("asset-3")
# Verify valid page requests
self.assert_correct_asset_response(self.url, 0, 3, 3)
self.assert_correct_asset_response(self.url + "?page_size=2", 0, 2, 3)
self.assert_correct_asset_response(self.url + "?page_size=2&page=1", 2, 1, 3)
# Verify querying outside the range of valid pages
self.assert_correct_asset_response(self.url + "?page_size=2&page=-1", 0, 2, 3)
self.assert_correct_asset_response(self.url + "?page_size=2&page=2", 2, 1, 3)
self.assert_correct_asset_response(self.url + "?page_size=3&page=1", 0, 3, 3)
def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total): def assert_correct_asset_response(self, url, expected_start, expected_length, expected_total):
resp = self.client.get(url, HTTP_ACCEPT='application/json') resp = self.client.get(url, HTTP_ACCEPT='application/json')
...@@ -69,7 +100,7 @@ class AssetsToyCourseTestCase(CourseTestCase): ...@@ -69,7 +100,7 @@ class AssetsToyCourseTestCase(CourseTestCase):
self.assertEquals(json_response['totalCount'], expected_total) self.assertEquals(json_response['totalCount'], expected_total)
class UploadTestCase(CourseTestCase): class UploadTestCase(AssetsTestCase):
""" """
Unit tests for uploading a file Unit tests for uploading a file
""" """
...@@ -78,11 +109,8 @@ class UploadTestCase(CourseTestCase): ...@@ -78,11 +109,8 @@ class UploadTestCase(CourseTestCase):
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True) location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.url = location.url_reverse('assets/', '') self.url = location.url_reverse('assets/', '')
@skip("CorruptGridFile error on continuous integration server")
def test_happy_path(self): def test_happy_path(self):
f = BytesIO("sample content") resp = self.upload_asset()
f.name = "sample.txt"
resp = self.client.post(self.url, {"name": "my-name", "file": f})
self.assertEquals(resp.status_code, 200) self.assertEquals(resp.status_code, 200)
def test_no_file(self): def test_no_file(self):
...@@ -90,7 +118,7 @@ class UploadTestCase(CourseTestCase): ...@@ -90,7 +118,7 @@ class UploadTestCase(CourseTestCase):
self.assertEquals(resp.status_code, 400) self.assertEquals(resp.status_code, 400)
class AssetToJsonTestCase(TestCase): class AssetToJsonTestCase(AssetsTestCase):
""" """
Unit test for transforming asset information into something Unit test for transforming asset information into something
we can send out to the client via JSON. we can send out to the client via JSON.
...@@ -115,7 +143,7 @@ class AssetToJsonTestCase(TestCase): ...@@ -115,7 +143,7 @@ class AssetToJsonTestCase(TestCase):
self.assertIsNone(output["thumbnail"]) self.assertIsNone(output["thumbnail"])
class LockAssetTestCase(CourseTestCase): class LockAssetTestCase(AssetsTestCase):
""" """
Unit test for locking and unlocking an asset. Unit test for locking and unlocking an asset.
""" """
......
"""Tests for items views.""" """Tests for items views."""
import json import json
import datetime from datetime import datetime
import ddt import ddt
from mock import Mock, patch from mock import Mock, patch
...@@ -149,6 +149,13 @@ class TestCreateItem(ItemTest): ...@@ -149,6 +149,13 @@ class TestCreateItem(ItemTest):
resp = self.create_xblock(category='problem', boilerplate='nosuchboilerplate.yaml') resp = self.create_xblock(category='problem', boilerplate='nosuchboilerplate.yaml')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def test_create_with_future_date(self):
self.assertEqual(self.course.start, datetime(2030, 1, 1, tzinfo=UTC))
resp = self.create_xblock(category='chapter')
locator = self.response_locator(resp)
obj = self.get_item_from_modulestore(locator)
self.assertEqual(obj.start, datetime(2030, 1, 1, tzinfo=UTC))
class TestEditItem(ItemTest): class TestEditItem(ItemTest):
""" """
...@@ -214,14 +221,14 @@ class TestEditItem(ItemTest): ...@@ -214,14 +221,14 @@ class TestEditItem(ItemTest):
data={'metadata': {'due': '2010-11-22T04:00Z'}} data={'metadata': {'due': '2010-11-22T04:00Z'}}
) )
sequential = self.get_item_from_modulestore(self.seq_locator) sequential = self.get_item_from_modulestore(self.seq_locator)
self.assertEqual(sequential.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.client.ajax_post( self.client.ajax_post(
self.seq_update_url, self.seq_update_url,
data={'metadata': {'start': '2010-09-12T14:00Z'}} data={'metadata': {'start': '2010-09-12T14:00Z'}}
) )
sequential = self.get_item_from_modulestore(self.seq_locator) sequential = self.get_item_from_modulestore(self.seq_locator)
self.assertEqual(sequential.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(sequential.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) self.assertEqual(sequential.start, datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
def test_delete_child(self): def test_delete_child(self):
""" """
...@@ -326,7 +333,7 @@ class TestEditItem(ItemTest): ...@@ -326,7 +333,7 @@ class TestEditItem(ItemTest):
published = self.get_item_from_modulestore(self.problem_locator, False) published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due) self.assertIsNone(published.due)
draft = self.get_item_from_modulestore(self.problem_locator, True) draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_public_with_update(self): def test_make_public_with_update(self):
""" Update a problem and make it public at the same time. """ """ Update a problem and make it public at the same time. """
...@@ -338,7 +345,7 @@ class TestEditItem(ItemTest): ...@@ -338,7 +345,7 @@ class TestEditItem(ItemTest):
} }
) )
published = self.get_item_from_modulestore(self.problem_locator, False) published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertEqual(published.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_make_private_with_update(self): def test_make_private_with_update(self):
""" Make a problem private and update it at the same time. """ """ Make a problem private and update it at the same time. """
...@@ -357,7 +364,7 @@ class TestEditItem(ItemTest): ...@@ -357,7 +364,7 @@ class TestEditItem(ItemTest):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False) self.get_item_from_modulestore(self.problem_locator, False)
draft = self.get_item_from_modulestore(self.problem_locator, True) draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
def test_create_draft_with_update(self): def test_create_draft_with_update(self):
""" Create a draft and update it at the same time. """ """ Create a draft and update it at the same time. """
...@@ -378,7 +385,7 @@ class TestEditItem(ItemTest): ...@@ -378,7 +385,7 @@ class TestEditItem(ItemTest):
published = self.get_item_from_modulestore(self.problem_locator, False) published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due) self.assertIsNone(published.due)
draft = self.get_item_from_modulestore(self.problem_locator, True) draft = self.get_item_from_modulestore(self.problem_locator, True)
self.assertEqual(draft.due, datetime.datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
@ddt.ddt @ddt.ddt
......
...@@ -72,7 +72,7 @@ class CourseTestCase(ModuleStoreTestCase): ...@@ -72,7 +72,7 @@ class CourseTestCase(ModuleStoreTestCase):
email = 'test+courses@edx.org' email = 'test+courses@edx.org'
password = 'foo' password = 'foo'
# Create the use so we can log them in. # Create the user so we can log them in.
self.user = User.objects.create_user(uname, email, password) self.user = User.objects.create_user(uname, email, password)
# Note that we do not actually need to do anything # Note that we do not actually need to do anything
......
...@@ -27,7 +27,7 @@ from django.http import HttpResponseNotFound ...@@ -27,7 +27,7 @@ from django.http import HttpResponseNotFound
import json import json
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from pymongo import DESCENDING from pymongo import DESCENDING
import math
__all__ = ['assets_handler'] __all__ = ['assets_handler']
...@@ -91,17 +91,20 @@ def _assets_json(request, location): ...@@ -91,17 +91,20 @@ def _assets_json(request, location):
""" """
requested_page = int(request.REQUEST.get('page', 0)) requested_page = int(request.REQUEST.get('page', 0))
requested_page_size = int(request.REQUEST.get('page_size', 50)) requested_page_size = int(request.REQUEST.get('page_size', 50))
sort = [('uploadDate', DESCENDING)]
current_page = max(requested_page, 0) current_page = max(requested_page, 0)
start = current_page * requested_page_size start = current_page * requested_page_size
assets, total_count = _get_assets_for_page(request, location, current_page, requested_page_size, sort)
old_location = loc_mapper().translate_locator_to_location(location)
course_reference = StaticContent.compute_location(old_location.org, old_location.course, old_location.name)
assets, total_count = contentstore().get_all_content_for_course(
course_reference, start=start, maxresults=requested_page_size, sort=[('uploadDate', DESCENDING)]
)
end = start + len(assets) end = start + len(assets)
# If the query is beyond the final page, then re-query the final page so that at least one asset is returned
if requested_page > 0 and start >= total_count:
current_page = int(math.floor((total_count - 1) / requested_page_size))
start = current_page * requested_page_size
assets, total_count = _get_assets_for_page(request, location, current_page, requested_page_size, sort)
end = start + len(assets)
asset_json = [] asset_json = []
for asset in assets: for asset in assets:
asset_id = asset['_id'] asset_id = asset['_id']
...@@ -123,6 +126,20 @@ def _assets_json(request, location): ...@@ -123,6 +126,20 @@ def _assets_json(request, location):
}) })
def _get_assets_for_page(request, location, current_page, page_size, sort):
"""
Returns the list of assets for the specified page and page size.
"""
start = current_page * page_size
old_location = loc_mapper().translate_locator_to_location(location)
course_reference = StaticContent.compute_location(old_location.org, old_location.course, old_location.name)
return contentstore().get_all_content_for_course(
course_reference, start=start, maxresults=page_size, sort=sort
)
@require_POST @require_POST
@ensure_csrf_cookie @ensure_csrf_cookie
@login_required @login_required
......
...@@ -94,7 +94,6 @@ require(["domReady!", "gettext", "js/views/feedback_prompt"], function(doc, gett ...@@ -94,7 +94,6 @@ require(["domReady!", "gettext", "js/views/feedback_prompt"], function(doc, gett
<h2 class="title">${_("About Exporting Courses")}</h2> <h2 class="title">${_("About Exporting Courses")}</h2>
<div class="copy"> <div class="copy">
## Translators: ".tar.gz" is a file extension, and should not be translated ## Translators: ".tar.gz" is a file extension, and should not be translated
<p>${_("You can export courses and edit them outside of Studio. The exported file is a .tar.gz file (that is, a .tar file compressed with GNU Zip) that contains the course structure and content. You can also re-import courses that you've exported.").format(em_start='<strong>', em_end="</strong>")}</p> <p>${_("You can export courses and edit them outside of Studio. The exported file is a .tar.gz file (that is, a .tar file compressed with GNU Zip) that contains the course structure and content. You can also re-import courses that you've exported.").format(em_start='<strong>', em_end="</strong>")}</p>
</div> </div>
</div> </div>
......
...@@ -126,20 +126,16 @@ ...@@ -126,20 +126,16 @@
<aside class="content-supplementary" role="complimentary"> <aside class="content-supplementary" role="complimentary">
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("Why import a course?")}</h3> <h3 class="title-3">${_("Why import a course?")}</h3>
## Translators: ".tar.gz" is a file extension, and files with that extension are called "gzipped tar files": these terms should not be translated
<p>${_("You may want to run a new version of an existing course, or replace an existing course altogether. Or, you may have developed a course outside Studio.")}</p> <p>${_("You may want to run a new version of an existing course, or replace an existing course altogether. Or, you may have developed a course outside Studio.")}</p>
</div> </div>
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("What content is imported?")}</h3> <h3 class="title-3">${_("What content is imported?")}</h3>
## Translators: ".tar.gz" is a file extension, and files with that extension are called "gzipped tar files": these terms should not be translated
<p>${_("Only the course content and structure (including sections, subsections, and units) are imported. Other data, including student data, grading information, discussion forum data, course settings, and course team information, remains the same as it was in the existing course.")}</p> <p>${_("Only the course content and structure (including sections, subsections, and units) are imported. Other data, including student data, grading information, discussion forum data, course settings, and course team information, remains the same as it was in the existing course.")}</p>
</div> </div>
<div class="bit"> <div class="bit">
## Translators: ".tar.gz" is a file extension, and should not be translated
<h3 class="title-3">${_("Warning: Importing while a course is running")}</h3> <h3 class="title-3">${_("Warning: Importing while a course is running")}</h3>
<p>${_("If you perform an import while your course is running, and you change the URL names (or url_name nodes) of any Problem components, the student data associated with those Problem components may be lost. This data includes students' problem scores.")}</p> <p>${_("If you perform an import while your course is running, and you change the URL names (or url_name nodes) of any Problem components, the student data associated with those Problem components may be lost. This data includes students' problem scores.")}</p>
</div> </div>
</aside> </aside>
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<h1>Check your email</h1> <h1>Check your email</h1>
<p>${_("An activation link has been sent to {emaiL}, along with instructions for activating your account.").format(email=email)}</p> <p>${_("An activation link has been sent to {email}, along with instructions for activating your account.").format(email=email)}</p>
...@@ -164,9 +164,7 @@ class CourseFields(object): ...@@ -164,9 +164,7 @@ class CourseFields(object):
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
start = Date(help="Start time when this module is visible", start = Date(help="Start time when this module is visible",
# using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the default=datetime(2030, 1, 1, tzinfo=UTC()),
# time of first invocation of this stmt on the server
default=datetime.fromtimestamp(0, UTC()),
scope=Scope.settings) scope=Scope.settings)
end = Date(help="Date that this class ends", scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings)
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
......
...@@ -160,7 +160,10 @@ class @Annotatable ...@@ -160,7 +160,10 @@ class @Annotatable
@hideTips visible @hideTips visible
toggleAnnotationButtonText: (hide) -> toggleAnnotationButtonText: (hide) ->
buttonText = (if hide then 'Show' else 'Hide')+' Annotations' if hide
buttonText = gettext('Show Annotations')
else
buttonText = gettext('Hide Annotations')
@$(@toggleAnnotationsSelector).text(buttonText) @$(@toggleAnnotationsSelector).text(buttonText)
toggleInstructions: () -> toggleInstructions: () ->
...@@ -169,7 +172,10 @@ class @Annotatable ...@@ -169,7 +172,10 @@ class @Annotatable
@toggleInstructionsText hide @toggleInstructionsText hide
toggleInstructionsButton: (hide) -> toggleInstructionsButton: (hide) ->
txt = (if hide then 'Expand' else 'Collapse')+' Instructions' if hide
txt = gettext('Expand Instructions')
else
txt = gettext('Collapse Instructions')
cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded']) cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded'])
@$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1]) @$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1])
...@@ -221,13 +227,14 @@ class @Annotatable ...@@ -221,13 +227,14 @@ class @Annotatable
makeTipTitle: (el) -> makeTipTitle: (el) ->
(api) => (api) =>
title = $(el).data('comment-title') title = $(el).data('comment-title')
(if title then title else 'Commentary') (if title then title else gettext('Commentary'))
createComment: (text) -> createComment: (text) ->
$("<div class=\"annotatable-comment\">#{text}</div>") $("<div class=\"annotatable-comment\">#{text}</div>")
createReplyLink: (problem_id) -> createReplyLink: (problem_id) ->
$("<a class=\"annotatable-reply\" href=\"javascript:void(0);\" data-problem-id=\"#{problem_id}\">Reply to Annotation</a>") linktxt = gettext('Reply to Annotation')
$("<a class=\"annotatable-reply\" href=\"javascript:void(0);\" data-problem-id=\"#{problem_id}\">#{linktxt}</a>")
findVisibleTips: () -> findVisibleTips: () ->
visible = [] visible = []
......
...@@ -318,14 +318,16 @@ class @Problem ...@@ -318,14 +318,16 @@ class @Problem
@el.find('.problem > div').each (index, element) => @el.find('.problem > div').each (index, element) =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element] MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
@$('.show-label').text 'Hide Answer(s)' `// Translators: the word Answer here refers to the answer to a problem the student must solve.`
@$('.show-label').text gettext('Hide Answer(s)')
@el.addClass 'showed' @el.addClass 'showed'
@updateProgress response @updateProgress response
else else
@$('[id^=answer_], [id^=solution_]').text '' @$('[id^=answer_], [id^=solution_]').text ''
@$('[correct_answer]').attr correct_answer: null @$('[correct_answer]').attr correct_answer: null
@el.removeClass 'showed' @el.removeClass 'showed'
@$('.show-label').text 'Show Answer(s)' `// Translators: the word Answer here refers to the answer to a problem the student must solve.`
@$('.show-label').text gettext('Show Answer(s)')
@el.find(".capa_inputtype").each (index, inputtype) => @el.find(".capa_inputtype").each (index, inputtype) =>
display = @inputtypeDisplays[$(inputtype).attr('id')] display = @inputtypeDisplays[$(inputtype).attr('id')]
...@@ -403,6 +405,7 @@ class @Problem ...@@ -403,6 +405,7 @@ class @Problem
formulaequationinput: (element) -> formulaequationinput: (element) ->
$(element).find('input').on 'input', -> $(element).find('input').on 'input', ->
$p = $(element).find('p.status') $p = $(element).find('p.status')
`// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.text gettext("unanswered") $p.text gettext("unanswered")
$p.parent().removeClass().addClass "unanswered" $p.parent().removeClass().addClass "unanswered"
...@@ -431,7 +434,8 @@ class @Problem ...@@ -431,7 +434,8 @@ class @Problem
textline: (element) -> textline: (element) ->
$(element).find('input').on 'input', -> $(element).find('input').on 'input', ->
$p = $(element).find('p.status') $p = $(element).find('p.status')
$p.text "unanswered" `// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.text gettext("unanswered")
$p.parent().removeClass().addClass "unanswered" $p.parent().removeClass().addClass "unanswered"
inputtypeSetupMethods: inputtypeSetupMethods:
......
...@@ -17,7 +17,7 @@ class InheritanceMixin(XBlockMixin): ...@@ -17,7 +17,7 @@ class InheritanceMixin(XBlockMixin):
start = Date( start = Date(
help="Start time when this module is visible", help="Start time when this module is visible",
default=datetime.fromtimestamp(0, UTC), default=datetime(2030, 1, 1, tzinfo=UTC),
scope=Scope.settings scope=Scope.settings
) )
due = Date(help="Date that this problem is due by", scope=Scope.settings) due = Date(help="Date that this problem is due by", scope=Scope.settings)
......
...@@ -55,8 +55,6 @@ class CourseFactory(XModuleFactory): ...@@ -55,8 +55,6 @@ class CourseFactory(XModuleFactory):
# Write the data to the mongo datastore # Write the data to the mongo datastore
new_course = store.create_xmodule(location, metadata=kwargs.get('metadata', None)) new_course = store.create_xmodule(location, metadata=kwargs.get('metadata', None))
new_course.start = datetime.datetime.now(UTC).replace(microsecond=0)
# The rest of kwargs become attributes on the course: # The rest of kwargs become attributes on the course:
for k, v in kwargs.iteritems(): for k, v in kwargs.iteritems():
setattr(new_course, k, v) setattr(new_course, k, v)
......
...@@ -33,6 +33,7 @@ def import_static_content( ...@@ -33,6 +33,7 @@ def import_static_content(
policy = {} policy = {}
verbose = True verbose = True
mimetypes_list = mimetypes.types_map.values()
for dirname, _, filenames in os.walk(static_dir): for dirname, _, filenames in os.walk(static_dir):
for filename in filenames: for filename in filenames:
...@@ -64,10 +65,11 @@ def import_static_content( ...@@ -64,10 +65,11 @@ def import_static_content(
policy_ele = policy.get(content_loc.name, {}) policy_ele = policy.get(content_loc.name, {})
displayname = policy_ele.get('displayname', filename) displayname = policy_ele.get('displayname', filename)
locked = policy_ele.get('locked', False) locked = policy_ele.get('locked', False)
mime_type = policy_ele.get( mime_type = policy_ele.get('contentType')
'contentType',
mimetypes.guess_type(filename)[0] # Check extracted contentType in list of all valid mimetypes
) if not mime_type or mime_type not in mimetypes_list:
mime_type = mimetypes.guess_type(filename)[0] # Assign guessed mimetype
content = StaticContent( content = StaticContent(
content_loc, displayname, mime_type, data, content_loc, displayname, mime_type, data,
import_path=fullname_with_subpath, locked=locked import_path=fullname_with_subpath, locked=locked
......
import unittest import unittest
import datetime from datetime import datetime
from fs.memoryfs import MemoryFS from fs.memoryfs import MemoryFS
...@@ -13,7 +13,15 @@ from django.utils.timezone import UTC ...@@ -13,7 +13,15 @@ from django.utils.timezone import UTC
ORG = 'test_org' ORG = 'test_org'
COURSE = 'test_course' COURSE = 'test_course'
NOW = datetime.datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=UTC()) NOW = datetime.strptime('2013-01-01T01:00:00', '%Y-%m-%dT%H:%M:00').replace(tzinfo=UTC())
class CourseFieldsTestCase(unittest.TestCase):
def test_default_start_date(self):
self.assertEqual(
xmodule.course_module.CourseFields.start.default,
datetime(2030, 1, 1, tzinfo=UTC())
)
class DummySystem(ImportSystem): class DummySystem(ImportSystem):
...@@ -77,7 +85,7 @@ class IsNewCourseTestCase(unittest.TestCase): ...@@ -77,7 +85,7 @@ class IsNewCourseTestCase(unittest.TestCase):
# Needed for test_is_newish # Needed for test_is_newish
datetime_patcher = patch.object( datetime_patcher = patch.object(
xmodule.course_module, 'datetime', xmodule.course_module, 'datetime',
Mock(wraps=datetime.datetime) Mock(wraps=datetime)
) )
mocked_datetime = datetime_patcher.start() mocked_datetime = datetime_patcher.start()
mocked_datetime.now.return_value = NOW mocked_datetime.now.return_value = NOW
......
...@@ -228,9 +228,11 @@ class ImportTestCase(BaseCourseTestCase): ...@@ -228,9 +228,11 @@ class ImportTestCase(BaseCourseTestCase):
# Check that the child does not inherit a value for due # Check that the child does not inherit a value for due
child = descriptor.get_children()[0] child = descriptor.get_children()[0]
self.assertEqual(child.due, None) self.assertEqual(child.due, None)
# Check that the child hasn't started yet
self.assertLessEqual( self.assertLessEqual(
child.start, datetime.datetime.now(UTC()),
datetime.datetime.now(UTC()) child.start
) )
def test_metadata_override_default(self): def test_metadata_override_default(self):
......
describe "DiscussionContentView", -> describe "DiscussionContentView", ->
beforeEach -> beforeEach ->
setFixtures setFixtures(
(
""" """
<div class="discussion-post"> <div class="discussion-post">
<header> <header>
<a data-tooltip="vote" data-role="discussion-vote" class="vote-btn discussion-vote discussion-vote-up" href="#"> <a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon">+</span> <span class="votes-count-number">0</span></a> <span class="plus-icon"/><span class='votes-count-number'>0</span> <span class="sr">votes (click to vote)</span></a>
<h1>Post Title</h1> <h1>Post Title</h1>
<p class="posted-details"> <p class="posted-details">
<a class="username" href="/courses/MITx/999/Robot_Super_Course/discussion/forum/users/1">robot</a> <a class="username" href="/courses/MITx/999/Robot_Super_Course/discussion/forum/users/1">robot</a>
...@@ -23,16 +22,21 @@ describe "DiscussionContentView", -> ...@@ -23,16 +22,21 @@ describe "DiscussionContentView", ->
""" """
) )
@thread = new Thread { @threadData = {
id: '01234567', id: '01234567',
user_id: '567', user_id: '567',
course_id: 'mitX/999/test', course_id: 'mitX/999/test',
body: 'this is a thread', body: 'this is a thread',
created_at: '2013-04-03T20:08:39Z', created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123'] abuse_flaggers: ['123'],
roles: [] votes: {up_count: '42'},
type: "thread",
roles: []
} }
@thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread }) @view = new DiscussionContentView({ model: @thread })
@view.setElement($('.discussion-post'))
window.user = new DiscussionUser({id: '567', upvoted_ids: []})
it 'defines the tag', -> it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist expect($('#jasmine-fixtures')).toExist
...@@ -56,3 +60,15 @@ describe "DiscussionContentView", -> ...@@ -56,3 +60,15 @@ describe "DiscussionContentView", ->
@thread.set("abuse_flaggers",temp_array) @thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse() @thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual [] expect(@thread.get 'abuse_flaggers').toEqual []
it 'renders the vote button properly', ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it 'votes correctly', ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, false)
it 'unvotes correctly', ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, false)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
describe "DiscussionThreadProfileView", ->
beforeEach ->
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
</div>
"""
)
@threadData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a thread",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: "42"}
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadProfileView({ model: @thread })
@view.setElement($(".discussion-post"))
window.user = new DiscussionUser({id: "567", upvoted_ids: []})
it "renders the vote correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, true)
it "unvotes correctly", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, true)
it "toggles the vote correctly", ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
describe "DiscussionThreadShowView", ->
beforeEach ->
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
</div>
"""
)
@threadData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a thread",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: "42"}
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadShowView({ model: @thread })
@view.setElement($(".discussion-post"))
window.user = new DiscussionUser({id: "567", upvoted_ids: []})
it "renders the vote correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @thread, @threadData, true)
it "unvotes correctly", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @threadData, true)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @thread)
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
class @DiscussionViewSpecHelper
@expectVoteRendered = (view, voted) ->
button = view.$el.find(".vote-btn")
if voted
expect(button.hasClass("is-cast")).toBe(true)
expect(button.attr("aria-pressed")).toEqual("true")
expect(button.attr("data-tooltip")).toEqual("remove vote")
expect(button.find(".votes-count-number").html()).toEqual("43")
expect(button.find(".sr").html()).toEqual("votes (click to remove your vote)")
else
expect(button.hasClass("is-cast")).toBe(false)
expect(button.attr("aria-pressed")).toEqual("false")
expect(button.attr("data-tooltip")).toEqual("vote")
expect(button.find(".votes-count-number").html()).toEqual("42")
expect(button.find(".sr").html()).toEqual("votes (click to vote)")
@checkRenderVote = (view, model) ->
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, false)
window.user.vote(model)
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, true)
window.user.unvote(model)
view.renderVote()
DiscussionViewSpecHelper.expectVoteRendered(view, false)
@checkVote = (view, model, modelData, checkRendering) ->
view.renderVote()
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "43"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
)
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.vote()
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
expect($.ajax).toHaveBeenCalled()
@checkUnvote = (view, model, modelData, checkRendering) ->
window.user.vote(model)
expect(window.user.voted(model)).toBe(true)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, true)
spyOn($, "ajax").andCallFake((params) =>
newModelData = {}
$.extend(newModelData, modelData, {votes: {up_count: "42"}})
params.success(newModelData, "success")
# Caller invokes always function on return value but it doesn't matter here
{always: ->}
)
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
$.ajax.reset()
# Check idempotence
view.unvote()
expect(window.user.voted(model)).toBe(false)
if checkRendering
DiscussionViewSpecHelper.expectVoteRendered(view, false)
expect($.ajax).toHaveBeenCalled()
@checkToggleVote = (view, model) ->
event = {preventDefault: ->}
spyOn(event, "preventDefault")
spyOn(view, "vote").andCallFake(() -> window.user.vote(model))
spyOn(view, "unvote").andCallFake(() -> window.user.unvote(model))
expect(window.user.voted(model)).toBe(false)
view.toggleVote(event)
expect(view.vote).toHaveBeenCalled()
expect(view.unvote).not.toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(1)
view.vote.reset()
view.unvote.reset()
expect(window.user.voted(model)).toBe(true)
view.toggleVote(event)
expect(view.vote).not.toHaveBeenCalled()
expect(view.unvote).toHaveBeenCalled()
expect(event.preventDefault.callCount).toEqual(2)
@checkVoteButtonEvents = (view) ->
spyOn(view, "toggleVote")
button = view.$el.find(".vote-btn")
button.click()
expect(view.toggleVote).toHaveBeenCalled()
view.toggleVote.reset()
button.trigger($.Event("keydown", {which: 13}))
expect(view.toggleVote).toHaveBeenCalled()
view.toggleVote.reset()
button.trigger($.Event("keydown", {which: 32}))
expect(view.toggleVote).not.toHaveBeenCalled()
describe "ThreadResponseShowView", ->
beforeEach ->
setFixtures(
"""
<div class="discussion-post">
<a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon"/><span class="votes-count-number">0</span> <span class="sr">votes (click to vote)</span>
</a>
</div>
"""
)
@commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: "42"}
}
@comment = new Comment(@commentData)
@view = new ThreadResponseShowView({ model: @comment })
@view.setElement($(".discussion-post"))
window.user = new DiscussionUser({id: "567", upvoted_ids: []})
it "renders the vote correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @comment)
it "votes correctly", ->
DiscussionViewSpecHelper.checkVote(@view, @comment, @commentData, true)
it "unvotes correctly", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @commentData, true)
it 'toggles the vote correctly', ->
DiscussionViewSpecHelper.checkToggleVote(@view, @comment)
it "vote button activates on appropriate events", ->
DiscussionViewSpecHelper.checkVoteButtonEvents(@view)
...@@ -99,6 +99,13 @@ if Backbone? ...@@ -99,6 +99,13 @@ if Backbone?
@get("abuse_flaggers").pop(window.user.get('id')) @get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @ @trigger "change", @
vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1
@trigger "change", @
unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1
@trigger "change", @
class @Thread extends @Content class @Thread extends @Content
urlMappers: urlMappers:
...@@ -130,14 +137,6 @@ if Backbone? ...@@ -130,14 +137,6 @@ if Backbone?
unfollow: -> unfollow: ->
@set('subscribed', false) @set('subscribed', false)
vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1
@trigger "change", @
unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1
@trigger "change", @
display_body: -> display_body: ->
if @has("highlighted_body") if @has("highlighted_body")
String(@get("highlighted_body")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>') String(@get("highlighted_body")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
......
...@@ -91,7 +91,7 @@ class @DiscussionUtil ...@@ -91,7 +91,7 @@ class @DiscussionUtil
@activateOnEnter: (event, func) -> @activateOnEnter: (event, func) ->
if event.which == 13 if event.which == 13
e.preventDefault() event.preventDefault()
func(event) func(event)
@makeFocusTrap: (elem) -> @makeFocusTrap: (elem) ->
......
...@@ -159,3 +159,42 @@ if Backbone? ...@@ -159,3 +159,42 @@ if Backbone?
temp_array = [] temp_array = []
@model.set('abuse_flaggers', temp_array) @model.set('abuse_flaggers', temp_array)
renderVote: =>
button = @$el.find(".vote-btn")
voted = window.user.voted(@model)
voteNum = @model.get("votes")["up_count"]
button.toggleClass("is-cast", voted)
button.attr("aria-pressed", voted)
button.attr("data-tooltip", if voted then "remove vote" else "vote")
button.find(".votes-count-number").html(voteNum)
button.find(".sr").html(if voted then "votes (click to remove your vote)" else "votes (click to vote)")
toggleVote: (event) =>
event.preventDefault()
if window.user.voted(@model)
@unvote()
else
@vote()
vote: =>
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$el.find(".vote-btn")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
unvote: =>
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$el.find(".vote-btn")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
...@@ -2,7 +2,10 @@ if Backbone? ...@@ -2,7 +2,10 @@ if Backbone?
class @DiscussionThreadProfileView extends DiscussionContentView class @DiscussionThreadProfileView extends DiscussionContentView
expanded = false expanded = false
events: events:
"click .discussion-vote": "toggleVote" "click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnEnter(event, @toggleVote)
"click .action-follow": "toggleFollowing" "click .action-follow": "toggleFollowing"
"keypress .action-follow": "keypress .action-follow":
(event) -> DiscussionUtil.activateOnEnter(event, toggleFollowing) (event) -> DiscussionUtil.activateOnEnter(event, toggleFollowing)
...@@ -27,7 +30,7 @@ if Backbone? ...@@ -27,7 +30,7 @@ if Backbone?
@$el.html(Mustache.render(@template, params)) @$el.html(Mustache.render(@template, params))
@initLocal() @initLocal()
@delegateEvents() @delegateEvents()
@renderVoted() @renderVote()
@renderAttrs() @renderAttrs()
@$("span.timeago").timeago() @$("span.timeago").timeago()
@convertMath() @convertMath()
...@@ -35,15 +38,8 @@ if Backbone? ...@@ -35,15 +38,8 @@ if Backbone?
@renderResponses() @renderResponses()
@ @
renderVoted: =>
if window.user.voted(@model)
@$("[data-role=discussion-vote]").addClass("is-cast")
else
@$("[data-role=discussion-vote]").removeClass("is-cast")
updateModelDetails: => updateModelDetails: =>
@renderVoted() @renderVote()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"])
convertMath: -> convertMath: ->
element = @$(".post-body") element = @$(".post-body")
...@@ -71,35 +67,6 @@ if Backbone? ...@@ -71,35 +67,6 @@ if Backbone?
addComment: => addComment: =>
@model.comment() @model.comment()
toggleVote: (event) ->
event.preventDefault()
if window.user.voted(@model)
@unvote()
else
@vote()
vote: ->
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
unvote: ->
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
edit: -> edit: ->
abbreviateBody: -> abbreviateBody: ->
......
...@@ -2,7 +2,10 @@ if Backbone? ...@@ -2,7 +2,10 @@ if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView class @DiscussionThreadShowView extends DiscussionContentView
events: events:
"click .discussion-vote": "toggleVote" "click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnEnter(event, @toggleVote)
"click .discussion-flag-abuse": "toggleFlagAbuse" "click .discussion-flag-abuse": "toggleFlagAbuse"
"keypress .discussion-flag-abuse": "keypress .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnEnter(event, toggleFlagAbuse) (event) -> DiscussionUtil.activateOnEnter(event, toggleFlagAbuse)
...@@ -28,7 +31,7 @@ if Backbone? ...@@ -28,7 +31,7 @@ if Backbone?
render: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
@renderVoted() @renderVote()
@renderFlagged() @renderFlagged()
@renderPinned() @renderPinned()
@renderAttrs() @renderAttrs()
...@@ -38,14 +41,6 @@ if Backbone? ...@@ -38,14 +41,6 @@ if Backbone?
@highlight @$("h1,h3") @highlight @$("h1,h3")
@ @
renderVoted: =>
if window.user.voted(@model)
@$("[data-role=discussion-vote]").addClass("is-cast")
@$("[data-role=discussion-vote] span.sr").html("votes (click to remove your vote)")
else
@$("[data-role=discussion-vote]").removeClass("is-cast")
@$("[data-role=discussion-vote] span.sr").html("votes (click to vote)")
renderFlagged: => renderFlagged: =>
if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0) if window.user.id in @model.get("abuse_flaggers") or (DiscussionUtil.isFlagModerator and @model.get("abuse_flaggers").length > 0)
@$("[data-role=thread-flag]").addClass("flagged") @$("[data-role=thread-flag]").addClass("flagged")
...@@ -70,52 +65,15 @@ if Backbone? ...@@ -70,52 +65,15 @@ if Backbone?
updateModelDetails: => updateModelDetails: =>
@renderVoted() @renderVote()
@renderFlagged() @renderFlagged()
@renderPinned() @renderPinned()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"] + '<span class ="sr"></span>')
if window.user.voted(@model)
@$("[data-role=discussion-vote] .votes-count-number span.sr").html("votes (click to remove your vote)")
else
@$("[data-role=discussion-vote] .votes-count-number span.sr").html("votes (click to vote)")
convertMath: -> convertMath: ->
element = @$(".post-body") element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text() element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]] MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
toggleVote: (event) ->
event.preventDefault()
if window.user.voted(@model)
@unvote()
else
@vote()
vote: ->
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response, {silent: true})
unvote: ->
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response, {silent: true})
edit: (event) -> edit: (event) ->
@trigger "thread:edit", event @trigger "thread:edit", event
......
if Backbone? if Backbone?
class @ThreadResponseShowView extends DiscussionContentView class @ThreadResponseShowView extends DiscussionContentView
events: events:
"click .vote-btn": "toggleVote" "click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnEnter(event, @toggleVote)
"click .action-endorse": "toggleEndorse" "click .action-endorse": "toggleEndorse"
"click .action-delete": "_delete" "click .action-delete": "_delete"
"click .action-edit": "edit" "click .action-edit": "edit"
...@@ -23,9 +26,7 @@ if Backbone? ...@@ -23,9 +26,7 @@ if Backbone?
render: -> render: ->
@$el.html(@renderTemplate()) @$el.html(@renderTemplate())
@delegateEvents() @delegateEvents()
if window.user.voted(@model) @renderVote()
@$(".vote-btn").addClass("is-cast")
@$(".vote-btn span.sr").html("votes (click to remove your vote)")
@renderAttrs() @renderAttrs()
@renderFlagged() @renderFlagged()
@$el.find(".posted-details").timeago() @$el.find(".posted-details").timeago()
...@@ -46,39 +47,6 @@ if Backbone? ...@@ -46,39 +47,6 @@ if Backbone?
@$el.addClass("community-ta") @$el.addClass("community-ta")
@$el.prepend('<div class="community-ta-banner">Community TA</div>') @$el.prepend('<div class="community-ta-banner">Community TA</div>')
toggleVote: (event) ->
event.preventDefault()
@$(".vote-btn").toggleClass("is-cast")
if @$(".vote-btn").hasClass("is-cast")
@vote()
@$(".vote-btn span.sr").html("votes (click to remove your vote)")
else
@unvote()
@$(".vote-btn span.sr").html("votes (click to vote)")
vote: ->
url = @model.urlFor("upvote")
@$(".votes-count-number").html((parseInt(@$(".votes-count-number").html()) + 1) + '<span class="sr"></span>')
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
unvote: ->
url = @model.urlFor("unvote")
@$(".votes-count-number").html((parseInt(@$(".votes-count-number").html()) - 1)+'<span class="sr"></span>')
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
edit: (event) -> edit: (event) ->
@trigger "response:edit", event @trigger "response:edit", event
...@@ -115,4 +83,5 @@ if Backbone? ...@@ -115,4 +83,5 @@ if Backbone?
@$(".discussion-flag-abuse .flag-label").html("Report Misuse") @$(".discussion-flag-abuse .flag-label").html("Report Misuse")
updateModelDetails: => updateModelDetails: =>
@renderVote()
@renderFlagged() @renderFlagged()
...@@ -34,6 +34,7 @@ lib_paths: ...@@ -34,6 +34,7 @@ lib_paths:
- js/vendor/underscore-min.js - js/vendor/underscore-min.js
- js/vendor/backbone-min.js - js/vendor/backbone-min.js
- js/vendor/jquery.timeago.js - js/vendor/jquery.timeago.js
- js/vendor/URI.min.js
- coffee/src/ajax_prefix.js - coffee/src/ajax_prefix.js
- js/test/add_ajax_prefix.js - js/test/add_ajax_prefix.js
- coffee/src/jquery.immediateDescendents.js - coffee/src/jquery.immediateDescendents.js
......
<b>Lab 2A: Superposition Experiment</b> <b>Lab 2A: Superposition Experiment</b>
<p>Isn't the toy course great?</p> <p>Isn't the toy course great? &</p>
{
"textbook.pdf":{
"contentType":"text/pdf",
"displayname":"textbook.pdf",
"locked":false,
"filename":"/c4x/edx/toy/asset/textbook.pdf",
"import_path":null,
"thumbnail_location":null
}
}
...@@ -8,10 +8,14 @@ Change Log ...@@ -8,10 +8,14 @@ Change Log
============== ================================================================ ============== ================================================================
DATE CHANGE DATE CHANGE
============== ================================================================ ============== ================================================================
12/05/2013 Complete revision of edX Studio documentation and integration 01/01/2014 Updated the chapters :ref:`Organizing Your Course Content` and
:ref:`Testing Your Course` to reflect changes in the Course Outline design.
01/01/2014 Updated the topic :ref:`Add Files to a Course` to reflect addition of
pagination to the Files & Uploads page.
12/10/2013 Added the appendix :ref:`MathJax in Studio`.
12/11/2013 Added the chapter :ref:`Guidelines for Creating Accessible Content`
12/12/2013 Added the edX :ref:`Glossary`
12/05/2013 Complete revision of edX Studio documentation and integration
of edX101 content. of edX101 content.
12/10/2013 Added MathJax appendix
12/11/2013 Added Accessibility chapter
12/12/2013 Added Glossary
============== ================================================================ ============== ================================================================
...@@ -151,6 +151,10 @@ Describe Your Course ...@@ -151,6 +151,10 @@ Describe Your Course
The description of your course appears on the Course Summary page that students see, and includes a course summary, prerequisites, staff information and FAQs. The description of your course appears on the Course Summary page that students see, and includes a course summary, prerequisites, staff information and FAQs.
For courses on edX.org, the description is shown in the course catalog.
On Edge, there is no course catalog and users will not find your course description. You must explicitly invite students to participate in your course for them to find the description.
#. From the **Settings** menu, select **Schedule & Details**. #. From the **Settings** menu, select **Schedule & Details**.
#. Scroll down to the **Introducing Your Course** section, then locate the **Course Overview** field. #. Scroll down to the **Introducing Your Course** section, then locate the **Course Overview** field.
...@@ -232,6 +236,25 @@ To add a file: ...@@ -232,6 +236,25 @@ To add a file:
#. To close the dialog box, click the **x** in the top right corner. #. To close the dialog box, click the **x** in the top right corner.
When you close the dialog box, the new files appear on the **Files & Uploads** page. When you close the dialog box, the new files appear on the **Files & Uploads** page.
==================
Find Files
==================
Files are sorted by the Date Added column, with the most recently added first.
The **Files & Uploads** page lists up to 50 files. If your course has more the 50 files, additional files are listed in other pages.
The range of the files listed on the page, and the total number of files, are shown at the top of the page.
You can navigate through the pages listing files in two ways:
* Use the **<** and **>** buttons at the top and bottom of the list to navigate to the previous and next pages.
* At the bottom of the page, enter the page number to skip to, then tab out of the field:
.. image:: Images/file_pagination.png
================== ==================
Get the File URL Get the File URL
......
...@@ -4,20 +4,20 @@ ...@@ -4,20 +4,20 @@
Working with Problem Components Working with Problem Components
################################ ################################
********* ******************************
Overview Overview of Problem Components
********* ******************************
The problem component allows you to add interactive, automatically The problem component allows you to add interactive, automatically
graded exercises to your course content. You can create many different graded exercises to your course content. You can create many different
types of problems in Studio. types of problems in Studio.
All problems receive a point score, but, by default, problems do not count All problems receive a point score, but, by default, problems do not count
toward a student's grade. If you want the problems to count toward the toward a student's grade. If you want the problems to count toward the
student's grade, change the assignment type of the subsection that contains the student's grade, change the assignment type of the subsection that contains the
problems. problems.
See the following topics: For more information, see the following topics.
* :ref:`Components and the User Interface` * :ref:`Components and the User Interface`
* :ref:`Problem Settings` * :ref:`Problem Settings`
...@@ -74,7 +74,7 @@ All problems on the edX platform have several component parts. ...@@ -74,7 +74,7 @@ All problems on the edX platform have several component parts.
#. **Feedback.** After a student clicks **Check**, all problems return a #. **Feedback.** After a student clicks **Check**, all problems return a
green check mark or a red X. green check mark or a red X.
.. image:: Images/AnatomyofaProblem_Feedback.gif .. image:: Images/AnatomyofaProblem_Feedback.gif
#. **Correct answer.** Most problems require that the instructor specify #. **Correct answer.** Most problems require that the instructor specify
...@@ -92,8 +92,8 @@ All problems on the edX platform have several component parts. ...@@ -92,8 +92,8 @@ All problems on the edX platform have several component parts.
#. **Grading.** The instructor may specify whether a group of problems #. **Grading.** The instructor may specify whether a group of problems
is graded. If a group of problems is graded, a clock icon appears for is graded. If a group of problems is graded, a clock icon appears for
that assignment in the course accordion. that assignment in the course accordion.
.. image:: Images/clock_icon.gif .. image:: Images/clock_icon.gif
#. **Due date.** The date that the problem is due. A problem that is #. **Due date.** The date that the problem is due. A problem that is
...@@ -124,9 +124,9 @@ Studio offers two interfaces for editing problem components: the Simple ...@@ -124,9 +124,9 @@ Studio offers two interfaces for editing problem components: the Simple
Editor and the Advanced Editor. Editor and the Advanced Editor.
- The **Simple Editor** allows you to edit problems visually, without - The **Simple Editor** allows you to edit problems visually, without
having to work with XML. having to work with XML.
- The **Advanced Editor** converts the problem to edX’s XML standard - The **Advanced Editor** converts the problem to edX’s XML standard
and allows you to edit that XML directly. and allows you to edit that XML directly.
.. note:: You can switch at any time from the Simple Editor to the .. note:: You can switch at any time from the Simple Editor to the
Advanced Editor by clicking **Advanced Editor** in the top right corner Advanced Editor by clicking **Advanced Editor** in the top right corner
...@@ -135,11 +135,11 @@ Editor and the Advanced Editor. ...@@ -135,11 +135,11 @@ Editor and the Advanced Editor.
The Simple Editor The Simple Editor
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
The Common Problem templates, including multiple choice, open in the Simple Editor. The The Common Problem templates, including multiple choice, open in the Simple Editor. The
following image shows a multiple choice problem in the Simple Editor. following image shows a multiple choice problem in the Simple Editor.
The Simple Editor includes a toolbar that helps you format the text of your problem. The Simple Editor includes a toolbar that helps you format the text of your problem.
When you select text and then click the formatting buttons, the Simple Editor formats When you select text and then click the formatting buttons, the Simple Editor formats
the text for you automatically. The toolbar buttons are the following: the text for you automatically. The toolbar buttons are the following:
1. Create a level 1 heading. 1. Create a level 1 heading.
...@@ -154,17 +154,17 @@ the text for you automatically. The toolbar buttons are the following: ...@@ -154,17 +154,17 @@ the text for you automatically. The toolbar buttons are the following:
The following image shows a multiple choice problem in the Simple Editor. The following image shows a multiple choice problem in the Simple Editor.
.. image:: Images/MultipleChoice_SimpleEditor.gif .. image:: Images/MultipleChoice_SimpleEditor.gif
.. _Advanced Editor: .. _Advanced Editor:
The Advanced Editor The Advanced Editor
~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~
The **Advanced Editor** opens a problem in XML. The Advanced Problem templates, The **Advanced Editor** opens a problem in XML. The Advanced Problem templates,
such as the circuit schematic builder, open directly in the Advanced Editor. such as the circuit schematic builder, open directly in the Advanced Editor.
For more information about the XML for different problem types, see :ref:`Appendix E`. For more information about the XML for different problem types, see :ref:`Appendix E`.
The following image shows the multiple choice problem above in the Advanced Editor The following image shows the multiple choice problem above in the Advanced Editor
instead of the Simple Editor. instead of the Simple Editor.
...@@ -328,8 +328,8 @@ Problem Types ...@@ -328,8 +328,8 @@ Problem Types
Studio includes templates for many different types of problems, from Studio includes templates for many different types of problems, from
simple multiple choice problems to advanced problems that require the simple multiple choice problems to advanced problems that require the
student to “build” a virtual circuit. Details about each problem type, student to “build” a virtual circuit. Details about each problem type,
including information about how to create the problem, appears in the including information about how to create the problem, appears in the
page for the problem type. page for the problem type.
- :ref:`Common Problems` appear on the **Common Problem Types** tab when you - :ref:`Common Problems` appear on the **Common Problem Types** tab when you
...@@ -344,7 +344,7 @@ page for the problem type. ...@@ -344,7 +344,7 @@ page for the problem type.
**Add New Component** in each unit, and these problems are available **Add New Component** in each unit, and these problems are available
in the Advanced component. in the Advanced component.
- :ref:`Open Response Assessment Problems` are a new kind of problem that allow you, the - :ref:`Open Response Assessment Problems` are a new kind of problem that allow you, the
students in your course, or a computer algorithm to grade responses in the form students in your course, or a computer algorithm to grade responses in the form
of essays, files such as computer code, and images. of essays, files such as computer code, and images.
.. _Multiple Problems in One Component: .. _Multiple Problems in One Component:
......
...@@ -20,6 +20,7 @@ This chapter describes the tools you use to build an edX course, and how to crea ...@@ -20,6 +20,7 @@ This chapter describes the tools you use to build an edX course, and how to crea
* :ref:`Use Studio on Edge` * :ref:`Use Studio on Edge`
* :ref:`Create Your First Course` * :ref:`Create Your First Course`
* :ref:`View Your Course on Edge` * :ref:`View Your Course on Edge`
* :ref:`What is edX.org?`
* :ref:`Register Your Course on edX.org` * :ref:`Register Your Course on edX.org`
If you are using an instance of Open edX, some specifics in this chapter may not apply. If you are using an instance of Open edX, some specifics in this chapter may not apply.
...@@ -47,6 +48,8 @@ What is Edge? ...@@ -47,6 +48,8 @@ What is Edge?
EdX Edge_ is the site where you can create courses with Studio, then run courses through the edX Learning Management System. EdX Edge_ is the site where you can create courses with Studio, then run courses through the edX Learning Management System.
EdX Edge_ is also used to host SPOCs, or Small Private Online Courses.
Visually and functionally, edX Edge is the same as edX.org_. Visually and functionally, edX Edge is the same as edX.org_.
However, on Edge you can freely publish courses. However, on Edge you can freely publish courses.
There is no course catalog on Edge and other users will not find your course. You must explicitly invite students to participate in your course. There is no course catalog on Edge and other users will not find your course. You must explicitly invite students to participate in your course.
...@@ -157,6 +160,20 @@ You can view the course and see that there is no content yet. ...@@ -157,6 +160,20 @@ You can view the course and see that there is no content yet.
To build your course, keep reading this document. To build your course, keep reading this document.
.. _What is edX.org?:
*******************
What is edX.org?
*******************
edX.org_ is the site where edX hosts MOOCs, or Massive Open Online Courses, that are created with our institutional partners. These courses are open to students from around the world.
Courses on edX.org_ are listed publicly.
To publish courses on edX.org, you must have an agreement with edX and specific approval from your university.
.. _Register Your Course on edx.org: .. _Register Your Course on edx.org:
************************************ ************************************
......
...@@ -12,6 +12,7 @@ Contents ...@@ -12,6 +12,7 @@ Contents
:maxdepth: 5 :maxdepth: 5
read_me read_me
change_log
get_started get_started
create_new_course create_new_course
establish_grading_policy establish_grading_policy
...@@ -32,7 +33,7 @@ Contents ...@@ -32,7 +33,7 @@ Contents
checking_student_progress checking_student_progress
ora_students ora_students
glossary glossary
change_log
......
...@@ -19,7 +19,7 @@ You organize your course in the following hierarchy: ...@@ -19,7 +19,7 @@ You organize your course in the following hierarchy:
Studio provides you with flexibility when organizing your course. Studio provides you with flexibility when organizing your course.
A common course model is for Sections to correspond to weeks, and for Subsections to correspond to lessons. A common course model is for sections to correspond to weeks, and for subsections to correspond to lessons.
.. note:: We recommend that you review :ref:`Guidelines for Creating Accessible Content` before developing content for your course. .. note:: We recommend that you review :ref:`Guidelines for Creating Accessible Content` before developing content for your course.
...@@ -48,18 +48,18 @@ The following example shows how a student would view this course content: ...@@ -48,18 +48,18 @@ The following example shows how a student would view this course content:
Sections Sections
******** ********
A Section is the topmost category in your course. A Section can represent a time-period in your course, or another organizing principle. A section is the topmost category in your course. A Section can represent a time-period in your course, or another organizing principle.
To create a Section: To create a section:
#. In the Course Outline, click **New Section**. #. In the Course Outline, click **New Section**.
#. In the field that opens at the top of the outline, enter the new Section name. #. In the field that opens at the top of the outline, enter the new Section name.
#. Click **Save**. #. Click **Save**.
The new, empty Section is placed at the bottom of the course outline. The new, empty section is placed at the bottom of the course outline.
You must now add Subsections to the Section. You must now add subsections to the section.
Whether or not students see the new Section depends on the release date. Whether or not students see the new section depends on the release date.
See :ref:`Publishing Your Course` for more information. See :ref:`Publishing Your Course` for more information.
.. _Subsections: .. _Subsections:
...@@ -68,54 +68,54 @@ See :ref:`Publishing Your Course` for more information. ...@@ -68,54 +68,54 @@ See :ref:`Publishing Your Course` for more information.
Subsections Subsections
**************** ****************
Sections are divided into Subsections. A Subsection may represent a topic in your course, or another organizing principle. Sections are divided into subsections. A subsection may represent a topic in your course, or another organizing principle.
You can set a Subsection to an assignment type that you created when You can set a subsection to an assignment type that you created when
you set up grading. You can then include assignments in the body of that you set up grading. You can then include assignments in the body of that
Subsection. For more information on grading, see LINK. subsection. See :ref:`Establish a Grading Policy` for more information on grading.
To create a Subsection: To create a Subsection:
#. Within the Section, click **New Subsection**. #. At the bottom of the section, click **New Subsection**.
#. In the field that opens at the bottom of the section, enter the new Subsection name. #. In the field that opens, enter the new Subsection name.
#. Click **Save**. #. Click **Save**.
The new, empty Subsection is placed at the bottom of the Section. The new, empty subsection is placed at the bottom of the section.
You must now add Units to the Subsection. You must now add Units to the subsection.
Whether or not students see the new Subsection depends on its release date. Whether or not students see the new subsection depends on its release date.
See LINK for more information on releasing your course. See :ref:`Publishing Your Course` for more information.
================== ==================
Edit a Subsection Edit a Subsection
================== ==================
You can add and delete Subsections, and select the grading policy, directly from the Course Outline. You can add and delete subsections, and select the grading policy, directly from the Course Outline.
You can also open the Subsection in its own page, to perform those tasks as well as to You can also open the subsection in its own page, to perform those tasks as well as to
set the Subsection release date, set a due date, preview a draft of the Subsection, or view the live course. set the subsection release date, set a due date, preview a draft of the subsection, or view the live course.
Click on the Subsection title. The Subsection opens in its own page: Click on the subsection title. The subsection opens in its own page:
.. image:: Images/subsection.png .. image:: Images/subsection.png
:width: 800 :width: 800
======================= =======================
Set the Grading Policy Add a Graded Assignment
======================= =======================
You can designate a Subsection as one of the assignment types that you specified in the grading policy. You can make a subsection a graded assignment. You select one of the assignment types that you specified in the grading policy.
You set the grading policy for the Subsection from the Course Outline or from the Subsection page. You select the assignment type for the Subsection from the Course Outline or from the Subsection page.
From the Course Outline, click the checkmark next to the Subsection. Then select a grading policy from the popup menu: From the Course Outline, click the checkmark next to the subsection. Then select the assignment type from the popup menu:
.. image:: Images/course_outline_set_grade.png .. image:: Images/course_outline_set_grade.png
:width: 800 :width: 800
From the Subsection page, click the text next to the **Graded as** label, then select a grading policy from the popup menu: From the Subsection page, click the text next to the **Graded as** label, then select the assignment type from the popup menu:
.. image:: Images/subsection_set_grade.png .. image:: Images/subsection_set_grade.png
:width: 800 :width: 800
...@@ -127,9 +127,9 @@ See :ref:`Establish a Grading Policy` for more information. ...@@ -127,9 +127,9 @@ See :ref:`Establish a Grading Policy` for more information.
Set the Due Date Set the Due Date
================== ==================
For Subsections that contain graded problems, you can set a due date. Students must complete the problems in the Subsection before the due date to get credit. For subsections that contain graded problems, you can set a due date. Students must complete the problems in the subsection before the due date to get credit.
#. From the Subsection page, click **SET A DUE DATE**. The Due Day and Due Time fields appear. #. From the subsection page, click **SET A DUE DATE**. The Due Day and Due Time fields appear.
#. Place the cursor in the Due Date field, and pick a day from the popup calendar. #. Place the cursor in the Due Date field, and pick a day from the popup calendar.
#. Place the cursor in the Due Time field and pick a time. #. Place the cursor in the Due Time field and pick a time.
...@@ -145,10 +145,10 @@ For more information, see :ref:`Establish a Grading Policy`. ...@@ -145,10 +145,10 @@ For more information, see :ref:`Establish a Grading Policy`.
Units Units
****** ******
Subsections are divided into Units. A Unit contains one or more Components. Subsections are divided into units. A unit contains one or more components.
For students, each Unit in the Subsection is represented as a link on the accordian at the top of the page. For students, each unit in the subsection is represented as a link on the accordian at the top of the page.
The following page shows a Subsection that has nine Units: The following page shows a subsection that has nine Units:
.. image:: Images/units_students.png .. image:: Images/units_students.png
:width: 800 :width: 800
...@@ -165,18 +165,18 @@ The following page shows a Subsection that has nine Units: ...@@ -165,18 +165,18 @@ The following page shows a Subsection that has nine Units:
you work with a private unit or edit a draft of a public unit. you work with a private unit or edit a draft of a public unit.
To create a Unit from the Course Outline or the Subsection page: To create a unit from the Course Outline or the subsection page:
#. Within the Subsection, click **New Unit**. #. Within the subsection, click **New Unit**.
#. Enter the Display Name that students will see. #. Enter the Display Name that students will see.
#. Click a Component type to add a the first Component in the Unit. #. Click a component type to add a the first component in the Unit.
.. image:: Images/Unit_DisplayName_Studio.png .. image:: Images/Unit_DisplayName_Studio.png
#. Follow the instructions for the type of Component, listed below. #. Follow the instructions for the type of component, listed below.
#. By default, the Unit visibility is **Private**, meaning students will not be able to see the Unit. Unless you want to publish the Unit to students immediately, leave this setting. See LINK for more information on releasing your course. #. By default, the Unit visibility is **Private**, meaning students will not be able to see the Unit. Unless you want to publish the Unit to students immediately, leave this setting. See :ref:`Publishing Your Course` for more information on releasing your course.
The Unit with the single Component is placed at the bottom of the Subsection. The unit with the single component is placed at the bottom of the subsection.
.. _Components: .. _Components:
...@@ -192,10 +192,10 @@ You add the first component when creating the unit. ...@@ -192,10 +192,10 @@ You add the first component when creating the unit.
To add another component to the unit: To add another component to the unit:
#. If the Unit is Public, change the **Visibility** setting to **Private**. You cannot modify a Public Unit. #. If the unit is public, change the **Visibility** setting to **Private**. You cannot modify a Public unit.
#. In the **Add New Component** panel at the bottom of the Unit, click the type of Component to add. #. In the **Add New Component** panel at the bottom of the unit, click the type of component to add.
.. image:: Images/Unit_DisplayName_Studio.png .. image:: Images/Unit_DisplayName_Studio.png
#. Follow the instructions for the type of Component: #. Follow the instructions for the type of component:
* :ref:`Working with HTML Components` * :ref:`Working with HTML Components`
* :ref:`Working with Video Components` * :ref:`Working with Video Components`
...@@ -212,12 +212,12 @@ Reorganize Your Course ...@@ -212,12 +212,12 @@ Reorganize Your Course
You can reorganize your course by dragging and dropping elements in the Course Outline. You can reorganize your course by dragging and dropping elements in the Course Outline.
To move a Section, Subsection, or Unit, click the mouse on the element's handle on the right side of the outline, then move the element to the new location. To move a section, subsection, or unit, click the mouse on the element's handle on the right side of the outline, then move the element to the new location.
Element handles are highlighed in the following image: Element handles are highlighed in the following image:
.. image:: Images/drag_drop.png .. image:: Images/drag_drop.png
:width: 800 :width: 800
When you move a course element, a blue line indicates the new position. You can move a Subsection to a new Section, and a Unit to a new Subsection. When you move a course element, a blue line indicates the new position. You can move a subsection to a new section, and a unit to a new subsection.
You can reorganize Components within a Unit in the same way. You can reorganize components within a unit in the same way.
\ No newline at end of file \ No newline at end of file
#############################
Working with Tools
#############################
***************************
Overview of Tools in Studio
***************************
**Intro to Tools text** - you can use various tools in Studio, etc. (Sometimes
called blades, though that's not intuitive for very many people.)
- Interactive periodic table (if we document this)
- :ref:`Qualtrics Survey`
- :ref:`Word Cloud`
- :ref:`Zooming image`
.. _Qualtrics Survey:
****************
Qualtrics Survey
****************
**description of Qualtrics survey and explanation of why course teams would want to
use it**
**image of Qualtrics survey**
Create a Qualtrics Survey
~~~~~~~~~~~~~~~~~~~~~~~~~
To create a Qualtrics survey, you'll use the Anonymous User ID template. This
template contains HTML with instructions.
#. Under **Add New Component**, click **html**, and then click **Anonymous User ID**.
#. In the empty component that appears, click **Edit**.
#. When the component editor opens, replace the example content with your own content.
- **flesh these instructions out more**
- To use your survey, you must edit the link in the template to include your university and survey ID.
- You can also embed the survey in an iframe in the HTML component.
- For more details, read the instructions in the HTML view of the component.
#. Click **Save** to save the HTML component.
...@@ -20,7 +20,7 @@ Preview Your Course ...@@ -20,7 +20,7 @@ Preview Your Course
*********************** ***********************
When you view your course through Preview mode, you see all the When you view your course through Preview mode, you see all the
Units of your course, regardless of whether they are set to Public or units of your course, regardless of whether they are set to Public or
Private and regardless of whether the release dates have passed. Private and regardless of whether the release dates have passed.
...@@ -32,23 +32,23 @@ You can enter Preview mode in two ways. ...@@ -32,23 +32,23 @@ You can enter Preview mode in two ways.
* On any subsection page, click **Preview Drafts**. * On any subsection page, click **Preview Drafts**.
.. image:: Images/image205.png .. image:: Images/preview_draft.png
:width: 800 :width: 800
* On any Unit page, click **Preview**. * On any Unit page, click **Preview**.
The following example shows the **Preview** button for a unit that The following example shows the **Preview** button for a unit that
is set to Public. is set to Public.
.. image:: Images/image207.png .. image:: Images/preview_public.png
:width: 800 :width: 800
The following example shows the **Preview** button for a unit that The following example shows the **Preview** button for a unit that
is set to Private. is set to Private.
.. image:: Images/image209.png .. image:: Images/preview_private.png
:width: 800 :width: 800
.. _View Your Live Course: .. _View Your Live Course:
...@@ -65,16 +65,16 @@ You can view the live course from three different places in Studio: ...@@ -65,16 +65,16 @@ You can view the live course from three different places in Studio:
* The **Course Outline** page. * The **Course Outline** page.
.. image:: Images/image217.png .. image:: Images/course_outline_view_live.png
:width: 800 :width: 800
* Any Subsection page. * Any Subsection page.
.. image:: Images/image219.png .. image:: Images/subsection_view_live.png
:width: 800 :width: 800
* The Unit page, if the Unit is Public. * The Unit page, if the Unit is Public.
.. image:: Images/image221.png .. image:: Images/unit_view_live.png
:width: 800 :width: 800
###################################
January 7, 2014
###################################
You can now access the public edX roadmap_ for details about the currently planned product direction.
.. _roadmap: https://edx-wiki.atlassian.net/wiki/display/OPENPROD/OpenEdX+Public+Product+Roadmap
*************
edX Studio
*************
New documentation, *Building a Course with edX Studio*, is available online_. You can also download the new guide as a PDF from the edX Studio user interface.
.. _online: http://edx.readthedocs.org/projects/ca/en/latest/
=============
New Features
=============
* The **Files & Uploads** page has been updated so that a maximum of 50 files now appear on a single page. If your course has more than 50 files, additional files are listed in separate pages. You can navigate to other pages through pagination controls at the top and bottom of the file list. This change improves the page performance for courses with a large number of files.
For more information, see the `updated documentation for adding files <http://edx.readthedocs.org/projects/ca/en/latest/create_new_course.html#add-files-to-a-course>`_.
.. note:: The :ref:`October 29 2013` release notes describe a workaround to limit the number of files that appear on a single page. With the January 7, 2014 release, this method is not necessary and no longer works.
* The **Course Outline** page is updated to include several design improvements. The new Course Outline appears as in the following example:
.. image:: images/course_outline.png
:alt: The Course Outline
To see the changes, view your course in Studio or see the `updated documentation for organizing your course content <http://edx.readthedocs.org/projects/ca/en/latest/organizing_course.html>`_.
* A template for custom JavaScript display and grading problems (also called JSInput problems) is now available. For more informatoin, see the `updated documentation for Custom JavaScript display and grading problems <http://edx.readthedocs.org/projects/ca/en/latest/advanced_problems.html#custom-javascript-display-and-grading>`_. (BLD-523) (BLD-556)
* A template for the Zooming Image tool is now available. For more informatoin, see the `updated documentation for the zooming image tool <http://edx.readthedocs.org/projects/ca/en/latest/tools.html#zooming-image>`_. (BLD-206)
==========================
Changes and Updates
==========================
* The Course Export tool now supports non-ASCII characters. (STUD-868)
* In the course outline, you can now drag a section to the end of the list of sections when the last section is collapsed. (STUD-879)
* In Video components, when you click inside the **Start Time** or **End Time** field, you can enter a time in HH:MM:SS format as normal text. After you click out of the field, Studio adds zeros and performs unit conversions so that the field contains six digits that correspond to hours, minutes, and seconds.
For example, if you enter 1:35, the text in the field changes to 00:01:35. If you enter 2:71:35, the text changes to 3:11:35. (BLD-506 and BLD-581)
* The **Save** button for JSInput Problem components now works as expected. (BLD-568)
***************************************
edX Learning Management System
***************************************
* When you download grades by clicking **Download CSV of answer distributions** on the Instructor Dashboard, the LMS no longer returns an empty CSV for small Studio-created courses. Instead, the LMS returns a CSV that is sorted by url_name and that includes responses from students who have unenrolled from the course.
Note that errors occur if you try to download grades for a large Studio-based course or an XML-based course.
* In the course wiki, the **Preview this Revision** and the **Merge selected with Current** dialog boxes are now keyboard accessible in Internet Explorer. (LMS-1539)
* On the Instructor Dashboard, when you click the Datadump tab and then click Download CSV of all student profile data, you no longer receive a 500 error message. (LMS-1675)
* For Image Response problems, the correct answer now appears when a student clicks **Show Answer**. (BLD-21)
* On iPads, the video player uses edX controls that appear after you click the video or the Play button. On iPhones, the video player uses native controls. (BLD-541)
.. _October 29 2013:
################################### ###################################
October 29, 2013 October 29, 2013
################################### ###################################
......
...@@ -11,7 +11,7 @@ You can now access the public edX roadmap_ for details about the currently plann ...@@ -11,7 +11,7 @@ You can now access the public edX roadmap_ for details about the currently plann
edX Studio edX Studio
************* *************
New documentation, *Building a Course with edX Studio* is available online_. You can also download the new guide as a PDF from the edX Studio user interface. New documentation, *Building a Course with edX Studio*, is available online_. You can also download the new guide as a PDF from the edX Studio user interface.
.. _online: http://edx.readthedocs.org/projects/ca/en/latest/ .. _online: http://edx.readthedocs.org/projects/ca/en/latest/
......
...@@ -25,7 +25,7 @@ html_static_path.append('source/_static') ...@@ -25,7 +25,7 @@ html_static_path.append('source/_static')
# General information about the project. # General information about the project.
project = u'Release Notes for edX Course Staff' project = u'Release Notes for edX Course Staff'
copyright = u'2013, edX Documentation Team' copyright = u'2013, edX'
# The short X.Y version. # The short X.Y version.
version = '' version = ''
......
...@@ -11,6 +11,7 @@ Contents ...@@ -11,6 +11,7 @@ Contents
:maxdepth: 5 :maxdepth: 5
read_me read_me
01-07-2014
12-17-2013 12-17-2013
12-09-2013 12-09-2013
12-03-2013 12-03-2013
......
...@@ -21,9 +21,9 @@ class Converter(object): ...@@ -21,9 +21,9 @@ class Converter(object):
# HTML: <B>, </B>, <BR/>, <textformat leading="10"> # HTML: <B>, </B>, <BR/>, <textformat leading="10">
# Python: %(date)s, %(name)s # Python: %(date)s, %(name)s
tag_pattern = re.compile(r''' tag_pattern = re.compile(r'''
(<[-\w" .:?=/]*>) | # <tag> (<[^>]+>) | # <tag>
({[^}]*}) | # {tag} ({[^}]+}) | # {tag}
(%\([^)]*\)\w) | # %(tag)s (%\([\w]+\)\w) | # %(tag)s
(&\w+;) | # &entity; (&\w+;) | # &entity;
(&\#\d+;) | # &#1234; (&\#\d+;) | # &#1234;
(&\#x[0-9a-f]+;) # &#xABCD; (&\#x[0-9a-f]+;) # &#xABCD;
......
from converter import Converter # -*- coding: utf-8 -*-
r"""
Creates new localization properties files in a dummy language.
Each property file is derived from the equivalent en_US file, with these
transformations applied:
1. Every vowel is replaced with an equivalent with extra accent marks.
# Creates new localization properties files in a dummy language 2. Every string is padded out to +30% length to simulate verbose languages
# Each property file is derived from the equivalent en_US file, except (such as German) to see if layout and flows work properly.
# 1. Every vowel is replaced with an equivalent with extra accent marks
# 2. Every string is padded out to +30% length to simulate verbose languages (e.g. German)
# to see if layout and flows work properly
# 3. Every string is terminated with a '#' character to make it easier to detect truncation
3. Every string is terminated with a '#' character to make it easier to detect
truncation.
# -------------------------------- Example use::
# Example use:
# >>> from dummy import Dummy
# >>> c = Dummy()
# >>> c.convert("hello my name is Bond, James Bond")
# u'h\xe9ll\xf6 my n\xe4m\xe9 \xefs B\xf6nd, J\xe4m\xe9s B\xf6nd Lorem i#'
#
# >>> c.convert('don\'t convert <a href="href">tag ids</a>')
# u'd\xf6n\'t \xe7\xf6nv\xe9rt <a href="href">t\xe4g \xefds</a> Lorem ipsu#'
#
# >>> c.convert('don\'t convert %(name)s tags on %(date)s')
# u"d\xf6n't \xe7\xf6nv\xe9rt %(name)s t\xe4gs \xf6n %(date)s Lorem ips#"
>>> from dummy import Dummy
>>> c = Dummy()
>>> c.convert("My name is Bond, James Bond")
u'M\xfd n\xe4m\xe9 \xefs B\xf8nd, J\xe4m\xe9s B\xf8nd \u2360\u03c3\u044f\u0454\u043c \u03b9\u03c1#'
>>> print c.convert("My name is Bond, James Bond")
Mý nämé ïs Bønd, Jämés Bønd Ⱡσяєм ιρ#
>>> print c.convert("don't convert <a href='href'>tag ids</a>")
døn't çønvért <a href='href'>täg ïds</a> Ⱡσяєм ιρѕυ#
>>> print c.convert("don't convert %(name)s tags on %(date)s")
døn't çønvért %(name)s tägs øn %(date)s Ⱡσяєм ιρѕ#
"""
from converter import Converter
# Substitute plain characters with accented lookalikes. # Substitute plain characters with accented lookalikes.
# http://tlt.its.psu.edu/suggestions/international/web/codehtml.html#accent # http://tlt.its.psu.edu/suggestions/international/web/codehtml.html#accent
TABLE = {'A': u'\xC0', TABLE = {
'a': u'\xE4', 'A': u'À',
'b': u'\xDF', 'a': u'ä',
'C': u'\xc7', 'b': u'ß',
'c': u'\xE7', 'C': u'Ç',
'E': u'\xC9', 'c': u'ç',
'e': u'\xE9', 'E': u'É',
'I': U'\xCC', 'e': u'é',
'i': u'\xEF', 'I': u'Ì',
'O': u'\xD8', 'i': u'ï',
'o': u'\xF8', 'O': u'Ø',
'U': u'\xDB', 'o': u'ø',
'u': u'\xFC', 'U': u'Û',
'Y': u'\xDD', 'u': u'ü',
'y': u'\xFD', 'Y': u'Ý',
} 'y': u'ý',
}
# The print industry's standard dummy text, in use since the 1500s # The print industry's standard dummy text, in use since the 1500s
# see http://www.lipsum.com/ # see http://www.lipsum.com/, then fed through a "fancy-text" converter.
LOREM = ' Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed ' \ # The string should start with a space.
'do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad ' \ LOREM = " " + " ".join( # join and split just make the string easier here.
'minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ' \ u"""
'ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate ' \ Ⱡσяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂
'velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat ' \ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм
'cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ' νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα
¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє
νєłιт єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт
¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм ι∂
єѕт łαвσяυм.
""".split()
)
# To simulate more verbose languages (like German), pad the length of a string # To simulate more verbose languages (like German), pad the length of a string
# by a multiple of PAD_FACTOR # by a multiple of PAD_FACTOR
...@@ -85,20 +99,6 @@ class Dummy(Converter): ...@@ -85,20 +99,6 @@ class Dummy(Converter):
"""replaces the final char of string with #""" """replaces the final char of string with #"""
return string[:-1] + '#' return string[:-1] + '#'
def init_msgs(self, msgs):
"""
Make sure the first msg in msgs has a plural property.
msgs is list of instances of polib.POEntry
"""
if not msgs:
return
headers = msgs[0].get_property('msgstr')
has_plural = any(header.startswith('Plural-Forms:') for header in headers)
if not has_plural:
# Apply declaration for English pluralization rules
plural = "Plural-Forms: nplurals=2; plural=(n != 1);\\n"
headers.append(plural)
def convert_msg(self, msg): def convert_msg(self, msg):
""" """
Takes one POEntry object and converts it (adds a dummy translation to it) Takes one POEntry object and converts it (adds a dummy translation to it)
...@@ -114,8 +114,10 @@ class Dummy(Converter): ...@@ -114,8 +114,10 @@ class Dummy(Converter):
# translate singular and plural # translate singular and plural
foreign_single = self.convert(source) foreign_single = self.convert(source)
foreign_plural = self.convert(plural) foreign_plural = self.convert(plural)
plural = {'0': self.final_newline(source, foreign_single), plural = {
'1': self.final_newline(plural, foreign_plural)} '0': self.final_newline(source, foreign_single),
'1': self.final_newline(plural, foreign_plural),
}
msg.msgstr_plural = plural msg.msgstr_plural = plural
else: else:
foreign = self.convert(source) foreign = self.convert(source)
......
...@@ -45,7 +45,7 @@ def main(): ...@@ -45,7 +45,7 @@ def main():
remove_file(source_msgs_dir.joinpath(filename)) remove_file(source_msgs_dir.joinpath(filename))
# Extract strings from mako templates. # Extract strings from mako templates.
babel_mako_cmd = 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT) babel_mako_cmd = 'pybabel extract -F %s -c "Translators:" . -o %s' % (BABEL_CONFIG, BABEL_OUT)
# Extract strings from django source files. # Extract strings from django source files.
make_django_cmd = ( make_django_cmd = (
......
...@@ -60,9 +60,12 @@ def merge(locale, target='django.po', fail_if_missing=True): ...@@ -60,9 +60,12 @@ def merge(locale, target='django.po', fail_if_missing=True):
def clean_metadata(file): def clean_metadata(file):
""" """
Clean up redundancies in the metadata caused by merging. Clean up redundancies in the metadata caused by merging.
This reads in a PO file and simply saves it back out again.
""" """
pofile(file).save() # Reading in the .po file and saving it again fixes redundancies.
pomsgs = pofile(file)
# The msgcat tool marks the metadata as fuzzy, but it's ok as it is.
pomsgs.metadata_is_fuzzy = False
pomsgs.save()
def validate_files(dir, files_to_merge): def validate_files(dir, files_to_merge):
......
...@@ -38,9 +38,15 @@ def main(file, locale): ...@@ -38,9 +38,15 @@ def main(file, locale):
raise IOError('File does not exist: %s' % file) raise IOError('File does not exist: %s' % file)
pofile = polib.pofile(file) pofile = polib.pofile(file)
converter = Dummy() converter = Dummy()
converter.init_msgs(pofile.translated_entries())
for msg in pofile: for msg in pofile:
converter.convert_msg(msg) converter.convert_msg(msg)
# If any message has a plural, then the file needs plural information.
# Apply declaration for English pluralization rules so that ngettext will
# do something reasonable.
if any(m.msgid_plural for m in pofile):
pofile.metadata['Plural-Forms'] = 'nplurals=2; plural=(n != 1);'
new_file = new_filename(file, locale) new_file = new_filename(file, locale)
create_dir_if_necessary(new_file) create_dir_if_necessary(new_file)
pofile.save(new_file) pofile.save(new_file)
......
"""Tests of i18n/converter.py"""
import os import os
from unittest import TestCase from unittest import TestCase
import ddt
import converter import converter
...@@ -11,36 +14,48 @@ class UpcaseConverter(converter.Converter): ...@@ -11,36 +14,48 @@ class UpcaseConverter(converter.Converter):
return string.upper() return string.upper()
@ddt.ddt
class TestConverter(TestCase): class TestConverter(TestCase):
""" """
Tests functionality of i18n/converter.py Tests functionality of i18n/converter.py
""" """
def test_converter(self): @ddt.data(
# no tags
('big bad wolf',
'BIG BAD WOLF'),
# one html tag
('big <strong>bad</strong> wolf',
'BIG <strong>BAD</strong> WOLF'),
# two html tags
('big <b>bad</b> gray <i>wolf</i>',
'BIG <b>BAD</b> GRAY <i>WOLF</i>'),
# html tags with attributes
('<a href="foo">bar</a> baz',
'<a href="foo">BAR</a> BAZ'),
("<a href='foo'>bar</a> baz",
"<a href='foo'>BAR</a> BAZ"),
# one python tag
('big %(adjective)s wolf',
'BIG %(adjective)s WOLF'),
# two python tags
('big %(adjective)s gray %(noun)s',
'BIG %(adjective)s GRAY %(noun)s'),
# both kinds of tags
('<strong>big</strong> %(adjective)s %(noun)s',
'<strong>BIG</strong> %(adjective)s %(noun)s'),
# .format-style tags
('The {0} barn is {1!r}.',
'THE {0} BARN IS {1!r}.'),
# HTML entities
('<b>&copy; 2013 edX, &#xa0;</b>',
'<b>&copy; 2013 EDX, &#xa0;</b>'),
)
def test_converter(self, data):
""" """
Tests with a simple converter (converts strings to uppercase). Tests with a simple converter (converts strings to uppercase).
Assert that embedded HTML and python tags are not converted. Assert that embedded HTML and python tags are not converted.
""" """
c = UpcaseConverter() source, expected = data
test_cases = [ result = UpcaseConverter().convert(source)
# no tags self.assertEquals(result, expected)
('big bad wolf', 'BIG BAD WOLF'),
# one html tag
('big <strong>bad</strong> wolf', 'BIG <strong>BAD</strong> WOLF'),
# two html tags
('big <b>bad</b> <i>wolf</i>', 'BIG <b>BAD</b> <i>WOLF</i>'),
# one python tag
('big %(adjective)s wolf', 'BIG %(adjective)s WOLF'),
# two python tags
('big %(adjective)s %(noun)s', 'BIG %(adjective)s %(noun)s'),
# both kinds of tags
('<strong>big</strong> %(adjective)s %(noun)s',
'<strong>BIG</strong> %(adjective)s %(noun)s'),
# .format-style tags
('The {0} barn is {1!r}.', 'THE {0} BARN IS {1!r}.'),
# HTML entities
('<b>&copy; 2013 edX, &#xa0;</b>', '<b>&copy; 2013 EDX, &#xa0;</b>'),
]
for source, expected in test_cases:
result = c.convert(source)
self.assertEquals(result, expected)
# -*- coding: utf-8 -*-
"""Tests of i18n/dummy.py"""
import os, string, random import os, string, random
from unittest import TestCase from unittest import TestCase
import ddt
from polib import POEntry from polib import POEntry
import dummy import dummy
@ddt.ddt
class TestDummy(TestCase): class TestDummy(TestCase):
""" """
Tests functionality of i18n/dummy.py Tests functionality of i18n/dummy.py
...@@ -13,39 +19,52 @@ class TestDummy(TestCase): ...@@ -13,39 +19,52 @@ class TestDummy(TestCase):
def setUp(self): def setUp(self):
self.converter = dummy.Dummy() self.converter = dummy.Dummy()
def test_dummy(self): def assertUnicodeEquals(self, str1, str2):
"""Just like assertEquals, but doesn't put Unicode into the fail message.
Either nose, or rake, or something, deals very badly with unusual
Unicode characters in the assertions, so we use repr here to keep
things safe.
"""
self.assertEquals(
str1, str2,
"Mismatch: %r != %r" % (str1, str2),
)
@ddt.data(
(u"hello my name is Bond, James Bond",
u"héllø mý nämé ïs Bønd, Jämés Bønd Ⱡσяєм ι#"),
(u"don't convert <a href='href'>tag ids</a>",
u"døn't çønvért <a href='href'>täg ïds</a> Ⱡσяєм ιρѕυ#"),
(u"don't convert %(name)s tags on %(date)s",
u"døn't çønvért %(name)s tägs øn %(date)s Ⱡσяєм ιρѕ#"),
)
def test_dummy(self, data):
""" """
Tests with a dummy converter (adds spurious accents to strings). Tests with a dummy converter (adds spurious accents to strings).
Assert that embedded HTML and python tags are not converted. Assert that embedded HTML and python tags are not converted.
""" """
test_cases = [ source, expected = data
("hello my name is Bond, James Bond", result = self.converter.convert(source)
u'h\xe9ll\xf8 m\xfd n\xe4m\xe9 \xefs B\xf8nd, J\xe4m\xe9s B\xf8nd Lorem i#'), self.assertUnicodeEquals(result, expected)
('don\'t convert <a href="href">tag ids</a>',
u'd\xf8n\'t \xe7\xf8nv\xe9rt <a href="href">t\xe4g \xefds</a> Lorem ipsu#'),
('don\'t convert %(name)s tags on %(date)s',
u"d\xf8n't \xe7\xf8nv\xe9rt %(name)s t\xe4gs \xf8n %(date)s Lorem ips#")
]
for source, expected in test_cases:
result = self.converter.convert(source)
self.assertEquals(result, expected)
def test_singular(self): def test_singular(self):
entry = POEntry() entry = POEntry()
entry.msgid = 'A lovely day for a cup of tea.' entry.msgid = 'A lovely day for a cup of tea.'
expected = u'\xc0 l\xf8v\xe9l\xfd d\xe4\xfd f\xf8r \xe4 \xe7\xfcp \xf8f t\xe9\xe4. Lorem i#' expected = u'À løvélý däý før ä çüp øf téä. Ⱡσяєм ι#'
self.converter.convert_msg(entry) self.converter.convert_msg(entry)
self.assertEquals(entry.msgstr, expected) self.assertUnicodeEquals(entry.msgstr, expected)
def test_plural(self): def test_plural(self):
entry = POEntry() entry = POEntry()
entry.msgid = 'A lovely day for a cup of tea.' entry.msgid = 'A lovely day for a cup of tea.'
entry.msgid_plural = 'A lovely day for some cups of tea.' entry.msgid_plural = 'A lovely day for some cups of tea.'
expected_s = u'\xc0 l\xf8v\xe9l\xfd d\xe4\xfd f\xf8r \xe4 \xe7\xfcp \xf8f t\xe9\xe4. Lorem i#' expected_s = u'À løvélý däý før ä çüp øf téä. Ⱡσяєм ι#'
expected_p = u'\xc0 l\xf8v\xe9l\xfd d\xe4\xfd f\xf8r s\xf8m\xe9 \xe7\xfcps \xf8f t\xe9\xe4. Lorem ip#' expected_p = u'À løvélý däý før sømé çüps øf téä. Ⱡσяєм ιρ#'
self.converter.convert_msg(entry) self.converter.convert_msg(entry)
result = entry.msgstr_plural result = entry.msgstr_plural
self.assertEquals(result['0'], expected_s) self.assertUnicodeEquals(result['0'], expected_s)
self.assertEquals(result['1'], expected_p) self.assertUnicodeEquals(result['1'], expected_p)
import os, sys, logging """Tests that validate .po files."""
from unittest import TestCase
from nose.plugins.skip import SkipTest import codecs
import logging
import os
import sys
import textwrap
import polib
from config import LOCALE_DIR from config import LOCALE_DIR
from execute import call from execute import call
from converter import Converter
def test_po_files(root=LOCALE_DIR): def test_po_files(root=LOCALE_DIR):
""" """
...@@ -12,20 +20,120 @@ def test_po_files(root=LOCALE_DIR): ...@@ -12,20 +20,120 @@ def test_po_files(root=LOCALE_DIR):
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
logging.basicConfig(stream=sys.stdout, level=logging.INFO) logging.basicConfig(stream=sys.stdout, level=logging.INFO)
for (dirpath, dirnames, filenames) in os.walk(root): for dirpath, __, filenames in os.walk(root):
for name in filenames: for name in filenames:
(base, ext) = os.path.splitext(name) __, ext = os.path.splitext(name)
if ext.lower() == '.po': if ext.lower() == '.po':
yield validate_po_file, os.path.join(dirpath, name), log filename = os.path.join(dirpath, name)
yield msgfmt_check_po_file, filename, log
yield check_messages, filename
def validate_po_file(filename, log): def msgfmt_check_po_file(filename, log):
""" """
Call GNU msgfmt -c on each .po file to validate its format. Call GNU msgfmt -c on each .po file to validate its format.
Any errors caught by msgfmt are logged to log. Any errors caught by msgfmt are logged to log.
""" """
# Use relative paths to make output less noisy. # Use relative paths to make output less noisy.
rfile = os.path.relpath(filename, LOCALE_DIR) rfile = os.path.relpath(filename, LOCALE_DIR)
(out, err) = call(['msgfmt','-c', rfile], working_directory=LOCALE_DIR) out, err = call(['msgfmt', '-c', rfile], working_directory=LOCALE_DIR)
if err != '': if err != '':
log.warn('\n'+err) log.info('\n' + out)
log.warn('\n' + err)
assert not err
def tags_in_string(msg):
"""
Return the set of tags in a message string.
Tags includes HTML tags, data placeholders, etc.
Skips tags that might change due to translations: HTML entities, <abbr>,
and so on.
"""
def is_linguistic_tag(tag):
"""Is this tag one that can change with the language?"""
if tag.startswith("&"):
return True
if any(x in tag for x in ["<abbr>", "<abbr ", "</abbr>"]):
return True
return False
__, tags = Converter().detag_string(msg)
return set(t for t in tags if not is_linguistic_tag(t))
def astral(msg):
"""Does `msg` have characters outside the Basic Multilingual Plane?"""
return any(ord(c) > 0xFFFF for c in msg)
def check_messages(filename):
"""
Checks messages in various ways:
Translations must have the same slots as the English. The translation
must not be empty. Messages can't have astral characters in them.
"""
# Don't check English files.
if "/locale/en/" in filename:
return
# problems will be a list of tuples. Each is a description, and a msgid,
# and then zero or more translations.
problems = []
pomsgs = polib.pofile(filename)
for msg in pomsgs:
# Check for characters Javascript can't support.
# https://code.djangoproject.com/ticket/21725
if astral(msg.msgstr):
problems.append(("Non-BMP char", msg.msgid, msg.msgstr))
if msg.msgid_plural:
# Plurals: two strings in, N strings out.
source = msg.msgid + " | " + msg.msgid_plural
translation = " | ".join(v for k,v in sorted(msg.msgstr_plural.items()))
empty = any(not t.strip() for t in msg.msgstr_plural.values())
else:
# Singular: just one string in and one string out.
source = msg.msgid
translation = msg.msgstr
empty = not msg.msgstr.strip()
if empty:
problems.append(("Empty translation", source))
else:
id_tags = tags_in_string(source)
tx_tags = tags_in_string(translation)
if id_tags != tx_tags:
id_has = u", ".join(u'"{}"'.format(t) for t in id_tags - tx_tags)
tx_has = u", ".join(u'"{}"'.format(t) for t in tx_tags - id_tags)
if id_has and tx_has:
diff = u"{} vs {}".format(id_has, tx_has)
elif id_has:
diff = u"{} missing".format(id_has)
else:
diff = u"{} added".format(tx_has)
problems.append((
"Different tags in source and translation",
source,
translation,
diff
))
if problems:
problem_file = filename.replace(".po", ".prob")
id_filler = textwrap.TextWrapper(width=79, initial_indent=" msgid: ", subsequent_indent=" " * 9)
tx_filler = textwrap.TextWrapper(width=79, initial_indent=" -----> ", subsequent_indent=" " * 9)
with codecs.open(problem_file, "w", encoding="utf8") as prob_file:
for problem in problems:
desc, msgid = problem[:2]
prob_file.write(u"{}\n{}\n".format(desc, id_filler.fill(msgid)))
for translation in problem[2:]:
prob_file.write(u"{}\n".format(tx_filler.fill(translation)))
prob_file.write(u"\n")
assert not problems, "Found %d problems in %s, details in .prob file" % (len(problems), filename)
...@@ -15,6 +15,7 @@ def push(): ...@@ -15,6 +15,7 @@ def push():
def pull(): def pull():
for locale in CONFIGURATION.locales: for locale in CONFIGURATION.locales:
if locale != CONFIGURATION.source_locale: if locale != CONFIGURATION.source_locale:
print "Pulling %s from transifex..." % locale
execute('tx pull -l %s' % locale) execute('tx pull -l %s' % locale)
clean_translated_locales() clean_translated_locales()
......
...@@ -50,16 +50,14 @@ def permitted(fn): ...@@ -50,16 +50,14 @@ def permitted(fn):
return wrapper return wrapper
def ajax_content_response(request, course_id, content, template_name): def ajax_content_response(request, course_id, content):
context = { context = {
'course_id': course_id, 'course_id': course_id,
'content': content, 'content': content,
} }
html = render_to_string(template_name, context)
user_info = cc.User.from_django_user(request.user).to_dict() user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = utils.get_annotated_content_info(course_id, content, request.user, user_info) annotated_content_info = utils.get_annotated_content_info(course_id, content, request.user, user_info)
return JsonResponse({ return JsonResponse({
'html': html,
'content': utils.safe_content(content), 'content': utils.safe_content(content),
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
}) })
...@@ -131,7 +129,7 @@ def create_thread(request, course_id, commentable_id): ...@@ -131,7 +129,7 @@ def create_thread(request, course_id, commentable_id):
data = thread.to_dict() data = thread.to_dict()
add_courseware_context([data], course) add_courseware_context([data], course)
if request.is_ajax(): if request.is_ajax():
return ajax_content_response(request, course_id, data, 'discussion/ajax_create_thread.html') return ajax_content_response(request, course_id, data)
else: else:
return JsonResponse(utils.safe_content(data)) return JsonResponse(utils.safe_content(data))
...@@ -147,7 +145,7 @@ def update_thread(request, course_id, thread_id): ...@@ -147,7 +145,7 @@ def update_thread(request, course_id, thread_id):
thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags'])) thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags']))
thread.save() thread.save()
if request.is_ajax(): if request.is_ajax():
return ajax_content_response(request, course_id, thread.to_dict(), 'discussion/ajax_update_thread.html') return ajax_content_response(request, course_id, thread.to_dict())
else: else:
return JsonResponse(utils.safe_content(thread.to_dict())) return JsonResponse(utils.safe_content(thread.to_dict()))
...@@ -184,7 +182,7 @@ def _create_comment(request, course_id, thread_id=None, parent_id=None): ...@@ -184,7 +182,7 @@ def _create_comment(request, course_id, thread_id=None, parent_id=None):
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
user.follow(comment.thread) user.follow(comment.thread)
if request.is_ajax(): if request.is_ajax():
return ajax_content_response(request, course_id, comment.to_dict(), 'discussion/ajax_create_comment.html') return ajax_content_response(request, course_id, comment.to_dict())
else: else:
return JsonResponse(utils.safe_content(comment.to_dict())) return JsonResponse(utils.safe_content(comment.to_dict()))
...@@ -228,7 +226,7 @@ def update_comment(request, course_id, comment_id): ...@@ -228,7 +226,7 @@ def update_comment(request, course_id, comment_id):
comment.update_attributes(**extract(request.POST, ['body'])) comment.update_attributes(**extract(request.POST, ['body']))
comment.save() comment.save()
if request.is_ajax(): if request.is_ajax():
return ajax_content_response(request, course_id, comment.to_dict(), 'discussion/ajax_update_comment.html') return ajax_content_response(request, course_id, comment.to_dict())
else: else:
return JsonResponse(utils.safe_content(comment.to_dict())) return JsonResponse(utils.safe_content(comment.to_dict()))
......
...@@ -248,13 +248,10 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -248,13 +248,10 @@ def single_thread(request, course_id, discussion_id, thread_id):
with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"): with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"):
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
context = {'thread': thread.to_dict(), 'course_id': course_id} context = {'thread': thread.to_dict(), 'course_id': course_id}
# TODO: Remove completely or switch back to server side rendering
# html = render_to_string('discussion/_ajax_single_thread.html', context)
content = utils.safe_content(thread.to_dict()) content = utils.safe_content(thread.to_dict())
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"): with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
add_courseware_context([content], course) add_courseware_context([content], course)
return utils.JsonResponse({ return utils.JsonResponse({
#'html': html,
'content': content, 'content': content,
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
}) })
......
...@@ -31,22 +31,3 @@ def include_mustache_templates(): ...@@ -31,22 +31,3 @@ def include_mustache_templates():
file_contents = map(read_file, filter(valid_file_name, os.listdir(mustache_dir))) file_contents = map(read_file, filter(valid_file_name, os.listdir(mustache_dir)))
return '\n'.join(map(wrap_in_tag, map(strip_file_name, file_contents))) return '\n'.join(map(wrap_in_tag, map(strip_file_name, file_contents)))
def render_content(content, additional_context={}):
context = {
'content': extend_content(content),
content['type']: True,
}
if cc_settings.MAX_COMMENT_DEPTH is not None:
if content['type'] == 'thread':
if cc_settings.MAX_COMMENT_DEPTH < 0:
context['max_depth'] = True
elif content['type'] == 'comment':
if cc_settings.MAX_COMMENT_DEPTH <= content['depth']:
context['max_depth'] = True
context = merge_dict(context, additional_context)
partial_mustache_helpers = {k: partial(v, content) for k, v in mustache_helpers.items()}
context = merge_dict(context, partial_mustache_helpers)
return render_mustache('discussion/mustache/_content.mustache', context)
from datetime import datetime from datetime import datetime
from pytz import UTC
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from django_comment_common.models import Role, Permission
from django_comment_client.tests.factories import RoleFactory from django_comment_client.tests.factories import RoleFactory
import django_comment_client.utils as utils import django_comment_client.utils as utils
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
class DictionaryTestCase(TestCase): class DictionaryTestCase(TestCase):
def test_extract(self): def test_extract(self):
d = {'cats': 'meow', 'dogs': 'woof'} d = {'cats': 'meow', 'dogs': 'woof'}
...@@ -128,7 +129,13 @@ class CoursewareContextTestCase(ModuleStoreTestCase): ...@@ -128,7 +129,13 @@ class CoursewareContextTestCase(ModuleStoreTestCase):
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class CategoryMapTestCase(ModuleStoreTestCase): class CategoryMapTestCase(ModuleStoreTestCase):
def setUp(self): def setUp(self):
self.course = CourseFactory.create(org="TestX", number="101", display_name="Test Course") self.course = CourseFactory.create(
org="TestX", number="101", display_name="Test Course",
# This test needs to use a course that has already started --
# discussion topics only show up if the course has already started,
# and the default start date for courses is Jan 1, 2030.
start=datetime(2012, 2, 3, tzinfo=UTC)
)
# Courses get a default discussion topic on creation, so remove it # Courses get a default discussion topic on creation, so remove it
self.course.discussion_topics = {} self.course.discussion_topics = {}
self.course.save() self.course.save()
......
...@@ -52,8 +52,21 @@ def _reserve_task(course_id, task_type, task_key, task_input, requester): ...@@ -52,8 +52,21 @@ def _reserve_task(course_id, task_type, task_key, task_input, requester):
""" """
if _task_is_running(course_id, task_type, task_key): if _task_is_running(course_id, task_type, task_key):
log.warning("Duplicate task found for task_type %s and task_key %s", task_type, task_key)
raise AlreadyRunningError("requested task is already running") raise AlreadyRunningError("requested task is already running")
try:
most_recent_id = InstructorTask.objects.latest('id').id
except InstructorTask.DoesNotExist:
most_recent_id = "None found"
finally:
log.warning(
"No duplicate tasks found: task_type %s, task_key %s, and most recent task_id = %s",
task_type,
task_key,
most_recent_id
)
# Create log entry now, so that future requests will know it's running. # Create log entry now, so that future requests will know it's running.
return InstructorTask.create(course_id, task_type, task_key, task_input, requester) return InstructorTask.create(course_id, task_type, task_key, task_input, requester)
......
...@@ -217,14 +217,14 @@ def get_processor_decline_html(params): ...@@ -217,14 +217,14 @@ def get_processor_decline_html(params):
"""Have to parse through the error codes to return a helpful message""" """Have to parse through the error codes to return a helpful message"""
payment_support_email = settings.PAYMENT_SUPPORT_EMAIL payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
msg = _(dedent( msg = dedent(_(
""" """
<p class="error_msg"> <p class="error_msg">
Sorry! Our payment processor did not accept your payment. Sorry! Our payment processor did not accept your payment.
The decision in they returned was <span class="decision">{decision}</span>, The decision they returned was <span class="decision">{decision}</span>,
and the reason was <span class="reason">{reason_code}:{reason_msg}</span>. and the reason was <span class="reason">{reason_code}:{reason_msg}</span>.
You were not charged. Please try a different form of payment. You were not charged. Please try a different form of payment.
Contact us with payment-specific questions at {email}. Contact us with payment-related questions at {email}.
</p> </p>
""")) """))
...@@ -240,7 +240,7 @@ def get_processor_exception_html(exception): ...@@ -240,7 +240,7 @@ def get_processor_exception_html(exception):
payment_support_email = settings.PAYMENT_SUPPORT_EMAIL payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
if isinstance(exception, CCProcessorDataException): if isinstance(exception, CCProcessorDataException):
msg = _(dedent( msg = dedent(_(
""" """
<p class="error_msg"> <p class="error_msg">
Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data!
...@@ -251,7 +251,7 @@ def get_processor_exception_html(exception): ...@@ -251,7 +251,7 @@ def get_processor_exception_html(exception):
""".format(msg=exception.message, email=payment_support_email))) """.format(msg=exception.message, email=payment_support_email)))
return msg return msg
elif isinstance(exception, CCProcessorWrongAmountException): elif isinstance(exception, CCProcessorWrongAmountException):
msg = _(dedent( msg = dedent(_(
""" """
<p class="error_msg"> <p class="error_msg">
Sorry! Due to an error your purchase was charged for a different amount than the order total! Sorry! Due to an error your purchase was charged for a different amount than the order total!
...@@ -261,7 +261,7 @@ def get_processor_exception_html(exception): ...@@ -261,7 +261,7 @@ def get_processor_exception_html(exception):
""".format(msg=exception.message, email=payment_support_email))) """.format(msg=exception.message, email=payment_support_email)))
return msg return msg
elif isinstance(exception, CCProcessorSignatureException): elif isinstance(exception, CCProcessorSignatureException):
msg = _(dedent( msg = dedent(_(
""" """
<p class="error_msg"> <p class="error_msg">
Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are
...@@ -307,32 +307,32 @@ REASONCODE_MAP.update( ...@@ -307,32 +307,32 @@ REASONCODE_MAP.update(
'100': _('Successful transaction.'), '100': _('Successful transaction.'),
'101': _('The request is missing one or more required fields.'), '101': _('The request is missing one or more required fields.'),
'102': _('One or more fields in the request contains invalid data.'), '102': _('One or more fields in the request contains invalid data.'),
'104': _(dedent( '104': dedent(_(
""" """
The merchantReferenceCode sent with this authorization request matches the The merchantReferenceCode sent with this authorization request matches the
merchantReferenceCode of another authorization request that you sent in the last 15 minutes. merchantReferenceCode of another authorization request that you sent in the last 15 minutes.
Possible fix: retry the payment after 15 minutes. Possible fix: retry the payment after 15 minutes.
""")), """)),
'150': _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), '150': _('Error: General system failure. Possible fix: retry the payment after a few minutes.'),
'151': _(dedent( '151': dedent(_(
""" """
Error: The request was received but there was a server timeout. Error: The request was received but there was a server timeout.
This error does not include timeouts between the client and the server. This error does not include timeouts between the client and the server.
Possible fix: retry the payment after some time. Possible fix: retry the payment after some time.
""")), """)),
'152': _(dedent( '152': dedent(_(
""" """
Error: The request was received, but a service did not finish running in time Error: The request was received, but a service did not finish running in time
Possible fix: retry the payment after some time. Possible fix: retry the payment after some time.
""")), """)),
'201': _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), '201': _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'),
'202': _(dedent( '202': dedent(_(
""" """
Expired card. You might also receive this if the expiration date you Expired card. You might also receive this if the expiration date you
provided does not match the date the issuing bank has on file. provided does not match the date the issuing bank has on file.
Possible fix: retry with another form of payment Possible fix: retry with another form of payment
""")), """)),
'203': _(dedent( '203': dedent(_(
""" """
General decline of the card. No other information provided by the issuing bank. General decline of the card. No other information provided by the issuing bank.
Possible fix: retry with another form of payment Possible fix: retry with another form of payment
...@@ -341,7 +341,7 @@ REASONCODE_MAP.update( ...@@ -341,7 +341,7 @@ REASONCODE_MAP.update(
# 205 was Stolen or lost card. Might as well not show this message to the person using such a card. # 205 was Stolen or lost card. Might as well not show this message to the person using such a card.
'205': _('Unknown reason'), '205': _('Unknown reason'),
'207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), '207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'),
'208': _(dedent( '208': dedent(_(
""" """
Inactive card or card not authorized for card-not-present transactions. Inactive card or card not authorized for card-not-present transactions.
Possible fix: retry with another form of payment Possible fix: retry with another form of payment
...@@ -352,13 +352,13 @@ REASONCODE_MAP.update( ...@@ -352,13 +352,13 @@ REASONCODE_MAP.update(
# Might as well not show this message to the person using such a card. # Might as well not show this message to the person using such a card.
'221': _('Unknown reason'), '221': _('Unknown reason'),
'231': _('Invalid account number. Possible fix: retry with another form of payment'), '231': _('Invalid account number. Possible fix: retry with another form of payment'),
'232': _(dedent( '232': dedent(_(
""" """
The card type is not accepted by the payment processor. The card type is not accepted by the payment processor.
Possible fix: retry with another form of payment Possible fix: retry with another form of payment
""")), """)),
'233': _('General decline by the processor. Possible fix: retry with another form of payment'), '233': _('General decline by the processor. Possible fix: retry with another form of payment'),
'234': _(dedent( '234': dedent(_(
""" """
There is a problem with our CyberSource merchant configuration. Please let us know at {0} There is a problem with our CyberSource merchant configuration. Please let us know at {0}
""".format(settings.PAYMENT_SUPPORT_EMAIL))), """.format(settings.PAYMENT_SUPPORT_EMAIL))),
...@@ -370,7 +370,7 @@ REASONCODE_MAP.update( ...@@ -370,7 +370,7 @@ REASONCODE_MAP.update(
# reason code 239 only applies if we are processing a capture or credit through the API, # reason code 239 only applies if we are processing a capture or credit through the API,
# so we should never see it # so we should never see it
'239': _('The requested transaction amount must match the previous transaction amount.'), '239': _('The requested transaction amount must match the previous transaction amount.'),
'240': _(dedent( '240': dedent(_(
""" """
The card type sent is invalid or does not correlate with the credit card number. The card type sent is invalid or does not correlate with the credit card number.
Possible fix: retry with the same card or another form of payment Possible fix: retry with the same card or another form of payment
...@@ -382,26 +382,26 @@ REASONCODE_MAP.update( ...@@ -382,26 +382,26 @@ REASONCODE_MAP.update(
# if the previously successful authorization has already been used by another capture request. # if the previously successful authorization has already been used by another capture request.
# This reason code only applies when we are processing a capture through the API # This reason code only applies when we are processing a capture through the API
# so we should never see it # so we should never see it
'242': _(dedent( '242': dedent(_(
""" """
You requested a capture through the API, but there is no corresponding, unused authorization record. You requested a capture through the API, but there is no corresponding, unused authorization record.
""")), """)),
# we should never see 243 # we should never see 243
'243': _('The transaction has already been settled or reversed.'), '243': _('The transaction has already been settled or reversed.'),
# reason code 246 applies only if we are processing a void through the API. so we should never see it # reason code 246 applies only if we are processing a void through the API. so we should never see it
'246': _(dedent( '246': dedent(_(
""" """
The capture or credit is not voidable because the capture or credit information has already been The capture or credit is not voidable because the capture or credit information has already been
submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided. submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided.
""")), """)),
# reason code 247 applies only if we are processing a void through the API. so we should never see it # reason code 247 applies only if we are processing a void through the API. so we should never see it
'247': _('You requested a credit for a capture that was previously voided'), '247': _('You requested a credit for a capture that was previously voided'),
'250': _(dedent( '250': dedent(_(
""" """
Error: The request was received, but there was a timeout at the payment processor. Error: The request was received, but there was a timeout at the payment processor.
Possible fix: retry the payment. Possible fix: retry the payment.
""")), """)),
'520': _(dedent( '520': dedent(_(
""" """
The authorization request was approved by the issuing bank but declined by CyberSource.' The authorization request was approved by the issuing bank but declined by CyberSource.'
Possible fix: retry with a different form of payment. Possible fix: retry with a different form of payment.
......
...@@ -23,8 +23,10 @@ function quickElement() { ...@@ -23,8 +23,10 @@ function quickElement() {
// CalendarNamespace -- Provides a collection of HTML calendar-related helper functions // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions
var CalendarNamespace = { var CalendarNamespace = {
monthsOfYear: gettext('January February March April May June July August September October November December').split(' '), // Translators: the names of months, keep the pipe (|) separators.
daysOfWeek: gettext('S M T W T F S').split(' '), monthsOfYear: gettext('January|February|March|April|May|June|July|August|September|October|November|December').split('|'),
// Translators: abbreviations for days of the week, keep the pipe (|) separators.
daysOfWeek: gettext('S|M|T|W|T|F|S').split('|'),
firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')),
isLeapYear: function(year) { isLeapYear: function(year) {
return (((year % 4)==0) && ((year % 100)!=0) || ((year % 400)==0)); return (((year % 4)==0) && ((year % 100)!=0) || ((year % 400)==0));
......
...@@ -29,8 +29,10 @@ if (typeof Array.prototype.filter == 'undefined') { ...@@ -29,8 +29,10 @@ if (typeof Array.prototype.filter == 'undefined') {
}; };
} }
var monthNames = gettext("January February March April May June July August September October November December").split(" "); // Translators: the names of months, keep the pipe (|) separators.
var weekdayNames = gettext("Sunday Monday Tuesday Wednesday Thursday Friday Saturday").split(" "); var monthNames = gettext("January|February|March|April|May|June|July|August|September|October|November|December").split("|");
// Translators: the names of days, keep the pipe (|) separators.
var weekdayNames = gettext("Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday").split("|");
/* Takes a string, returns the index of the month matching that string, throws /* Takes a string, returns the index of the month matching that string, throws
an error if 0 or more than 1 matches an error if 0 or more than 1 matches
......
...@@ -231,10 +231,6 @@ ...@@ -231,10 +231,6 @@
letter-spacing: 1px; letter-spacing: 1px;
margin-right: 10px; margin-right: 10px;
padding-right: 10px; padding-right: 10px;
&:hover, &:focus {
color: $link-color;
}
} }
.start-date { .start-date {
......
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%namespace file='main.html' import="stanford_theme_enabled"/>
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -26,11 +25,7 @@ from courseware.courses import course_image_url, get_course_about_section ...@@ -26,11 +25,7 @@ from courseware.courses import course_image_url, get_course_about_section
<p>${get_course_about_section(course, 'short_description')}</p> <p>${get_course_about_section(course, 'short_description')}</p>
</div> </div>
<div class="bottom"> <div class="bottom">
% if stanford_theme_enabled(): <span class="university">${get_course_about_section(course, 'university')}</span>
<span class="university">${get_course_about_section(course, 'university')}</span>
% else:
<a href="#" class="university">${get_course_about_section(course, 'university')}</a>
% endif
<span class="start-date">${course.start_date_text}</span> <span class="start-date">${course.start_date_text}</span>
</div> </div>
</section> </section>
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
<%inherit file="/main.html" /> <%inherit file="/main.html" />
<%namespace name='static' file='../static_content.html'/> <%namespace name='static' file='../static_content.html'/>
<%block name="bodyclass">courseware</%block> <%block name="bodyclass">courseware</%block>
## Translators: "edX" should *not* be translated
<%block name="title"><title>${_("Courseware")} - ${settings.PLATFORM_NAME}</title></%block> <%block name="title"><title>${_("Courseware")} - ${settings.PLATFORM_NAME}</title></%block>
<%block name="headextra"> <%block name="headextra">
......
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_comments(thread.get('children'))}
<%! import django_comment_client.helpers as helpers %>
<%def name="render_content(content, *args, **kwargs)">
${helpers.render_content(content, *args, **kwargs)}
</%def>
<%def name="render_content_with_comments(content, *args, **kwargs)">
<div class="${content['type'] | h}${' endorsed' if content.get('endorsed') else ''| h}" _id="${content['id'] | h}" _discussion_id="${content.get('commentable_id', '') | h}" _author_id="${content['user_id'] if (not content.get('anonymous')) else '' | h}">
${render_content(content, *args, **kwargs)}
${render_comments(content.get('children', []), *args, **kwargs)}
</div>
</%def>
<%def name="render_comments(comments, *args, **kwargs)">
<div class="comments">
% for comment in comments:
${render_content_with_comments(comment, *args, **kwargs)}
% endfor
</div>
</%def>
<%namespace name="renderer" file="_content_renderer.html"/>
<section class="discussion forum-discussion" _id="${discussion_id | h}">
<div class="discussion-non-content local">
<div class="search-wrapper">
<%include file="_search_bar.html" />
</div>
</div>
% if len(threads) == 0:
<div class="blank">
<%include file="_blank_slate.html" />
</div>
<div class="threads"></div>
% else:
<%include file="_sort.html" />
<div class="threads">
% for thread in threads:
${renderer.render_content_with_comments(thread)}
% endfor
</div>
<%include file="_paginator.html" />
% endif
</section>
<%include file="_js_data.html" />
<%namespace name="renderer" file="_content_renderer.html"/>
<section class="discussion inline-discussion" _id="${discussion_id | h}">
<div class="discussion-non-content local"></div>
<div class="threads">
% for thread in threads:
${renderer.render_content_with_comments(thread)}
% endfor
</div>
<%include file="_paginator.html" />
</section>
<%include file="_js_data.html" />
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<script type="text/javascript" src="${static.url('js/jquery.timeago.js')}"></script> <script type="text/javascript" src="${static.url('js/jquery.timeago.js')}"></script>
<script type="text/javascript" src="${static.url('js/jquery.tagsinput.js')}"></script> <script type="text/javascript" src="${static.url('js/jquery.tagsinput.js')}"></script>
<script type="text/javascript" src="${static.url('js/mustache.js')}"></script> <script type="text/javascript" src="${static.url('js/mustache.js')}"></script>
<script type="text/javascript" src="${static.url('js/URI.min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/URI.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<script type="text/javascript" src="${static.url('js/jquery.timeago.js')}"></script> <script type="text/javascript" src="${static.url('js/jquery.timeago.js')}"></script>
<script type="text/javascript" src="${static.url('js/jquery.tagsinput.js')}"></script> <script type="text/javascript" src="${static.url('js/jquery.tagsinput.js')}"></script>
<script type="text/javascript" src="${static.url('js/mustache.js')}"></script> <script type="text/javascript" src="${static.url('js/mustache.js')}"></script>
<script type="text/javascript" src="${static.url('js/URI.min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/URI.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
......
<%! from django.utils.translation import ugettext as _ %>
<%namespace name="renderer" file="_content_renderer.html"/>
<%! from django_comment_client.mustache_helpers import url_for_user %>
<article class="discussion-article" data-id="${discussion_id| h}">
<a href="#" class="dogear"></a>
<div class="discussion-post">
<header>
%if thread['group_id']:
<div class="group-visibility-label">${_("This post visible only to group {group}.").format(group=cohort_dictionary[thread['group_id']])} </div>
%endif
<a href="#" class="vote-btn discussion-vote discussion-vote-up"><span class="plus-icon">+</span> <span class='votes-count-number'>${thread['votes']['up_count']}<span class="sr">votes (click to vote)</span></span></a>
<h1>${thread['title']}</h1>
<p class="posted-details">
<span class="timeago" title="${thread['created_at'] | h}">sometime</span> by
<a href="${url_for_user(thread, thread['user_id'])}">${thread['username']}</a>
</p>
</header>
<div class="post-body">
${thread['body']}
</div>
</div>
<ol class="responses">
% for reply in thread.get("children", []):
<li>
<div class="response-body">${reply['body']}</div>
<ol class="comments">
% for comment in reply.get("children", []):
<li><div class="comment-body">${comment['body']}</div></li>
% endfor
</ol>
</li>
% endfor
</ol>
</article>
<%include file="_js_data.html" />
...@@ -31,8 +31,8 @@ ...@@ -31,8 +31,8 @@
<div class="group-visibility-label">${"<%- obj.group_string%>"}</div> <div class="group-visibility-label">${"<%- obj.group_string%>"}</div>
${"<% } %>"} ${"<% } %>"}
<a href="#" class="vote-btn discussion-vote discussion-vote-up" data-role="discussion-vote" data-tooltip="vote"> <a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false">
<span class="plus-icon">+</span> <span class='votes-count-number'>${'<%- votes["up_count"] %>'}<span class="sr">votes (click to vote)</span></span></a> <span class="plus-icon"/><span class='votes-count-number'>${'<%- votes["up_count"] %>'}</span> <span class="sr">votes (click to vote)</span></a>
<h1>${'<%- title %>'}</h1> <h1>${'<%- title %>'}</h1>
<p class="posted-details"> <p class="posted-details">
${"<% if (obj.username) { %>"} ${"<% if (obj.username) { %>"}
...@@ -123,7 +123,7 @@ ...@@ -123,7 +123,7 @@
<script type="text/template" id="thread-response-show-template"> <script type="text/template" id="thread-response-show-template">
<header class="response-local"> <header class="response-local">
<a href="javascript:void(0)" class="vote-btn" data-tooltip="vote"><span class="plus-icon"></span><span class="votes-count-number">${"<%- votes['up_count'] %>"}<span class="sr">votes (click to vote)</span></span></a> <a href="#" class="vote-btn" data-tooltip="vote" role="button" aria-pressed="false"><span class="plus-icon"/><span class="votes-count-number">${"<%- votes['up_count'] %>"}</span> <span class="sr">votes (click to vote)</span></a>
<a href="javascript:void(0)" class="endorse-btn${'<% if (endorsed) { %> is-endorsed<% } %>'} action-endorse" style="cursor: default; display: none;" data-tooltip="endorse"><span class="check-icon" style="pointer-events: none; "></span></a> <a href="javascript:void(0)" class="endorse-btn${'<% if (endorsed) { %> is-endorsed<% } %>'} action-endorse" style="cursor: default; display: none;" data-tooltip="endorse"><span class="check-icon" style="pointer-events: none; "></span></a>
${"<% if (obj.username) { %>"} ${"<% if (obj.username) { %>"}
<a href="${'<%- user_url %>'}" class="posted-by">${'<%- username %>'}</a> <a href="${'<%- user_url %>'}" class="posted-by">${'<%- username %>'}</a>
......
<%namespace name="renderer" file="_content_renderer.html"/>
<section class="discussion user-active-discussion" _id="${user_id | h}">
<div class="discussion-non-content local"></div>
<div class="threads">
% for thread in threads:
${renderer.render_content_with_comments(thread, {'partial_comments': True})}
% endfor
</div>
<%include file="_paginator.html" />
</section>
<%include file="_js_data.html" />
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content_with_comments(content)}
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content_with_comments(content)}
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content(content)}
<%namespace name="renderer" file="_content_renderer.html"/>
${renderer.render_content(content)}
<div class="discussion-content local{{#content.roles}} role-{{name}}{{/content.roles}}">
CONTENT MUSTACHE
<div class="discussion-content-wrapper">
<div class="discussion-votes">
<a class="discussion-vote discussion-vote-up" href="javascript:void(0)" value="up">&#9650;</a>
<div class="discussion-votes-point">{{content.votes.point}}<span class="sr">votes (click to vote)</span></div>
<a class="discussion-vote discussion-vote-down" href="javascript:void(0)" value="down">&#9660;</a>
</div>
<div class="discussion-right-wrapper">
<ul class="admin-actions">
<li style="display: none;"><a href="javascript:void(0)" class="admin-endorse">Endorse</a></li>
<li style="display: none;"><a href="javascript:void(0)" class="admin-edit">Edit</a></li>
<li style="display: none;"><a href="javascript:void(0)" class="admin-delete">Delete</a></li>
{{#thread}}
<li style="display: none;"><a href="javascript:void(0)" class="admin-openclose">{{close_thread_text}}</a></li>
{{/thread}}
</ul>
{{#thread}}
<a class="thread-title" name="{{content.id}}" href="javascript:void(0)">{{content.displayed_title}}</a>
{{/thread}}
<div class="discussion-content-view">
<a name="{{content.id}}" style="width: 0; height: 0; padding: 0; border: none;"></a>
<div class="content-body {{content.type}}-body" id="content-body-{{content.id}}">{{content.displayed_body}}</div>
{{#thread}}
<div class="thread-tags">
{{#content.tags}}
<a class="thread-tag" href="{{##url_for_tags}}{{.}}{{/url_for_tags}}">{{.}}</a>
{{/content.tags}}
</div>
{{/thread}}
<div class="context">
{{#content.courseware_location}}
(this post is about <a href="../../jump_to/{{content.courseware_location}}">{{content.courseware_title}}</a>)
{{/content.courseware_location}}
</div>
<div class="info">
<div class="comment-time">
{{#content.updated}}
updated
{{/content.updated}}
<span class="timeago" title="{{content.updated_at}}">{{content.created_at}}</span> by
{{#content.anonymous}}
anonymous
{{/content.anonymous}}
{{^content.anonymous}}
<a href="{{##url_for_user}}{{content.user_id}}{{/url_for_user}}" class="{{#content.roles}}author-{{name}} {{/content.roles}}">{{content.username}}</a>
{{/content.anonymous}}
</div>
<div class="show-comments-wrapper">
{{#thread}}
{{#partial_comments}}
<a href="javascript:void(0)" class="discussion-show-comments first-time">Show all comments (<span class="comments-count">{{content.comments_count}}</span> total)</a>
{{/partial_comments}}
{{^partial_comments}}
<a href="javascript:void(0)" class="discussion-show-comments">Show <span class="comments-count">{{content.comments_count}}</span> {{##pluralize}}{{content.comments_count}} comment{{/pluralize}}</a>
{{/partial_comments}}
{{/thread}}
</div>
<ul class="discussion-actions">
{{^max_depth}}
<li><a class="discussion-link discussion-reply discussion-reply-{{content.type}}" href="javascript:void(0)">Reply</a></li>
{{/max_depth}}
{{#thread}}
<li><div class="follow-wrapper"><a class="discussion-link discussion-follow-thread" href="javascript:void(0)">Follow</a></div></li>
{{/thread}}
<li><a class="discussion-link discussion-permanent-link" href="{{content.permalink}}">Permanent Link</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<form class="discussion-content-edit discussion-comment-edit" _id="{{id}}">
<ul class="discussion-errors discussion-update-errors"></ul>
<div class="comment-body-edit body-input">{{body}}</div>
<div class = "edit-post-control">
<a class="discussion-cancel-update" href="javascript:void(0)">Cancel</a>
<a class="discussion-submit-update control-button" href="javascript:void(0)">Update</a>
</div>
</form>
<form class="discussion-content-edit discussion-thread-edit" _id="{{id}}">
<ul class="discussion-errors discussion-update-errors"></ul>
<input type="text" class="thread-title-edit title-input" placeholder="Title" value="{{title}}"/>
<div class="thread-body-edit body-input">{{body}}</div>
<input class="thread-tags-edit" placeholder="Tags" value="{{tags}}" />
<div class = "edit-post-control">
<a class="discussion-cancel-update" href="javascript:void(0)">Cancel</a>
<a class="discussion-submit-update control-button" href="javascript:void(0)">Update</a>
</div>
</form>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment