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,
in roughly chronological order, most recent first. Add your entries at or near
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.
Blades: Video player improvements:
......
......@@ -4,6 +4,7 @@
from lettuce import world
from nose.tools import assert_equal, assert_in # pylint: disable=E0611
from terrain.steps import reload_the_page
from common import type_in_codemirror
@world.absorb
......@@ -114,6 +115,16 @@ def edit_component():
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
def verify_setting_entry(setting, display_name, value, explicitly_set):
"""
......
......@@ -9,3 +9,11 @@ Feature: Course export
And I export the course
Then I get an error dialog
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 @@
#pylint: disable=C0111
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
......@@ -16,9 +16,7 @@ def i_export_the_course(step):
@step('I edit and enter bad XML$')
def i_enter_bad_xml(step):
world.edit_component()
type_in_codemirror(
0,
enter_xml_in_advanced_problem(step,
"""<problem><h1>Smallest Canvas</h1>
<p>You want to make the smallest canvas you can.</p>
<multiplechoiceresponse>
......@@ -29,7 +27,11 @@ def i_enter_bad_xml(step):
</multiplechoiceresponse>
</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$')
......
......@@ -24,11 +24,21 @@ from xmodule.modulestore.mongo.base import location_to_query
class AssetsTestCase(CourseTestCase):
"""
Parent class for all asset tests.
"""
def setUp(self):
super(AssetsTestCase, self).setUp()
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
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):
resp = self.client.get(self.url, HTTP_ACCEPT='text/html')
self.assertEquals(resp.status_code, 200)
......@@ -38,12 +48,7 @@ class AssetsTestCase(CourseTestCase):
path = StaticContent.get_static_path_from_location(location)
self.assertEquals(path, '/static/my_file_name.jpg')
class AssetsToyCourseTestCase(CourseTestCase):
"""
Tests the assets returned from assets_handler for the toy test course.
"""
def test_toy_assets(self):
def test_pdf_asset(self):
module_store = modulestore('direct')
_, course_items = import_from_xml(
module_store,
......@@ -56,9 +61,35 @@ class AssetsToyCourseTestCase(CourseTestCase):
location = loc_mapper().translate_location(course.location.course_id, course.location, False, True)
url = location.url_reverse('assets/', '')
self.assert_correct_asset_response(url, 0, 3, 3)
self.assert_correct_asset_response(url + "?page_size=2", 0, 2, 3)
self.assert_correct_asset_response(url + "?page_size=2&page=1", 2, 1, 3)
# Test valid contentType for pdf asset (textbook.pdf)
resp = self.client.get(url, HTTP_ACCEPT='application/json')
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):
resp = self.client.get(url, HTTP_ACCEPT='application/json')
......@@ -69,7 +100,7 @@ class AssetsToyCourseTestCase(CourseTestCase):
self.assertEquals(json_response['totalCount'], expected_total)
class UploadTestCase(CourseTestCase):
class UploadTestCase(AssetsTestCase):
"""
Unit tests for uploading a file
"""
......@@ -78,11 +109,8 @@ class UploadTestCase(CourseTestCase):
location = loc_mapper().translate_location(self.course.location.course_id, self.course.location, False, True)
self.url = location.url_reverse('assets/', '')
@skip("CorruptGridFile error on continuous integration server")
def test_happy_path(self):
f = BytesIO("sample content")
f.name = "sample.txt"
resp = self.client.post(self.url, {"name": "my-name", "file": f})
resp = self.upload_asset()
self.assertEquals(resp.status_code, 200)
def test_no_file(self):
......@@ -90,7 +118,7 @@ class UploadTestCase(CourseTestCase):
self.assertEquals(resp.status_code, 400)
class AssetToJsonTestCase(TestCase):
class AssetToJsonTestCase(AssetsTestCase):
"""
Unit test for transforming asset information into something
we can send out to the client via JSON.
......@@ -115,7 +143,7 @@ class AssetToJsonTestCase(TestCase):
self.assertIsNone(output["thumbnail"])
class LockAssetTestCase(CourseTestCase):
class LockAssetTestCase(AssetsTestCase):
"""
Unit test for locking and unlocking an asset.
"""
......
"""Tests for items views."""
import json
import datetime
from datetime import datetime
import ddt
from mock import Mock, patch
......@@ -149,6 +149,13 @@ class TestCreateItem(ItemTest):
resp = self.create_xblock(category='problem', boilerplate='nosuchboilerplate.yaml')
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):
"""
......@@ -214,14 +221,14 @@ class TestEditItem(ItemTest):
data={'metadata': {'due': '2010-11-22T04:00Z'}}
)
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.seq_update_url,
data={'metadata': {'start': '2010-09-12T14:00Z'}}
)
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.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
self.assertEqual(sequential.due, datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(sequential.start, datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
def test_delete_child(self):
"""
......@@ -326,7 +333,7 @@ class TestEditItem(ItemTest):
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due)
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):
""" Update a problem and make it public at the same time. """
......@@ -338,7 +345,7 @@ class TestEditItem(ItemTest):
}
)
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):
""" Make a problem private and update it at the same time. """
......@@ -357,7 +364,7 @@ class TestEditItem(ItemTest):
with self.assertRaises(ItemNotFoundError):
self.get_item_from_modulestore(self.problem_locator, False)
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):
""" Create a draft and update it at the same time. """
......@@ -378,7 +385,7 @@ class TestEditItem(ItemTest):
published = self.get_item_from_modulestore(self.problem_locator, False)
self.assertIsNone(published.due)
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
......
......@@ -72,7 +72,7 @@ class CourseTestCase(ModuleStoreTestCase):
email = 'test+courses@edx.org'
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)
# Note that we do not actually need to do anything
......
......@@ -27,7 +27,7 @@ from django.http import HttpResponseNotFound
import json
from django.utils.translation import ugettext as _
from pymongo import DESCENDING
import math
__all__ = ['assets_handler']
......@@ -91,15 +91,18 @@ def _assets_json(request, location):
"""
requested_page = int(request.REQUEST.get('page', 0))
requested_page_size = int(request.REQUEST.get('page_size', 50))
sort = [('uploadDate', DESCENDING)]
current_page = max(requested_page, 0)
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)
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)]
)
# 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 = []
......@@ -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
@ensure_csrf_cookie
@login_required
......
......@@ -94,7 +94,6 @@ require(["domReady!", "gettext", "js/views/feedback_prompt"], function(doc, gett
<h2 class="title">${_("About Exporting Courses")}</h2>
<div class="copy">
## 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>
</div>
</div>
......
......@@ -126,20 +126,16 @@
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<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>
</div>
<div class="bit">
<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>
</div>
<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>
<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>
</aside>
......
<%! from django.utils.translation import ugettext as _ %>
<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):
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)
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
# time of first invocation of this stmt on the server
default=datetime.fromtimestamp(0, UTC()),
default=datetime(2030, 1, 1, tzinfo=UTC()),
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)
......
......@@ -160,7 +160,10 @@ class @Annotatable
@hideTips visible
toggleAnnotationButtonText: (hide) ->
buttonText = (if hide then 'Show' else 'Hide')+' Annotations'
if hide
buttonText = gettext('Show Annotations')
else
buttonText = gettext('Hide Annotations')
@$(@toggleAnnotationsSelector).text(buttonText)
toggleInstructions: () ->
......@@ -169,7 +172,10 @@ class @Annotatable
@toggleInstructionsText 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'])
@$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1])
......@@ -221,13 +227,14 @@ class @Annotatable
makeTipTitle: (el) ->
(api) =>
title = $(el).data('comment-title')
(if title then title else 'Commentary')
(if title then title else gettext('Commentary'))
createComment: (text) ->
$("<div class=\"annotatable-comment\">#{text}</div>")
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: () ->
visible = []
......
......@@ -318,14 +318,16 @@ class @Problem
@el.find('.problem > div').each (index, 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'
@updateProgress response
else
@$('[id^=answer_], [id^=solution_]').text ''
@$('[correct_answer]').attr correct_answer: null
@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) =>
display = @inputtypeDisplays[$(inputtype).attr('id')]
......@@ -403,6 +405,7 @@ class @Problem
formulaequationinput: (element) ->
$(element).find('input').on 'input', ->
$p = $(element).find('p.status')
`// Translators: the word unanswered here is about answering a problem the student must solve.`
$p.text gettext("unanswered")
$p.parent().removeClass().addClass "unanswered"
......@@ -431,7 +434,8 @@ class @Problem
textline: (element) ->
$(element).find('input').on 'input', ->
$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"
inputtypeSetupMethods:
......
......@@ -17,7 +17,7 @@ class InheritanceMixin(XBlockMixin):
start = Date(
help="Start time when this module is visible",
default=datetime.fromtimestamp(0, UTC),
default=datetime(2030, 1, 1, tzinfo=UTC),
scope=Scope.settings
)
due = Date(help="Date that this problem is due by", scope=Scope.settings)
......
......@@ -55,8 +55,6 @@ class CourseFactory(XModuleFactory):
# Write the data to the mongo datastore
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:
for k, v in kwargs.iteritems():
setattr(new_course, k, v)
......
......@@ -33,6 +33,7 @@ def import_static_content(
policy = {}
verbose = True
mimetypes_list = mimetypes.types_map.values()
for dirname, _, filenames in os.walk(static_dir):
for filename in filenames:
......@@ -64,10 +65,11 @@ def import_static_content(
policy_ele = policy.get(content_loc.name, {})
displayname = policy_ele.get('displayname', filename)
locked = policy_ele.get('locked', False)
mime_type = policy_ele.get(
'contentType',
mimetypes.guess_type(filename)[0]
)
mime_type = policy_ele.get('contentType')
# 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_loc, displayname, mime_type, data,
import_path=fullname_with_subpath, locked=locked
......
import unittest
import datetime
from datetime import datetime
from fs.memoryfs import MemoryFS
......@@ -13,7 +13,15 @@ from django.utils.timezone import UTC
ORG = 'test_org'
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):
......@@ -77,7 +85,7 @@ class IsNewCourseTestCase(unittest.TestCase):
# Needed for test_is_newish
datetime_patcher = patch.object(
xmodule.course_module, 'datetime',
Mock(wraps=datetime.datetime)
Mock(wraps=datetime)
)
mocked_datetime = datetime_patcher.start()
mocked_datetime.now.return_value = NOW
......
......@@ -228,9 +228,11 @@ class ImportTestCase(BaseCourseTestCase):
# Check that the child does not inherit a value for due
child = descriptor.get_children()[0]
self.assertEqual(child.due, None)
# Check that the child hasn't started yet
self.assertLessEqual(
child.start,
datetime.datetime.now(UTC())
datetime.datetime.now(UTC()),
child.start
)
def test_metadata_override_default(self):
......
describe "DiscussionContentView", ->
beforeEach ->
setFixtures
(
setFixtures(
"""
<div class="discussion-post">
<header>
<a data-tooltip="vote" data-role="discussion-vote" class="vote-btn discussion-vote discussion-vote-up" href="#">
<span class="plus-icon">+</span> <span class="votes-count-number">0</span></a>
<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>
<h1>Post Title</h1>
<p class="posted-details">
<a class="username" href="/courses/MITx/999/Robot_Super_Course/discussion/forum/users/1">robot</a>
......@@ -23,16 +22,21 @@ describe "DiscussionContentView", ->
"""
)
@thread = new Thread {
@threadData = {
id: '01234567',
user_id: '567',
course_id: 'mitX/999/test',
body: 'this is a thread',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123']
abuse_flaggers: ['123'],
votes: {up_count: '42'},
type: "thread",
roles: []
}
@thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread })
@view.setElement($('.discussion-post'))
window.user = new DiscussionUser({id: '567', upvoted_ids: []})
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
......@@ -56,3 +60,15 @@ describe "DiscussionContentView", ->
@thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse()
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?
@get("abuse_flaggers").pop(window.user.get('id'))
@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
urlMappers:
......@@ -130,14 +137,6 @@ if Backbone?
unfollow: ->
@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: ->
if @has("highlighted_body")
String(@get("highlighted_body")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
......
......@@ -91,7 +91,7 @@ class @DiscussionUtil
@activateOnEnter: (event, func) ->
if event.which == 13
e.preventDefault()
event.preventDefault()
func(event)
@makeFocusTrap: (elem) ->
......
......@@ -159,3 +159,42 @@ if Backbone?
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?
class @DiscussionThreadProfileView extends DiscussionContentView
expanded = false
events:
"click .discussion-vote": "toggleVote"
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnEnter(event, @toggleVote)
"click .action-follow": "toggleFollowing"
"keypress .action-follow":
(event) -> DiscussionUtil.activateOnEnter(event, toggleFollowing)
......@@ -27,7 +30,7 @@ if Backbone?
@$el.html(Mustache.render(@template, params))
@initLocal()
@delegateEvents()
@renderVoted()
@renderVote()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
......@@ -35,15 +38,8 @@ if Backbone?
@renderResponses()
@
renderVoted: =>
if window.user.voted(@model)
@$("[data-role=discussion-vote]").addClass("is-cast")
else
@$("[data-role=discussion-vote]").removeClass("is-cast")
updateModelDetails: =>
@renderVoted()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"])
@renderVote()
convertMath: ->
element = @$(".post-body")
......@@ -71,35 +67,6 @@ if Backbone?
addComment: =>
@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: ->
abbreviateBody: ->
......
......@@ -2,7 +2,10 @@ if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView
events:
"click .discussion-vote": "toggleVote"
"click .vote-btn":
(event) -> @toggleVote(event)
"keydown .vote-btn":
(event) -> DiscussionUtil.activateOnEnter(event, @toggleVote)
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keypress .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnEnter(event, toggleFlagAbuse)
......@@ -28,7 +31,7 @@ if Backbone?
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderVoted()
@renderVote()
@renderFlagged()
@renderPinned()
@renderAttrs()
......@@ -38,14 +41,6 @@ if Backbone?
@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: =>
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")
......@@ -70,52 +65,15 @@ if Backbone?
updateModelDetails: =>
@renderVoted()
@renderVote()
@renderFlagged()
@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: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
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) ->
@trigger "thread:edit", event
......
if Backbone?
class @ThreadResponseShowView extends DiscussionContentView
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-delete": "_delete"
"click .action-edit": "edit"
......@@ -23,9 +26,7 @@ if Backbone?
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
if window.user.voted(@model)
@$(".vote-btn").addClass("is-cast")
@$(".vote-btn span.sr").html("votes (click to remove your vote)")
@renderVote()
@renderAttrs()
@renderFlagged()
@$el.find(".posted-details").timeago()
......@@ -46,39 +47,6 @@ if Backbone?
@$el.addClass("community-ta")
@$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) ->
@trigger "response:edit", event
......@@ -115,4 +83,5 @@ if Backbone?
@$(".discussion-flag-abuse .flag-label").html("Report Misuse")
updateModelDetails: =>
@renderVote()
@renderFlagged()
......@@ -34,6 +34,7 @@ lib_paths:
- js/vendor/underscore-min.js
- js/vendor/backbone-min.js
- js/vendor/jquery.timeago.js
- js/vendor/URI.min.js
- coffee/src/ajax_prefix.js
- js/test/add_ajax_prefix.js
- coffee/src/jquery.immediateDescendents.js
......
<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
============== ================================================================
DATE CHANGE
============== ================================================================
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.
12/10/2013 Added MathJax appendix
12/11/2013 Added Accessibility chapter
12/12/2013 Added Glossary
============== ================================================================
......@@ -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.
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**.
#. Scroll down to the **Introducing Your Course** section, then locate the **Course Overview** field.
......@@ -234,6 +238,25 @@ To add a file:
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
==================
To link to the file from a course component, update, or the course handout list, you must get the file URL.
......
......@@ -4,9 +4,9 @@
Working with Problem Components
################################
*********
Overview
*********
******************************
Overview of Problem Components
******************************
The problem component allows you to add interactive, automatically
graded exercises to your course content. You can create many different
......@@ -17,7 +17,7 @@ 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
problems.
See the following topics:
For more information, see the following topics.
* :ref:`Components and the User Interface`
* :ref:`Problem Settings`
......
......@@ -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:`Create Your First Course`
* :ref:`View Your Course on Edge`
* :ref:`What is 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.
......@@ -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 also used to host SPOCs, or Small Private Online Courses.
Visually and functionally, edX Edge is the same as edX.org_.
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.
......@@ -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.
.. _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:
************************************
......
......@@ -12,6 +12,7 @@ Contents
:maxdepth: 5
read_me
change_log
get_started
create_new_course
establish_grading_policy
......@@ -32,7 +33,7 @@ Contents
checking_student_progress
ora_students
glossary
change_log
......
......@@ -19,7 +19,7 @@ You organize your course in the following hierarchy:
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.
......@@ -48,18 +48,18 @@ The following example shows how a student would view this course content:
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 field that opens at the top of the outline, enter the new Section name.
#. Click **Save**.
The new, empty Section is placed at the bottom of the course outline.
You must now add Subsections to the Section.
The new, empty section is placed at the bottom of the course outline.
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.
.. _Subsections:
......@@ -68,54 +68,54 @@ See :ref:`Publishing Your Course` for more information.
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
Subsection. For more information on grading, see LINK.
subsection. See :ref:`Establish a Grading Policy` for more information on grading.
To create a Subsection:
#. Within the Section, click **New Subsection**.
#. In the field that opens at the bottom of the section, enter the new Subsection name.
#. At the bottom of the section, click **New Subsection**.
#. In the field that opens, enter the new Subsection name.
#. Click **Save**.
The new, empty Subsection is placed at the bottom of the Section.
You must now add Units to the Subsection.
The new, empty subsection is placed at the bottom of the section.
You must now add Units to the subsection.
Whether or not students see the new Subsection depends on its release date.
See LINK for more information on releasing your course.
Whether or not students see the new subsection depends on its release date.
See :ref:`Publishing Your Course` for more information.
==================
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
set the Subsection release date, set a due date, preview a draft of the Subsection, or view the live course.
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.
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
: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
: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
:width: 800
......@@ -127,9 +127,9 @@ See :ref:`Establish a Grading Policy` for more information.
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 Time field and pick a time.
......@@ -145,10 +145,10 @@ For more information, see :ref:`Establish a Grading Policy`.
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.
The following page shows a Subsection that has nine Units:
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:
.. image:: Images/units_students.png
:width: 800
......@@ -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.
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.
#. 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
#. 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.
#. 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 :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:
......@@ -192,10 +192,10 @@ You add the first component when creating 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.
#. In the **Add New Component** panel at the bottom of the Unit, click the type of Component to add.
#. 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.
.. 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 Video Components`
......@@ -212,12 +212,12 @@ Reorganize Your Course
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:
.. image:: Images/drag_drop.png
: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.
\ No newline at end of file
You can reorganize components within a unit in the same way.
\ No newline at end of file
.. _Working with LTI Components:
.. _Tools:
Working with LTI Components
============================
#############################
Working with Tools
#############################
Introduction to LTI Components
------------------------------
***************************
Overview of Tools in Studio
***************************
In addition to text, images, and different types of problems, Studio allows you
to add customized learning tools such as word clouds to your course.
- :ref:`LTI Component`: LTI components allow you to add an external learning application
or textbook to Studio.
- :ref:`Word Cloud`: Word clouds arrange text that students enter - for example, in
response to a question - into a colorful graphic that students can see.
- :ref:`Zooming image`: Zooming images allow you to enlarge sections of an image so
that students can see the section in detail.
.. _LTI Component:
**************
LTI Components
**************
You may have discovered or developed an external learning application
that you want to add to your online course. Or, you may have a digital
......@@ -73,7 +92,7 @@ Step 1. Add LTI to the Advanced Modules Policy Key
then enter **“lti”**. Make sure to include the quotation marks, but
not the period.
.. image:: Images/LTI_policy_key.gif
.. image:: Images/LTI_Policy_Key.gif
**Note** If the **Policy Value** field already contains text, place your
cursor directly after the closing quotation mark for the final item, and
......@@ -186,3 +205,85 @@ Step 3. Add the LTI Component to a Unit
a student’s score can be any value between 0 and 1.
For more information about problem weights and computing point scores, see :ref:`Problem Weight`.
.. _Word Cloud:
**********
Word Cloud
**********
In a word cloud exercise, students enter words into a field in response
to a question or prompt. The words all the students have entered then
appear instantly as a colorful graphic, with the most popular responses
appearing largest. The graphic becomes larger as more students answer.
Students can both see the way their peers have answered and contribute
their thoughts to the group.
For example, the following word cloud was created from students'
responses to a question in a HarvardX course.
.. image:: Images/WordCloudExample.gif
Create a Word Cloud Exercise
----------------------------
To create a word cloud exercise:
#. Add the Word Cloud advanced component. To do this, add the
"word_cloud" key value to the **Advanced Settings** page. (For more
information, see the instructions in :ref:`Specialized Problems`.)
#. In the unit where you want to create the problem, click **Advanced**
under **Add New Component**.
#. In the list of problem types, click **Word Cloud**.
#. In the component that appears, click **Edit**.
#. In the component editor, specify the settings that you want. You can
leave the default value for everything except **Display Name**.
- **Display Name**: The name that appears in the course ribbon and
as a heading above the problem.
- **Inputs**: The number of text boxes into which students can enter
words, phrases, or sentences.
- **Maximum Words**: The maximum number of words that the word cloud
displays. If students enter 300 different words but the maximum is
set to 250, only the 250 most commonly entered words appear in the
word cloud.
- **Show Percents**: The number of times that students have entered
a given word as a percentage of all words entered appears near
that word.
#. Click **Save**.
For more information, see `Xml Format of "Word Cloud" Module
<https://edx.readthedocs.org/en/latest/course_data_formats/word_cloud/word_cloud.html#>`_.
.. _Zooming Image:
******************
Zooming Image Tool
******************
Some edX courses use extremely large, extremely detailed graphics. To make it
easier to understand we can offer two versions of those graphics, with the zoomed
section showing when you click on the main view.
The example below is from 7.00x: Introduction to Biology and shows a subset of the
biochemical reactions that cells carry out.
.. image:: Images/Zooming_Image.gif
Create a Zooming Image Tool
---------------------------
#. Under **Add New Component**, click **html**, and then click **Zooming Image**.
#. In the empty component that appears, click **Edit**.
#. When the component editor opens, replace the example content with your own content.
#. Click **Save** to save the HTML component.
#############################
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
***********************
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.
......@@ -32,22 +32,22 @@ You can enter Preview mode in two ways.
* On any subsection page, click **Preview Drafts**.
.. image:: Images/image205.png
.. image:: Images/preview_draft.png
:width: 800
* On any Unit page, click **Preview**.
The following example shows the **Preview** button for a unit that
is set to Public.
The following example shows the **Preview** button for a unit that
is set to Public.
.. image:: Images/image207.png
.. image:: Images/preview_public.png
:width: 800
The following example shows the **Preview** button for a unit that
is set to Private.
The following example shows the **Preview** button for a unit that
is set to Private.
.. image:: Images/image209.png
.. image:: Images/preview_private.png
:width: 800
.. _View Your Live Course:
......@@ -65,16 +65,16 @@ You can view the live course from three different places in Studio:
* The **Course Outline** page.
.. image:: Images/image217.png
.. image:: Images/course_outline_view_live.png
:width: 800
* Any Subsection page.
.. image:: Images/image219.png
.. image:: Images/subsection_view_live.png
:width: 800
* The Unit page, if the Unit is Public.
.. image:: Images/image221.png
.. image:: Images/unit_view_live.png
: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
###################################
......
......@@ -11,7 +11,7 @@ You can now access the public edX roadmap_ for details about the currently plann
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/
......
......@@ -25,7 +25,7 @@ html_static_path.append('source/_static')
# General information about the project.
project = u'Release Notes for edX Course Staff'
copyright = u'2013, edX Documentation Team'
copyright = u'2013, edX'
# The short X.Y version.
version = ''
......
......@@ -11,6 +11,7 @@ Contents
:maxdepth: 5
read_me
01-07-2014
12-17-2013
12-09-2013
12-03-2013
......
......@@ -21,9 +21,9 @@ class Converter(object):
# HTML: <B>, </B>, <BR/>, <textformat leading="10">
# Python: %(date)s, %(name)s
tag_pattern = re.compile(r'''
(<[-\w" .:?=/]*>) | # <tag>
({[^}]*}) | # {tag}
(%\([^)]*\)\w) | # %(tag)s
(<[^>]+>) | # <tag>
({[^}]+}) | # {tag}
(%\([\w]+\)\w) | # %(tag)s
(&\w+;) | # &entity;
(&\#\d+;) | # &#1234;
(&\#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.
2. Every string is padded out to +30% length to simulate verbose languages
(such as German) to see if layout and flows work properly.
# Creates new localization properties files in a dummy language
# Each property file is derived from the equivalent en_US file, except
# 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.
# http://tlt.its.psu.edu/suggestions/international/web/codehtml.html#accent
TABLE = {'A': u'\xC0',
'a': u'\xE4',
'b': u'\xDF',
'C': u'\xc7',
'c': u'\xE7',
'E': u'\xC9',
'e': u'\xE9',
'I': U'\xCC',
'i': u'\xEF',
'O': u'\xD8',
'o': u'\xF8',
'U': u'\xDB',
'u': u'\xFC',
'Y': u'\xDD',
'y': u'\xFD',
}
TABLE = {
'A': u'À',
'a': u'ä',
'b': u'ß',
'C': u'Ç',
'c': u'ç',
'E': u'É',
'e': u'é',
'I': u'Ì',
'i': u'ï',
'O': u'Ø',
'o': u'ø',
'U': u'Û',
'u': u'ü',
'Y': u'Ý',
'y': u'ý',
}
# The print industry's standard dummy text, in use since the 1500s
# see http://www.lipsum.com/
LOREM = ' Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed ' \
'do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad ' \
'minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ' \
'ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate ' \
'velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat ' \
'cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. '
# see http://www.lipsum.com/, then fed through a "fancy-text" converter.
# The string should start with a space.
LOREM = " " + " ".join( # join and split just make the string easier here.
u"""
Ⱡσяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂
тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм
νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα
¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє
νєłιт єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт
¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм ι∂
єѕт łαвσяυм.
""".split()
)
# To simulate more verbose languages (like German), pad the length of a string
# by a multiple of PAD_FACTOR
......@@ -85,20 +99,6 @@ class Dummy(Converter):
"""replaces the final char of string with #"""
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):
"""
Takes one POEntry object and converts it (adds a dummy translation to it)
......@@ -114,8 +114,10 @@ class Dummy(Converter):
# translate singular and plural
foreign_single = self.convert(source)
foreign_plural = self.convert(plural)
plural = {'0': self.final_newline(source, foreign_single),
'1': self.final_newline(plural, foreign_plural)}
plural = {
'0': self.final_newline(source, foreign_single),
'1': self.final_newline(plural, foreign_plural),
}
msg.msgstr_plural = plural
else:
foreign = self.convert(source)
......
......@@ -45,7 +45,7 @@ def main():
remove_file(source_msgs_dir.joinpath(filename))
# 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.
make_django_cmd = (
......
......@@ -60,9 +60,12 @@ def merge(locale, target='django.po', fail_if_missing=True):
def clean_metadata(file):
"""
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):
......
......@@ -38,9 +38,15 @@ def main(file, locale):
raise IOError('File does not exist: %s' % file)
pofile = polib.pofile(file)
converter = Dummy()
converter.init_msgs(pofile.translated_entries())
for msg in pofile:
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)
create_dir_if_necessary(new_file)
pofile.save(new_file)
......
"""Tests of i18n/converter.py"""
import os
from unittest import TestCase
import ddt
import converter
......@@ -11,36 +14,48 @@ class UpcaseConverter(converter.Converter):
return string.upper()
@ddt.ddt
class TestConverter(TestCase):
"""
Tests functionality of i18n/converter.py
"""
def test_converter(self):
"""
Tests with a simple converter (converts strings to uppercase).
Assert that embedded HTML and python tags are not converted.
"""
c = UpcaseConverter()
test_cases = [
@ddt.data(
# no tags
('big bad wolf', 'BIG BAD WOLF'),
('big bad wolf',
'BIG BAD WOLF'),
# one html tag
('big <strong>bad</strong> wolf', 'BIG <strong>BAD</strong> WOLF'),
('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>'),
('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'),
('big %(adjective)s wolf',
'BIG %(adjective)s WOLF'),
# two python tags
('big %(adjective)s %(noun)s', 'BIG %(adjective)s %(noun)s'),
('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}.'),
('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)
('<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).
Assert that embedded HTML and python tags are not converted.
"""
source, expected = data
result = UpcaseConverter().convert(source)
self.assertEquals(result, expected)
# -*- coding: utf-8 -*-
"""Tests of i18n/dummy.py"""
import os, string, random
from unittest import TestCase
import ddt
from polib import POEntry
import dummy
@ddt.ddt
class TestDummy(TestCase):
"""
Tests functionality of i18n/dummy.py
......@@ -13,39 +19,52 @@ class TestDummy(TestCase):
def setUp(self):
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).
Assert that embedded HTML and python tags are not converted.
"""
test_cases = [
("hello my name is Bond, James Bond",
u'h\xe9ll\xf8 m\xfd n\xe4m\xe9 \xefs B\xf8nd, J\xe4m\xe9s B\xf8nd Lorem i#'),
('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:
source, expected = data
result = self.converter.convert(source)
self.assertEquals(result, expected)
self.assertUnicodeEquals(result, expected)
def test_singular(self):
entry = POEntry()
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.assertEquals(entry.msgstr, expected)
self.assertUnicodeEquals(entry.msgstr, expected)
def test_plural(self):
entry = POEntry()
entry.msgid = 'A lovely day for a cup 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_p = u'\xc0 l\xf8v\xe9l\xfd d\xe4\xfd f\xf8r s\xf8m\xe9 \xe7\xfcps \xf8f t\xe9\xe4. Lorem ip#'
expected_s = u'À løvélý däý før ä çüp øf téä. Ⱡσяєм ι#'
expected_p = u'À løvélý däý før sømé çüps øf téä. Ⱡσяєм ιρ#'
self.converter.convert_msg(entry)
result = entry.msgstr_plural
self.assertEquals(result['0'], expected_s)
self.assertEquals(result['1'], expected_p)
self.assertUnicodeEquals(result['0'], expected_s)
self.assertUnicodeEquals(result['1'], expected_p)
import os, sys, logging
from unittest import TestCase
from nose.plugins.skip import SkipTest
"""Tests that validate .po files."""
import codecs
import logging
import os
import sys
import textwrap
import polib
from config import LOCALE_DIR
from execute import call
from converter import Converter
def test_po_files(root=LOCALE_DIR):
"""
......@@ -12,20 +20,120 @@ def test_po_files(root=LOCALE_DIR):
log = logging.getLogger(__name__)
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:
(base, ext) = os.path.splitext(name)
__, ext = os.path.splitext(name)
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.
Any errors caught by msgfmt are logged to log.
"""
# Use relative paths to make output less noisy.
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 != '':
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():
def pull():
for locale in CONFIGURATION.locales:
if locale != CONFIGURATION.source_locale:
print "Pulling %s from transifex..." % locale
execute('tx pull -l %s' % locale)
clean_translated_locales()
......
......@@ -50,16 +50,14 @@ def permitted(fn):
return wrapper
def ajax_content_response(request, course_id, content, template_name):
def ajax_content_response(request, course_id, content):
context = {
'course_id': course_id,
'content': content,
}
html = render_to_string(template_name, context)
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)
return JsonResponse({
'html': html,
'content': utils.safe_content(content),
'annotated_content_info': annotated_content_info,
})
......@@ -131,7 +129,7 @@ def create_thread(request, course_id, commentable_id):
data = thread.to_dict()
add_courseware_context([data], course)
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:
return JsonResponse(utils.safe_content(data))
......@@ -147,7 +145,7 @@ def update_thread(request, course_id, thread_id):
thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags']))
thread.save()
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:
return JsonResponse(utils.safe_content(thread.to_dict()))
......@@ -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.follow(comment.thread)
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:
return JsonResponse(utils.safe_content(comment.to_dict()))
......@@ -228,7 +226,7 @@ def update_comment(request, course_id, comment_id):
comment.update_attributes(**extract(request.POST, ['body']))
comment.save()
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:
return JsonResponse(utils.safe_content(comment.to_dict()))
......
......@@ -248,13 +248,10 @@ def single_thread(request, course_id, discussion_id, thread_id):
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)
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())
with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"):
add_courseware_context([content], course)
return utils.JsonResponse({
#'html': html,
'content': content,
'annotated_content_info': annotated_content_info,
})
......
......@@ -31,22 +31,3 @@ def include_mustache_templates():
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)))
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 pytz import UTC
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from django_comment_common.models import Role, Permission
from django_comment_client.tests.factories import RoleFactory
import django_comment_client.utils as utils
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
class DictionaryTestCase(TestCase):
def test_extract(self):
d = {'cats': 'meow', 'dogs': 'woof'}
......@@ -128,7 +129,13 @@ class CoursewareContextTestCase(ModuleStoreTestCase):
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class CategoryMapTestCase(ModuleStoreTestCase):
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
self.course.discussion_topics = {}
self.course.save()
......
......@@ -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):
log.warning("Duplicate task found for task_type %s and task_key %s", task_type, task_key)
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.
return InstructorTask.create(course_id, task_type, task_key, task_input, requester)
......
......@@ -217,14 +217,14 @@ def get_processor_decline_html(params):
"""Have to parse through the error codes to return a helpful message"""
payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
msg = _(dedent(
msg = dedent(_(
"""
<p class="error_msg">
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>.
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>
"""))
......@@ -240,7 +240,7 @@ def get_processor_exception_html(exception):
payment_support_email = settings.PAYMENT_SUPPORT_EMAIL
if isinstance(exception, CCProcessorDataException):
msg = _(dedent(
msg = dedent(_(
"""
<p class="error_msg">
Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data!
......@@ -251,7 +251,7 @@ def get_processor_exception_html(exception):
""".format(msg=exception.message, email=payment_support_email)))
return msg
elif isinstance(exception, CCProcessorWrongAmountException):
msg = _(dedent(
msg = dedent(_(
"""
<p class="error_msg">
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):
""".format(msg=exception.message, email=payment_support_email)))
return msg
elif isinstance(exception, CCProcessorSignatureException):
msg = _(dedent(
msg = dedent(_(
"""
<p class="error_msg">
Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are
......@@ -307,32 +307,32 @@ REASONCODE_MAP.update(
'100': _('Successful transaction.'),
'101': _('The request is missing one or more required fields.'),
'102': _('One or more fields in the request contains invalid data.'),
'104': _(dedent(
'104': dedent(_(
"""
The merchantReferenceCode sent with this authorization request matches the
merchantReferenceCode of another authorization request that you sent in the last 15 minutes.
Possible fix: retry the payment after 15 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.
This error does not include timeouts between the client and the server.
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
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'),
'202': _(dedent(
'202': dedent(_(
"""
Expired card. You might also receive this if the expiration date you
provided does not match the date the issuing bank has on file.
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.
Possible fix: retry with another form of payment
......@@ -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': _('Unknown reason'),
'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.
Possible fix: retry with another form of payment
......@@ -352,13 +352,13 @@ REASONCODE_MAP.update(
# Might as well not show this message to the person using such a card.
'221': _('Unknown reason'),
'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.
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}
""".format(settings.PAYMENT_SUPPORT_EMAIL))),
......@@ -370,7 +370,7 @@ REASONCODE_MAP.update(
# reason code 239 only applies if we are processing a capture or credit through the API,
# so we should never see it
'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.
Possible fix: retry with the same card or another form of payment
......@@ -382,26 +382,26 @@ REASONCODE_MAP.update(
# 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
# 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.
""")),
# we should never see 243
'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
'246': _(dedent(
'246': dedent(_(
"""
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.
""")),
# 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'),
'250': _(dedent(
'250': dedent(_(
"""
Error: The request was received, but there was a timeout at the payment processor.
Possible fix: retry the payment.
""")),
'520': _(dedent(
'520': dedent(_(
"""
The authorization request was approved by the issuing bank but declined by CyberSource.'
Possible fix: retry with a different form of payment.
......
......@@ -23,8 +23,10 @@ function quickElement() {
// CalendarNamespace -- Provides a collection of HTML calendar-related helper functions
var CalendarNamespace = {
monthsOfYear: gettext('January February March April May June July August September October November December').split(' '),
daysOfWeek: gettext('S M T W T F S').split(' '),
// Translators: the names of months, keep the pipe (|) separators.
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')),
isLeapYear: function(year) {
return (((year % 4)==0) && ((year % 100)!=0) || ((year % 400)==0));
......
......@@ -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(" ");
var weekdayNames = gettext("Sunday Monday Tuesday Wednesday Thursday Friday Saturday").split(" ");
// Translators: the names of months, keep the pipe (|) separators.
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
an error if 0 or more than 1 matches
......
......@@ -231,10 +231,6 @@
letter-spacing: 1px;
margin-right: 10px;
padding-right: 10px;
&:hover, &:focus {
color: $link-color;
}
}
.start-date {
......
<%namespace name='static' file='static_content.html'/>
<%namespace file='main.html' import="stanford_theme_enabled"/>
<%!
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse
......@@ -26,11 +25,7 @@ from courseware.courses import course_image_url, get_course_about_section
<p>${get_course_about_section(course, 'short_description')}</p>
</div>
<div class="bottom">
% if stanford_theme_enabled():
<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>
</div>
</section>
......
......@@ -2,7 +2,6 @@
<%inherit file="/main.html" />
<%namespace name='static' file='../static_content.html'/>
<%block name="bodyclass">courseware</%block>
## Translators: "edX" should *not* be translated
<%block name="title"><title>${_("Courseware")} - ${settings.PLATFORM_NAME}</title></%block>
<%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 @@
<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/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/backbone-min.js')}"></script>
......
......@@ -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.tagsinput.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/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 @@
<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">
<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>
<h1>${'<%- title %>'}</h1>
<p class="posted-details">
${"<% if (obj.username) { %>"}
......@@ -123,7 +123,7 @@
<script type="text/template" id="thread-response-show-template">
<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>
${"<% if (obj.username) { %>"}
<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