Merged master to rc/2013-11-21

This commit is contained in:
Ned Batchelder
2013-11-25 14:56:09 -05:00
115 changed files with 2799 additions and 1575 deletions

View File

@@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase):
self.user_bad_passwd.password = UNUSABLE_PASSWORD
self.user_bad_passwd.save()
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_user_bad_password_reset(self):
"""Tests password reset behavior for user with password marked UNUSABLE_PASSWORD"""
bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email})
bad_pwd_resp = password_reset(bad_pwd_req)
# If they've got an unusable password, we return a successful response code
self.assertEquals(bad_pwd_resp.status_code, 200)
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False,
'error': 'Invalid e-mail or user'}))
self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True,
'value': "('registration/password_reset_done.html', [])"}))
@patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_nonexist_email_password_reset(self):
"""Now test the exception cases with of reset_password called with invalid email."""
bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"})
bad_email_resp = password_reset(bad_email_req)
# Note: even if the email is bad, we return a successful response code
# This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX
self.assertEquals(bad_email_resp.status_code, 200)
self.assertEquals(bad_email_resp.content, json.dumps({'success': False,
'error': 'Invalid e-mail or user'}))
self.assertEquals(bad_email_resp.content, json.dumps({'success': True,
'value': "('registration/password_reset_done.html', [])"}))
@unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False),
dedent("""Skipping Test because CMS has not provided necessary templates for password reset.
@@ -152,38 +157,43 @@ class CourseEndingTest(TestCase):
{'status': 'processing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False, })
'show_survey_button': False,
})
cert_status = {'status': 'unavailable'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'processing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False})
cert_status = {'status': 'generating', 'grade': '67'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
'show_survey_button': False,
'mode': None
})
cert_status = {'status': 'regenerating', 'grade': '67'}
cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
'grade': '67',
'mode': 'honor'
})
cert_status = {'status': 'regenerating', 'grade': '67', 'mode': 'verified'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67',
'mode': 'verified'
})
download_url = 'http://s3.edx/cert'
cert_status = {'status': 'downloadable', 'grade': '67',
'download_url': download_url}
'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'ready',
'show_disabled_download_button': False,
@@ -191,30 +201,33 @@ class CourseEndingTest(TestCase):
'download_url': download_url,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
'grade': '67',
'mode': 'honor'
})
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'notpassing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
'grade': '67',
'mode': 'honor'
})
# Test a course that doesn't have a survey specified
course2 = Mock(end_of_course_survey_url=None)
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
'download_url': download_url, 'mode': 'honor'}
self.assertEqual(_cert_info(user, course2, cert_status),
{'status': 'notpassing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False,
'grade': '67'
'grade': '67',
'mode': 'honor'
})

View File

@@ -185,7 +185,8 @@ def _cert_info(user, course, cert_status):
default_info = {'status': default_status,
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False}
'show_survey_button': False,
}
if cert_status is None:
return default_info
@@ -203,7 +204,8 @@ def _cert_info(user, course, cert_status):
d = {'status': status,
'show_download_url': status == 'ready',
'show_disabled_download_button': status == 'generating', }
'show_disabled_download_button': status == 'generating',
'mode': cert_status.get('mode', None)}
if (status in ('generating', 'ready', 'notpassing', 'restricted') and
course.end_of_course_survey_url is not None):
@@ -296,7 +298,7 @@ def complete_course_mode_info(course_id, enrollment):
def dashboard(request):
user = request.user
# Build our (course, enorllment) list for the user, but ignore any courses that no
# Build our (course, enrollment) list for the user, but ignore any courses that no
# longer exist (because the course IDs have changed). Still, we don't delete those
# enrollments, because it could have been a data push snafu.
course_enrollment_pairs = []
@@ -1229,11 +1231,8 @@ def password_reset(request):
from_email=settings.DEFAULT_FROM_EMAIL,
request=request,
domain_override=request.get_host())
return HttpResponse(json.dumps({'success': True,
return HttpResponse(json.dumps({'success': True,
'value': render_to_string('registration/password_reset_done.html', {})}))
else:
return HttpResponse(json.dumps({'success': False,
'error': _('Invalid e-mail or user')}))
def password_reset_confirm_wrapper(
@@ -1515,4 +1514,4 @@ def change_email_settings(request):
log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id))
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
return HttpResponse(json.dumps({'success': True}))
return HttpResponse(json.dumps({'success': True}))

View File

@@ -946,17 +946,34 @@ class NumericalResponse(LoncapaResponse):
class StringResponse(LoncapaResponse):
'''
This response type allows one or more answers. Use `_or_` separator to set
more than 1 answer.
Example:
# One answer
<stringresponse answer="Michigan">
<textline size="20" />
</stringresponse >
# Multiple answers
<stringresponse answer="Martin Luther King_or_Dr. Martin Luther King Jr.">
<textline size="20" />
</stringresponse >
'''
response_tag = 'stringresponse'
hint_tag = 'stringhint'
allowed_inputfields = ['textline']
required_attributes = ['answer']
max_inputfields = 1
correct_answer = None
correct_answer = []
SEPARATOR = '_or_'
def setup_response(self):
self.correct_answer = contextualize_text(
self.xml.get('answer'), self.context).strip()
self.correct_answer = [contextualize_text(answer, self.context).strip()
for answer in self.xml.get('answer').split(self.SEPARATOR)]
def get_score(self, student_answers):
'''Grade a string response '''
@@ -966,23 +983,25 @@ class StringResponse(LoncapaResponse):
def check_string(self, expected, given):
if self.xml.get('type') == 'ci':
return given.lower() == expected.lower()
return given == expected
return given.lower() in [i.lower() for i in expected]
return given in expected
def check_hint_condition(self, hxml_set, student_answers):
given = student_answers[self.answer_id].strip()
hints_to_show = []
for hxml in hxml_set:
name = hxml.get('name')
correct_answer = contextualize_text(
hxml.get('answer'), self.context).strip()
correct_answer = [contextualize_text(answer, self.context).strip()
for answer in hxml.get('answer').split(self.SEPARATOR)]
if self.check_string(correct_answer, given):
hints_to_show.append(name)
log.debug('hints_to_show = %s', hints_to_show)
return hints_to_show
def get_answers(self):
return {self.answer_id: self.correct_answer}
return {self.answer_id: ' <b>or</b> '.join(self.correct_answer)}
#-----------------------------------------------------------------------------

View File

@@ -55,7 +55,7 @@
% else:
<% my_id = content_node.get('contents','') %>
<% my_val = value.get(my_id,'') %>
<input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h} "/>
<input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h}"/>
%endif
<span class="mock_label">
${content_node['tail_text']}

View File

@@ -500,6 +500,7 @@ class StringResponseTest(ResponseTest):
xml_factory_class = StringResponseXMLFactory
def test_case_sensitive(self):
# Test single answer
problem = self.build_problem(answer="Second", case_sensitive=True)
# Exact string should be correct
@@ -509,7 +510,20 @@ class StringResponseTest(ResponseTest):
self.assert_grade(problem, "Other String", "incorrect")
self.assert_grade(problem, "second", "incorrect")
# Test multiple answers
answers = ["Second", "Third", "Fourth"]
problem = self.build_problem(answer="_or_".join(answers), case_sensitive=True)
for answer in answers:
# Exact string should be correct
self.assert_grade(problem, answer, "correct")
# Other strings and the lowercase version of the string are incorrect
self.assert_grade(problem, "Other String", "incorrect")
self.assert_grade(problem, "second", "incorrect")
def test_case_insensitive(self):
# Test single answer
problem = self.build_problem(answer="Second", case_sensitive=False)
# Both versions of the string should be allowed, regardless
@@ -520,9 +534,28 @@ class StringResponseTest(ResponseTest):
# Other strings are not allowed
self.assert_grade(problem, "Other String", "incorrect")
# Test multiple answers
answers = ["Second", "Third", "Fourth"]
problem = self.build_problem(answer="_or_".join(answers), case_sensitive=False)
for answer in answers:
# Exact string should be correct
self.assert_grade(problem, answer, "correct")
self.assert_grade(problem, answer.lower(), "correct")
# Other strings and the lowercase version of the string are incorrect
self.assert_grade(problem, "Other String", "incorrect")
def test_hints(self):
multiple_answers = [
"Martin Luther King Junior",
"Doctor Martin Luther King Junior",
"Dr. Martin Luther King Jr.",
"Martin Luther King"
]
hints = [("wisconsin", "wisc", "The state capital of Wisconsin is Madison"),
("minnesota", "minn", "The state capital of Minnesota is St. Paul")]
("minnesota", "minn", "The state capital of Minnesota is St. Paul"),
("_or_".join(multiple_answers), "mlk", "He lead the civil right movement in the United States of America.")]
problem = self.build_problem(answer="Michigan",
case_sensitive=False,
@@ -550,6 +583,14 @@ class StringResponseTest(ResponseTest):
correct_map = problem.grade_answers(input_dict)
self.assertEquals(correct_map.get_hint('1_2_1'), "")
# We should get the same hint for each answer
for answer in multiple_answers:
input_dict = {'1_2_1': answer}
correct_map = problem.grade_answers(input_dict)
self.assertEquals(correct_map.get_hint('1_2_1'),
"He lead the civil right movement in the United States of America.")
def test_computed_hints(self):
problem = self.build_problem(
answer="Michigan",

View File

@@ -597,6 +597,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
@property
def raw_grader(self):
# force the caching of the xblock value so that it can detect the change
# pylint: disable=pointless-statement
self.grading_policy['GRADER']
return self._grading_policy['RAW_GRADER']
@raw_grader.setter

View File

@@ -726,21 +726,24 @@ section.problem {
}
a.full {
@include position(absolute, 0 0 1px 0);
@include position(absolute, 0 0px 1px 0px);
@include box-sizing(border-box);
display: block;
padding: 4px;
width: 100%;
background: #f3f3f3;
text-align: right;
font-size: .8em;
font-size: 1em;
&.full-top{
@include position(absolute, 1px 0px auto 0px);
}
}
}
}
.external-grader-message {
section {
padding-top: $baseline/2;
padding-top: ($baseline*1.5);
padding-left: $baseline;
background-color: #fafafa;
color: #2c2c2c;

View File

@@ -0,0 +1,115 @@
describe 'Collapsible', ->
html = custom_labels = html_custom = el = undefined
initialize = (template) =>
setFixtures(template)
el = $('.collapsible')
Collapsible.setCollapsibles(el)
disableFx = () =>
$.fx.off = true
enableFx = () =>
$.fx.off = false
beforeEach ->
html = '''
<section class="collapsible">
<div class="shortform">
shortform message
</div>
<div class="longform">
<p>longform is visible</p>
</div>
</section>
'''
html_custom = '''
<section class="collapsible">
<div class="shortform-custom" data-open-text="Show shortform-custom" data-close-text="Hide shortform-custom">
shortform message
</div>
<div class="longform">
<p>longform is visible</p>
</div>
</section>
'''
describe 'setCollapsibles', ->
it 'Default container initialized correctly', ->
initialize(html)
expect(el.find('.shortform')).toContain '.full-top'
expect(el.find('.shortform')).toContain '.full-bottom'
expect(el.find('.longform')).toBeHidden()
expect(el.find('.full')).toHandle('click')
it 'Custom container initialized correctly', ->
initialize(html_custom)
expect(el.find('.shortform-custom')).toContain '.full-custom'
expect(el.find('.full-custom')).toHaveText "Show shortform-custom"
expect(el.find('.longform')).toBeHidden()
expect(el.find('.full-custom')).toHandle('click')
describe 'toggleFull', ->
beforeEach ->
disableFx()
afterEach ->
enableFx()
it 'Default container', ->
initialize(html)
event = jQuery.Event('click', {
target: el.find('.full').get(0)
})
assertChanges = (state='closed') =>
anchors = el.find('.full')
if state is 'closed'
expect(el.find('.longform')).toBeHidden()
expect(el).not.toHaveClass('open')
text = "See full output"
else
expect(el.find('.longform')).toBeVisible()
expect(el).toHaveClass('open')
text = "Hide output"
$.each anchors, (index, el) =>
expect(el).toHaveText text
Collapsible.toggleFull(event, "See full output", "Hide output")
assertChanges('opened')
Collapsible.toggleFull(event, "See full output", "Hide output")
assertChanges('closed')
it 'Custom container', ->
initialize(html_custom)
event = jQuery.Event('click', {
target: el.find('.full-custom').get(0)
})
assertChanges = (state='closed') =>
anchors = el.find('.full-custom')
if state is 'closed'
expect(el.find('.longform')).toBeHidden()
expect(el).not.toHaveClass('open')
text = "Show shortform-custom"
else
expect(el.find('.longform')).toBeVisible()
expect(el).toHaveClass('open')
text = "Hide shortform-custom"
$.each anchors, (index, el) =>
expect(el).toHaveText text
Collapsible.toggleFull(event, "Show shortform-custom", "Hide shortform-custom")
assertChanges('opened')
Collapsible.toggleFull(event, "Show shortform-custom", "Hide shortform-custom")
assertChanges('closed')

View File

@@ -104,45 +104,45 @@ describe 'MarkdownEditingDescriptor', ->
Enter the number of fingers on a human hand:
= 5
[Explanation]
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.
If you look at your hand, you can count that you have five fingers.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.</p>
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
<p>Enter the numerical value of Pi:</p>
<numericalresponse answer="3.14159">
<responseparam type="tolerance" default=".02" />
<formulaequationinput />
</numericalresponse>
<p>Enter the approximate value of 502*9:</p>
<numericalresponse answer="4518">
<responseparam type="tolerance" default="15%" />
<formulaequationinput />
</numericalresponse>
<p>Enter the number of fingers on a human hand:</p>
<numericalresponse answer="5">
<formulaequationinput />
</numericalresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.</p>
<p>Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.</p>
<p>If you look at your hand, you can count that you have five fingers.</p>
</div>
@@ -161,12 +161,27 @@ describe 'MarkdownEditingDescriptor', ->
</numericalresponse>
</problem>""")
it 'markup with multiple answers doesn\'t break numerical response', ->
data = MarkdownEditingDescriptor.markdownToXml("""
Enter 1 with a tolerance:
= 1 +- .02
or= 2 +- 5%
""")
expect(data).toEqual("""<problem>
<p>Enter 1 with a tolerance:</p>
<numericalresponse answer="1">
<responseparam type="tolerance" default=".02" />
<formulaequationinput />
</numericalresponse>
</problem>""")
it 'converts multiple choice to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
What Apple device competed with the portable CD player?
( ) The iPad
( ) Napster
@@ -174,16 +189,16 @@ describe 'MarkdownEditingDescriptor', ->
( ) The vegetable peeler
( ) Android
( ) The Beatles
[Explanation]
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.</p>
<p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p>
<p>What Apple device competed with the portable CD player?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
@@ -195,76 +210,102 @@ describe 'MarkdownEditingDescriptor', ->
<choice correct="false">The Beatles</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p>
</div>
</solution>
</problem>""")
</problem>""")
it 'converts OptionResponse to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.
Translation between Option Response and __________ is extremely straightforward:
[[(Multiple Choice), String Response, Numerical Response, External Response, Image Response]]
[Explanation]
Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.</p>
<p>The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.</p>
<p>Translation between Option Response and __________ is extremely straightforward:</p>
<optionresponse>
<optioninput options="('Multiple Choice','String Response','Numerical Response','External Response','Image Response')" correct="Multiple Choice"></optioninput>
</optionresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.</p>
</div>
</solution>
</problem>""")
</problem>""")
it 'converts StringResponse to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.
The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.
Which US state has Lansing as its capital?
= Michigan
[Explanation]
Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.</p>
<p>The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.</p>
<p>Which US state has Lansing as its capital?</p>
<stringresponse answer="Michigan" type="ci">
<textline size="20"/>
</stringresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.</p>
</div>
</solution>
</problem>""")
it 'converts StringResponse with multiple answers to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""Who lead the civil right movement in the United States of America?
= Dr. Martin Luther King Jr.
or= Doctor Martin Luther King Junior
or= Martin Luther King
or= Martin Luther King Junior
[Explanation]
Test Explanation.
[Explanation]
""")
expect(data).toEqual("""<problem>
<p>Who lead the civil right movement in the United States of America?</p>
<stringresponse answer="Dr. Martin Luther King Jr._or_Doctor Martin Luther King Junior_or_Martin Luther King_or_Martin Luther King Junior" type="ci">
<textline size="20"/>
</stringresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
<p>Test Explanation.</p>
</div>
</solution>
</problem>""")
@@ -273,26 +314,26 @@ describe 'MarkdownEditingDescriptor', ->
data = MarkdownEditingDescriptor.markdownToXml("""Not a header
A header
==============
Multiple choice w/ parentheticals
( ) option (with parens)
( ) xd option (x)
()) parentheses inside
() no space b4 close paren
Choice checks
[ ] option1 [x]
[x] correct
[x] redundant
[(] distractor
[] no space
Option with multiple correct ones
[[one option, (correct one), (should not be correct)]]
Option with embedded parens
[[My (heart), another, (correct)]]
What happens w/ empty correct options?
[[()]]
@@ -300,21 +341,21 @@ describe 'MarkdownEditingDescriptor', ->
[explanation]
orphaned start
No p tags in the below
<script type='javascript'>
var two = 2;
console.log(two * 2);
</script>
But in this there should be
<div>
Great ideas require offsetting.
bad tests require drivel
</div>
[code]
Code should be nicely monospaced.
[/code]
@@ -322,7 +363,7 @@ describe 'MarkdownEditingDescriptor', ->
expect(data).toEqual("""<problem>
<p>Not a header</p>
<h1>A header</h1>
<p>Multiple choice w/ parentheticals</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
@@ -332,7 +373,7 @@ describe 'MarkdownEditingDescriptor', ->
<choice correct="false">no space b4 close paren</choice>
</choicegroup>
</multiplechoiceresponse>
<p>Choice checks</p>
<choiceresponse>
<checkboxgroup direction="vertical">
@@ -343,25 +384,25 @@ describe 'MarkdownEditingDescriptor', ->
<choice correct="false">no space</choice>
</checkboxgroup>
</choiceresponse>
<p>Option with multiple correct ones</p>
<optionresponse>
<optioninput options="('one option','correct one','should not be correct')" correct="correct one"></optioninput>
</optionresponse>
<p>Option with embedded parens</p>
<optionresponse>
<optioninput options="('My (heart)','another','correct')" correct="correct"></optioninput>
</optionresponse>
<p>What happens w/ empty correct options?</p>
<optionresponse>
<optioninput options="('')" correct=""></optioninput>
</optionresponse>
<solution>
<div class="detailed-solution">
<p>Explanation</p>
@@ -379,14 +420,14 @@ describe 'MarkdownEditingDescriptor', ->
console.log(two * 2);
</script>
<p>But in this there should be</p>
<div>
<p>Great ideas require offsetting.</p>
<p>bad tests require drivel</p>
</div>
<pre><code>
Code should be nicely monospaced.
</code></pre>

View File

@@ -271,7 +271,17 @@
});
// Disabled 10/29/13 due to flakiness in master
xdescribe('multiple YT on page', function () {
//
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
describe('multiple YT on page', function () {
var state1, state2, state3;
beforeEach(function () {

View File

@@ -457,7 +457,17 @@
});
// Disabled 10/25/13 due to flakiness in master
xit('scroll caption to new position', function () {
//
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('scroll caption to new position', function () {
expect($.fn.scrollTo).toHaveBeenCalled();
});
});
@@ -538,7 +548,17 @@
});
// Disabled 10/23/13 due to flakiness in master
xdescribe('scrollCaption', function () {
//
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
describe('scrollCaption', function () {
beforeEach(function () {
initialize();
});
@@ -683,7 +703,17 @@
});
// Test turned off due to flakiness (30.10.2013).
xit('scroll the caption', function () {
//
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('scroll the caption', function () {
// After transcripts are shown, and the video plays for a
// bit.
jasmine.Clock.tick(1000);

View File

@@ -72,7 +72,17 @@
expect(state.focusGrabber.disableFocusGrabber).toHaveBeenCalled();
});
it('after controls hide focus grabbers are enabled', function () {
// Disabled on 18.11.2013 due to flakiness on local dev machine.
//
// Video FocusGrabber: after controls hide focus grabbers are
// enabled [fail]
// Expected spy enableFocusGrabber to have been called.
//
// Approximately 1 in 8 times this test fails.
//
// TODO: Most likely, focusGrabber will be disabled in the future. This
// test could become unneeded in the future.
xit('after controls hide focus grabbers are enabled', function () {
runs(function () {
// Captions should not be "sticky" for the autohide mechanism
// to work.

View File

@@ -4,11 +4,21 @@
videoProgressSlider, videoSpeedControl, videoVolumeControl,
oldOTBD;
function initialize(fixture) {
if (typeof fixture === 'undefined') {
loadFixtures('video_all.html');
} else {
function initialize(fixture, params) {
if (_.isString(fixture)) {
loadFixtures(fixture);
} else {
if (_.isObject(fixture)) {
params = fixture;
}
loadFixtures('video_all.html');
}
if (_.isObject(params)) {
$('#example')
.find('#video_id')
.data(params);
}
state = new Video('#example');
@@ -532,8 +542,54 @@
});
});
// Disabled 10/24/13 due to flakiness in master
xdescribe('updatePlayTime', function () {
describe('update with start & end time', function () {
var START_TIME = 1, END_TIME = 2;
beforeEach(function () {
initialize({start: START_TIME, end: END_TIME});
spyOn(videoPlayer, 'update').andCallThrough();
spyOn(videoPlayer, 'pause').andCallThrough();
spyOn(videoProgressSlider, 'notifyThroughHandleEnd')
.andCallThrough();
});
it('video is paused on first endTime, start & end time are reset', function () {
var checkForStartEndTimeSet = true;
videoProgressSlider.notifyThroughHandleEnd.reset();
videoPlayer.pause.reset();
videoPlayer.play();
waitsFor(function () {
if (
!isFinite(videoPlayer.currentTime) ||
videoPlayer.currentTime <= 0
) {
return false;
}
if (checkForStartEndTimeSet) {
checkForStartEndTimeSet = false;
expect(videoPlayer.startTime).toBe(START_TIME);
expect(videoPlayer.endTime).toBe(END_TIME);
}
return videoPlayer.pause.calls.length === 1
}, 5000, 'pause() has been called');
runs(function () {
expect(videoPlayer.startTime).toBe(0);
expect(videoPlayer.endTime).toBe(null);
expect(videoProgressSlider.notifyThroughHandleEnd)
.toHaveBeenCalledWith({end: true});
});
});
});
describe('updatePlayTime', function () {
beforeEach(function () {
initialize();
@@ -548,7 +604,7 @@
duration = videoPlayer.duration();
if (duration > 0) {
return true;
return true;
}
return false;
@@ -612,6 +668,74 @@
});
});
describe('updatePlayTime when start & end times are defined', function () {
var START_TIME = 1,
END_TIME = 2;
beforeEach(function () {
initialize({start: START_TIME, end: END_TIME});
spyOn(videoPlayer, 'updatePlayTime').andCallThrough();
spyOn(videoPlayer.player, 'seekTo').andCallThrough();
spyOn(videoProgressSlider, 'updateStartEndTimeRegion')
.andCallThrough();
});
it('when duration becomes available, updatePlayTime() is called', function () {
var duration;
expect(videoPlayer.initialSeekToStartTime).toBeTruthy();
expect(videoPlayer.seekToStartTimeOldSpeed).toBe('void');
videoPlayer.play();
waitsFor(function () {
duration = videoPlayer.duration();
return duration > 0 &&
videoPlayer.initialSeekToStartTime === false;
}, 'duration becomes available', 1000);
runs(function () {
expect(videoPlayer.startTime).toBe(START_TIME);
expect(videoPlayer.endTime).toBe(END_TIME);
expect(videoPlayer.player.seekTo).toHaveBeenCalledWith(START_TIME);
expect(videoProgressSlider.updateStartEndTimeRegion)
.toHaveBeenCalledWith({duration: duration});
expect(videoPlayer.seekToStartTimeOldSpeed).toBe(state.speed);
});
});
});
describe('updatePlayTime with invalid endTime', function () {
beforeEach(function () {
initialize({end: 100000});
spyOn(videoPlayer, 'updatePlayTime').andCallThrough();
});
it('invalid endTime is reset to null', function () {
var duration;
videoPlayer.updatePlayTime.reset();
videoPlayer.play();
waitsFor(function () {
duration = videoPlayer.duration();
return duration > 0 &&
videoPlayer.initialSeekToStartTime === false;
}, 'updatePlayTime was invoked and duration is set', 5000);
runs(function () {
expect(videoPlayer.endTime).toBe(null);
});
});
});
describe('toggleFullScreen', function () {
describe('when the video player is not full screen', function () {
beforeEach(function () {

View File

@@ -154,7 +154,17 @@
});
// Turned off test due to flakiness (30.10.2013).
xit('trigger seek event', function() {
//
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('trigger seek event', function() {
runs(function () {
videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 20 }
@@ -220,7 +230,17 @@
});
// Turned off test due to flakiness (30.10.2013).
xit('trigger seek event', function() {
//
// Update: Turned on test back again. Passing locally and on
// Jenkins in a large number of runs.
//
// Will observe for a little while to see if any failures arise.
// Most probable cause of test passing is:
//
// https://github.com/edx/edx-platform/pull/1642
//
// : )
it('trigger seek event', function() {
runs(function () {
videoProgressSlider.onStop(
jQuery.Event('stop'), { value: 20 }
@@ -285,6 +305,55 @@
expect(params).toEqual(expectedParams);
});
});
describe('notifyThroughHandleEnd', function () {
beforeEach(function () {
initialize();
spyOnEvent(videoProgressSlider.handle, 'focus');
spyOn(videoProgressSlider, 'notifyThroughHandleEnd')
.andCallThrough();
});
it('params.end = true', function () {
videoProgressSlider.notifyThroughHandleEnd({end: true});
expect(videoProgressSlider.handle.attr('title'))
.toBe('video ended');
expect('focus').toHaveBeenTriggeredOn(videoProgressSlider.handle);
});
it('params.end = false', function () {
videoProgressSlider.notifyThroughHandleEnd({end: false});
expect(videoProgressSlider.handle.attr('title'))
.toBe('video position');
expect('focus').not.toHaveBeenTriggeredOn(videoProgressSlider.handle);
});
it('is called when video plays', function () {
videoPlayer.play();
waitsFor(function () {
var duration = videoPlayer.duration(),
currentTime = videoPlayer.currentTime;
return (
isFinite(duration) &&
duration > 0 &&
isFinite(currentTime) &&
currentTime > 0
);
}, 'duration is set, video is playing', 5000);
runs(function () {
expect(videoProgressSlider.notifyThroughHandleEnd)
.toHaveBeenCalledWith({end: false});
});
});
});
});
}).call(this);

View File

@@ -1,7 +1,7 @@
class @Collapsible
# Set of library functions that provide a simple way to add collapsible
# functionality to elements.
# functionality to elements.
# setCollapsibles:
# Scan element's content for generic collapsible containers
@@ -9,12 +9,15 @@ class @Collapsible
###
el: container
###
linkTop = '<a href="#" class="full full-top">See full output</a>'
linkBottom = '<a href="#" class="full full-bottom">See full output</a>'
# standard longform + shortfom pattern
el.find('.longform').hide()
el.find('.shortform').append('<a href="#" class="full">See full output</a>')
el.find('.shortform').append(linkTop, linkBottom)
# custom longform + shortform text pattern
short_custom = el.find('.shortform-custom')
short_custom = el.find('.shortform-custom')
# set up each one individually
short_custom.each (index, elt) =>
open_text = $(elt).data('open-text')
@@ -31,13 +34,18 @@ class @Collapsible
@toggleFull: (event, open_text, close_text) =>
event.preventDefault()
$(event.target).parent().siblings().slideToggle()
$(event.target).parent().parent().toggleClass('open')
parent = $(event.target).parent()
parent.siblings().slideToggle()
parent.parent().toggleClass('open')
if $(event.target).text() == open_text
new_text = close_text
else
new_text = open_text
$(event.target).text(new_text)
if $(event.target).hasClass('full')
el = parent.find('.full')
else
el = $(event.target)
el.text(new_text)
@toggleHint: (event) =>
event.preventDefault()

View File

@@ -228,11 +228,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
});
// replace string and numerical
xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) {
var string;
var floatValue = parseFloat(p);
xml = xml.replace(/(^\=\s*(.*?$)(\n*or\=\s*(.*?$))*)+/gm, function(match, p) {
var string,
answersList = p.replace(/^(or)?=\s*/gm, '').split('\n'),
floatValue = parseFloat(answersList[0]);
if(!isNaN(floatValue)) {
var params = /(.*?)\+\-\s*(.*?$)/.exec(p);
var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]);
if(params) {
string = '<numericalresponse answer="' + floatValue + '">\n';
string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n';
@@ -242,10 +244,16 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
string += ' <formulaequationinput />\n';
string += '</numericalresponse>\n\n';
} else {
string = '<stringresponse answer="' + p + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n';
var answers = [];
for(var i = 0; i < answersList.length; i++) {
answers.push(answersList[i])
}
string = '<stringresponse answer="' + answers.join('_or_') + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n';
}
return string;
});
});
// replace selects
xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) {
@@ -262,13 +270,13 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
selectString += '</optionresponse>\n\n';
return selectString;
});
// replace explanations
xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) {
var selectString = '<solution>\n<div class="detailed-solution">\nExplanation\n\n' + p1 + '\n</div>\n</solution>';
return selectString;
});
// replace code blocks
xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, function(match, p1) {
var selectString = '<pre><code>\n' + p1 + '</code></pre>';
@@ -293,7 +301,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
// rid white space
xml = xml.replace(/\n\n\n/g, '\n');
// surround w/ problem tag
xml = '<problem>\n' + xml + '\n</problem>';

View File

@@ -3,7 +3,7 @@
// VideoPlayer module.
define(
'video/03_video_player.js',
['video/02_html5_video.js', 'video/00_resizer.js' ],
['video/02_html5_video.js', 'video/00_resizer.js'],
function (HTML5Video, Resizer) {
var dfd = $.Deferred();
@@ -83,11 +83,9 @@ function (HTML5Video, Resizer) {
state.videoPlayer.initialSeekToStartTime = true;
state.videoPlayer.oneTimePauseAtEndTime = true;
// The initial value of the variable `seekToStartTimeOldSpeed`
// should always differ from the value returned by the duration
// function.
// At the start, the initial value of the variable
// `seekToStartTimeOldSpeed` should always differ from the value
// returned by the duration function.
state.videoPlayer.seekToStartTimeOldSpeed = 'void';
state.videoPlayer.playerVars = {
@@ -215,8 +213,7 @@ function (HTML5Video, Resizer) {
// This function gets the video's current play position in time
// (currentTime) and its duration.
// It is called at a regular interval when the video is playing (see
// below).
// It is called at a regular interval when the video is playing.
function update() {
this.videoPlayer.currentTime = this.videoPlayer.player
.getCurrentTime();
@@ -224,22 +221,28 @@ function (HTML5Video, Resizer) {
if (isFinite(this.videoPlayer.currentTime)) {
this.videoPlayer.updatePlayTime(this.videoPlayer.currentTime);
// We need to pause the video is current time is smaller (or equal)
// than end time. Also, we must make sure that the end time is the
// one that was set in the configuration parameter. If it differs,
// this means that it was either reset to the end, or the duration
// changed it's value.
// We need to pause the video if current time is smaller (or equal)
// than end time. Also, we must make sure that this is only done
// once.
//
// In the case of YouTube Flash mode, we must remember that the
// start and end times are rescaled based on the current speed of
// the video.
// If `endTime` is not `null`, then we are safe to pause the
// video. `endTime` will be set to `null`, and this if statement
// will not be executed on next runs.
if (
this.videoPlayer.endTime <= this.videoPlayer.currentTime &&
this.videoPlayer.oneTimePauseAtEndTime
this.videoPlayer.endTime != null &&
this.videoPlayer.endTime <= this.videoPlayer.currentTime
) {
this.videoPlayer.oneTimePauseAtEndTime = false;
this.videoPlayer.pause();
this.videoPlayer.endTime = this.videoPlayer.duration();
// After the first time the video reached the `endTime`,
// `startTime` and `endTime` are disabled. The video will play
// from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
}
}
}
@@ -321,8 +324,10 @@ function (HTML5Video, Resizer) {
}
);
// After the user seeks, startTime and endTime are disabled. The video
// will play from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = duration;
this.videoPlayer.endTime = null;
this.videoPlayer.player.seekTo(newTime, true);
@@ -344,11 +349,21 @@ function (HTML5Video, Resizer) {
var time = this.videoPlayer.duration();
this.trigger('videoControl.pause', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
if (this.config.show_captions) {
this.trigger('videoCaption.pause', null);
}
// When only `startTime` is set, the video will play to the end
// starting at `startTime`. After the first time the video reaches the
// end, `startTime` and `endTime` are disabled. The video will play
// from start to the end on subsequent runs.
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
// Sometimes `onEnded` events fires when `currentTime` not equal
// `duration`. In this case, slider doesn't reach the end point of
// timeline.
@@ -391,6 +406,10 @@ function (HTML5Video, Resizer) {
this.trigger('videoControl.play', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: false
});
if (this.config.show_captions) {
this.trigger('videoCaption.play', null);
}
@@ -531,7 +550,7 @@ function (HTML5Video, Resizer) {
function updatePlayTime(time) {
var duration = this.videoPlayer.duration(),
durationChange;
durationChange, tempStartTime, tempEndTime;
if (
duration > 0 &&
@@ -545,13 +564,23 @@ function (HTML5Video, Resizer) {
this.videoPlayer.initialSeekToStartTime === false
) {
durationChange = true;
} else {
} else { // this.videoPlayer.initialSeekToStartTime === true
this.videoPlayer.initialSeekToStartTime = false;
durationChange = false;
}
this.videoPlayer.initialSeekToStartTime = false;
this.videoPlayer.seekToStartTimeOldSpeed = this.speed;
// Current startTime and endTime could have already been reset.
// We will remember their current values, and reset them at the
// end. We need to perform the below calculations on start and end
// times so that the range on the slider gets correctly updated in
// the case of speed change in Flash player mode (for YouTube
// videos).
tempStartTime = this.videoPlayer.startTime;
tempEndTime = this.videoPlayer.endTime;
// We retrieve the original times. They could have been changed due
// to the fact of speed change (duration change). This happens when
// in YouTube Flash mode. There each speed is a different video,
@@ -566,31 +595,33 @@ function (HTML5Video, Resizer) {
this.videoPlayer.startTime /= Number(this.speed);
}
}
// An `endTime` of `null` means that either the user didn't set
// and `endTime`, or it was set to a value greater than the
// duration of the video.
//
// If `endTime` is `null`, the video will play to the end. We do
// not set the `endTime` to the duration of the video because
// sometimes in YouTube mode the duration changes slightly during
// the course of playback. This would cause the video to pause just
// before the actual end of the video.
if (
this.videoPlayer.endTime === null ||
this.videoPlayer.endTime !== null &&
this.videoPlayer.endTime > duration
) {
this.videoPlayer.endTime = duration;
} else {
this.videoPlayer.endTime = null;
} else if (this.videoPlayer.endTime !== null) {
if (this.currentPlayerMode === 'flash') {
this.videoPlayer.endTime /= Number(this.speed);
}
}
// If this is not a duration change (if it is, we continue playing
// from current time), then we need to seek the video to the start
// time.
//
// We seek only if start time differs from zero.
if (durationChange === false && this.videoPlayer.startTime > 0) {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
}
// Rebuild the slider start-end range (if it doesn't take up the
// whole slider).
// whole slider). Remember that endTime === null means the end time
// is set to the end of video by default.
if (!(
this.videoPlayer.startTime === 0 &&
this.videoPlayer.endTime === duration
this.videoPlayer.endTime === null
)) {
this.trigger(
'videoProgressSlider.updateStartEndTimeRegion',
@@ -599,6 +630,28 @@ function (HTML5Video, Resizer) {
}
);
}
// If this is not a duration change (if it is, we continue playing
// from current time), then we need to seek the video to the start
// time.
//
// We seek only if start time differs from zero, and we haven't
// performed already such a seek.
if (
durationChange === false &&
this.videoPlayer.startTime > 0 &&
!(tempStartTime === 0 && tempEndTime === null)
) {
this.videoPlayer.player.seekTo(this.videoPlayer.startTime);
}
// Reset back the actual startTime and endTime if they have been
// already reset (a seek event happened, the video already ended
// once, or endTime has already been reached once).
if (tempStartTime === 0 && tempEndTime === null) {
this.videoPlayer.startTime = 0;
this.videoPlayer.endTime = null;
}
}
this.trigger(

View File

@@ -41,7 +41,8 @@ function () {
onSlide: onSlide,
onStop: onStop,
updatePlayTime: updatePlayTime,
updateStartEndTimeRegion: updateStartEndTimeRegion
updateStartEndTimeRegion: updateStartEndTimeRegion,
notifyThroughHandleEnd: notifyThroughHandleEnd
};
state.bindTo(methodsDict, state.videoProgressSlider, state);
@@ -111,11 +112,6 @@ function () {
duration = params.duration;
}
// If the range spans the entire length of video, we don't do anything.
if (!this.videoPlayer.startTime && !this.videoPlayer.endTime) {
return;
}
start = this.videoPlayer.startTime;
// If end is set to null, then we set it to the end of the video. We
@@ -199,8 +195,6 @@ function () {
}, 200);
}
// Changed for tests -- JM: Check if it is the cause of Chrome Bug Valera
// noticed
function updatePlayTime(params) {
var time = Math.floor(params.time),
duration = Math.floor(params.duration);
@@ -215,6 +209,33 @@ function () {
}
}
// When the video stops playing (either because the end was reached, or
// because endTime was reached), the screen reader must be notified that
// the video is no longer playing. We do this by a little trick. Setting
// the title attribute of the slider know to "video ended", and focusing
// on it. The screen reader will read the attr text.
//
// The user can then tab his way forward, landing on the next control
// element, the Play button.
//
// @param params - object with property `end`. If set to true, the
// function must set the title attribute to
// `video ended`;
// if set to false, the function must reset the attr to
// it's original state.
//
// This function will be triggered from VideoPlayer methods onEnded(),
// onPlay(), and update() (update method handles endTime).
function notifyThroughHandleEnd(params) {
if (params.end) {
this.videoProgressSlider.handle
.attr('title', 'video ended')
.focus();
} else {
this.videoProgressSlider.handle.attr('title', 'video position');
}
}
// Returns a string describing the current time of video in hh:mm:ss
// format.
function getTimeDescription(time) {

View File

@@ -204,21 +204,16 @@ class LocMapperStore(object):
self._decode_from_mongo(old_name),
None)
elif usage_id == locator.usage_id:
# figure out revision
# enforce the draft only if category in [..] logic
if category in draft.DIRECT_ONLY_CATEGORIES:
revision = None
elif locator.branch == candidate['draft_branch']:
revision = draft.DRAFT
else:
revision = None
# Always return revision=None because the
# old draft module store wraps locations as draft before
# trying to access things.
return Location(
'i4x',
candidate['_id']['org'],
candidate['_id']['course'],
category,
self._decode_from_mongo(old_name),
revision)
None)
return None
def add_block_location_translator(self, location, old_course_id=None, usage_id=None):

View File

@@ -778,11 +778,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
children: A list of child item identifiers
"""
# We expect the children IDs to always be the non-draft version. With view refactoring
# for split, we are now passing the draft version in some cases.
children_ids = [Location(child).replace(revision=None).url() for child in children]
self._update_single_item(location, {'definition.children': children_ids})
self._update_single_item(location, {'definition.children': children})
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location))
# fire signal that we've written to DB

View File

@@ -81,7 +81,7 @@ class DraftModuleStore(MongoModuleStore):
try:
return wrap_draft(super(DraftModuleStore, self).get_item(as_draft(location), depth=depth))
except ItemNotFoundError:
return wrap_draft(super(DraftModuleStore, self).get_item(as_published(location), depth=depth))
return wrap_draft(super(DraftModuleStore, self).get_item(location, depth=depth))
def get_instance(self, course_id, location, depth=0):
"""
@@ -169,7 +169,7 @@ class DraftModuleStore(MongoModuleStore):
try:
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.convert_to_draft(as_published(location))
self.convert_to_draft(location)
except ItemNotFoundError, e:
if not allow_not_found:
raise e
@@ -187,7 +187,7 @@ class DraftModuleStore(MongoModuleStore):
draft_loc = as_draft(location)
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.convert_to_draft(as_published(location))
self.convert_to_draft(location)
return super(DraftModuleStore, self).update_children(draft_loc, children)
@@ -203,7 +203,7 @@ class DraftModuleStore(MongoModuleStore):
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.convert_to_draft(as_published(location))
self.convert_to_draft(location)
if 'is_draft' in metadata:
del metadata['is_draft']
@@ -262,7 +262,7 @@ class DraftModuleStore(MongoModuleStore):
"""
Turn the published version into a draft, removing the published version
"""
self.convert_to_draft(as_published(location))
self.convert_to_draft(location)
super(DraftModuleStore, self).delete_item(location)
def _query_children_for_cache_children(self, items):

View File

@@ -88,7 +88,7 @@ class SplitMigrator(object):
index_info = self.split_modulestore.get_course_index_info(course_version_locator)
versions = index_info['versions']
versions['draft'] = versions['published']
self.split_modulestore.update_course_index(course_version_locator, {'versions': versions}, update_versions=True)
self.split_modulestore.update_course_index(index_info)
# clean up orphans in published version: in old mongo, parents pointed to the union of their published and draft
# children which meant some pointers were to non-existent locations in 'direct'

View File

@@ -22,5 +22,4 @@ class DefinitionLazyLoader(object):
Fetch the definition. Note, the caller should replace this lazy
loader pointer with the result so as not to fetch more than once
"""
return self.modulestore.definitions.find_one(
{'_id': self.definition_locator.definition_id})
return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id)

View File

@@ -0,0 +1,116 @@
"""
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
import pymongo
class MongoConnection(object):
"""
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
def __init__(
self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs
):
"""
Create & open the connection, authenticate, and provide pointers to the collections
"""
self.database = pymongo.database.Database(
pymongo.MongoClient(
host=host,
port=port,
tz_aware=tz_aware,
**kwargs
),
db
)
if user is not None and password is not None:
self.database.authenticate(user, password)
self.course_index = self.database[collection + '.active_versions']
self.structures = self.database[collection + '.structures']
self.definitions = self.database[collection + '.definitions']
# every app has write access to the db (v having a flag to indicate r/o v write)
# Force mongo to report errors, at the expense of performance
# pymongo docs suck but explanation:
# http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html
self.course_index.write_concern = {'w': 1}
self.structures.write_concern = {'w': 1}
self.definitions.write_concern = {'w': 1}
def get_structure(self, key):
"""
Get the structure from the persistence mechanism whose id is the given key
"""
return self.structures.find_one({'_id': key})
def find_matching_structures(self, query):
"""
Find the structure matching the query. Right now the query must be a legal mongo query
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
"""
return self.structures.find(query)
def insert_structure(self, structure):
"""
Create the structure in the db
"""
self.structures.insert(structure)
def update_structure(self, structure):
"""
Update the db record for structure
"""
self.structures.update({'_id': structure['_id']}, structure)
def get_course_index(self, key):
"""
Get the course_index from the persistence mechanism whose id is the given key
"""
return self.course_index.find_one({'_id': key})
def find_matching_course_indexes(self, query):
"""
Find the course_index matching the query. Right now the query must be a legal mongo query
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
"""
return self.course_index.find(query)
def insert_course_index(self, course_index):
"""
Create the course_index in the db
"""
self.course_index.insert(course_index)
def update_course_index(self, course_index):
"""
Update the db record for course_index
"""
self.course_index.update({'_id': course_index['_id']}, course_index)
def delete_course_index(self, key):
"""
Delete the course_index from the persistence mechanism whose id is the given key
"""
return self.course_index.remove({'_id': key})
def get_definition(self, key):
"""
Get the definition from the persistence mechanism whose id is the given key
"""
return self.definitions.find_one({'_id': key})
def find_matching_definitions(self, query):
"""
Find the definitions matching the query. Right now the query must be a legal mongo query
:param query: a mongo-style query of {key: [value|{$in ..}|..], ..}
"""
return self.definitions.find(query)
def insert_definition(self, definition):
"""
Create the definition in the db
"""
self.definitions.insert(definition)

View File

@@ -1,7 +1,6 @@
import threading
import datetime
import logging
import pymongo
import re
from importlib import import_module
from path import path
@@ -21,6 +20,7 @@ from .caching_descriptor_system import CachingDescriptorSystem
from xblock.fields import Scope
from xblock.runtime import Mixologist
from bson.objectid import ObjectId
from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection
log = logging.getLogger(__name__)
#==============================================================================
@@ -49,7 +49,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
A Mongodb backed ModuleStore supporting versions, inheritance,
and sharing.
"""
# pylint: disable=W0201
def __init__(self, doc_store_config, fs_root, render_template,
default_class=None,
error_tracker=null_error_tracker,
@@ -62,44 +61,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
super(SplitMongoModuleStore, self).__init__(**kwargs)
self.loc_mapper = loc_mapper
def do_connection(
db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs
):
"""
Create & open the connection, authenticate, and provide pointers to the collections
"""
self.db = pymongo.database.Database(
pymongo.MongoClient(
host=host,
port=port,
tz_aware=tz_aware,
**kwargs
),
db
)
if user is not None and password is not None:
self.db.authenticate(user, password)
self.course_index = self.db[collection + '.active_versions']
self.structures = self.db[collection + '.structures']
self.definitions = self.db[collection + '.definitions']
do_connection(**doc_store_config)
self.db_connection = MongoConnection(**doc_store_config)
self.db = self.db_connection.database
# Code review question: How should I expire entries?
# _add_cache could use a lru mechanism to control the cache size?
self.thread_cache = threading.local()
# every app has write access to the db (v having a flag to indicate r/o v write)
# Force mongo to report errors, at the expense of performance
# pymongo docs suck but explanation:
# http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html
self.course_index.write_concern = {'w': 1}
self.structures.write_concern = {'w': 1}
self.definitions.write_concern = {'w': 1}
if default_class is not None:
module_path, _, class_name = default_class.rpartition('.')
class_ = getattr(import_module(module_path), class_name)
@@ -138,7 +106,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
block['definition'] = DefinitionLazyLoader(self, block['definition'])
else:
# Load all descendants by id
descendent_definitions = self.definitions.find({
descendent_definitions = self.db_connection.find_matching_definitions({
'_id': {'$in': [block['definition']
for block in new_module_data.itervalues()]}})
# turn into a map
@@ -226,7 +194,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if course_locator.course_id is not None and course_locator.branch is not None:
# use the course_id
index = self.course_index.find_one({'_id': course_locator.course_id})
index = self.db_connection.get_course_index(course_locator.course_id)
if index is None:
raise ItemNotFoundError(course_locator)
if course_locator.branch not in index['versions']:
@@ -241,7 +209,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# cast string to ObjectId if necessary
version_guid = course_locator.as_object_id(version_guid)
entry = self.structures.find_one({'_id': version_guid})
entry = self.db_connection.get_structure(version_guid)
# b/c more than one course can use same structure, the 'course_id' and 'branch' are not intrinsic to structure
# and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so,
@@ -269,7 +237,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if qualifiers is None:
qualifiers = {}
qualifiers.update({"versions.{}".format(branch): {"$exists": True}})
matching = self.course_index.find(qualifiers)
matching = self.db_connection.find_matching_course_indexes(qualifiers)
# collect ids and then query for those
version_guids = []
@@ -279,7 +247,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
version_guids.append(version_guid)
id_version_map[version_guid] = structure['_id']
course_entries = self.structures.find({'_id': {'$in': version_guids}})
course_entries = self.db_connection.find_matching_structures({'_id': {'$in': version_guids}})
# get the block for the course element (s/b the root)
result = []
@@ -455,7 +423,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"""
if course_locator.course_id is None:
return None
index = self.course_index.find_one({'_id': course_locator.course_id})
index = self.db_connection.get_course_index(course_locator.course_id)
return index
# TODO figure out a way to make this info accessible from the course descriptor
@@ -487,7 +455,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'edited_on': when the change was made
}
"""
definition = self.definitions.find_one({'_id': definition_locator.definition_id})
definition = self.db_connection.get_definition(definition_locator.definition_id)
if definition is None:
return None
return definition['edit_info']
@@ -509,14 +477,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# TODO if depth is significant, it may make sense to get all that have the same original_version
# and reconstruct the subtree from version_guid
next_entries = self.structures.find({'previous_version' : version_guid})
next_entries = self.db_connection.find_matching_structures({'previous_version' : version_guid})
# must only scan cursor's once
next_versions = [struct for struct in next_entries]
result = {version_guid: [CourseLocator(version_guid=struct['_id']) for struct in next_versions]}
depth = 1
while depth < version_history_depth and len(next_versions) > 0:
depth += 1
next_entries = self.structures.find({'previous_version':
next_entries = self.db_connection.find_matching_structures({'previous_version':
{'$in': [struct['_id'] for struct in next_versions]}})
next_versions = [struct for struct in next_entries]
for course_structure in next_versions:
@@ -537,7 +505,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
course_struct = self._lookup_course(block_locator.version_agnostic())['structure']
usage_id = block_locator.usage_id
update_version_field = 'blocks.{}.edit_info.update_version'.format(usage_id)
all_versions_with_block = self.structures.find({'original_version': course_struct['original_version'],
all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'],
update_version_field: {'$exists': True}})
# find (all) root versions and build map previous: [successors]
possible_roots = []
@@ -596,7 +564,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
"original_version": new_id,
}
}
self.definitions.insert(document)
self.db_connection.insert_definition(document)
definition_locator = DefinitionLocator(new_id)
return definition_locator
@@ -618,7 +586,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
# if this looks in cache rather than fresh fetches, then it will probably not detect
# actual change b/c the descriptor and cache probably point to the same objects
old_definition = self.definitions.find_one({'_id': definition_locator.definition_id})
old_definition = self.db_connection.get_definition(definition_locator.definition_id)
if old_definition is None:
raise ItemNotFoundError(definition_locator.url())
@@ -630,7 +598,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
old_definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
# previous version id
old_definition['edit_info']['previous_version'] = definition_locator.definition_id
self.definitions.insert(old_definition)
self.db_connection.insert_definition(old_definition)
return DefinitionLocator(old_definition['_id']), True
else:
return definition_locator, False
@@ -657,7 +625,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param course_blocks: the current list of blocks.
:param category:
"""
existing_uses = self.course_index.find({"_id": {"$regex": id_root}})
existing_uses = self.db_connection.find_matching_course_indexes({"_id": {"$regex": id_root}})
if existing_uses.count() > 0:
max_found = 0
matcher = re.compile(id_root + r'(\d+)')
@@ -779,11 +747,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
parent['edit_info']['update_version'] = new_id
if continue_version:
# db update
self.structures.update({'_id': new_id}, new_structure)
self.db_connection.update_structure(new_structure)
# clear cache so things get refetched and inheritance recomputed
self._clear_cache(new_id)
else:
self.structures.insert(new_structure)
self.db_connection.insert_structure(new_structure)
# update the index entry if appropriate
if index_entry is not None:
@@ -856,7 +824,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'original_version': definition_id,
}
}
self.definitions.insert(definition_entry)
self.db_connection.insert_definition(definition_entry)
new_id = ObjectId()
draft_structure = {
@@ -880,7 +848,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
}
}
}
self.structures.insert(draft_structure)
self.db_connection.insert_structure(draft_structure)
if versions_dict is None:
versions_dict = {master_branch: new_id}
@@ -898,20 +866,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
if block_fields is not None:
root_block['fields'].update(block_fields)
if definition_fields is not None:
definition = self.definitions.find_one({'_id': root_block['definition']})
definition = self.db_connection.get_definition(root_block['definition'])
definition['fields'].update(definition_fields)
definition['edit_info']['previous_version'] = definition['_id']
definition['edit_info']['edited_by'] = user_id
definition['edit_info']['edited_on'] = datetime.datetime.now(UTC)
definition['_id'] = ObjectId()
self.definitions.insert(definition)
self.db_connection.insert_definition(definition)
root_block['definition'] = definition['_id']
root_block['edit_info']['edited_on'] = datetime.datetime.now(UTC)
root_block['edit_info']['edited_by'] = user_id
root_block['edit_info']['previous_version'] = root_block['edit_info'].get('update_version')
root_block['edit_info']['update_version'] = new_id
self.structures.insert(draft_structure)
self.db_connection.insert_structure(draft_structure)
versions_dict[master_branch] = new_id
# create the index entry
@@ -926,7 +894,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'edited_by': user_id,
'edited_on': datetime.datetime.now(UTC),
'versions': versions_dict}
self.course_index.insert(index_entry)
self.db_connection.insert_course_index(index_entry)
return self.get_course(CourseLocator(course_id=new_id, branch=master_branch))
def update_item(self, descriptor, user_id, force=False):
@@ -978,7 +946,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'previous_version': block_data['edit_info']['update_version'],
'update_version': new_id,
}
self.structures.insert(new_structure)
self.db_connection.insert_structure(new_structure)
# update the index entry if appropriate
if index_entry is not None:
self._update_head(index_entry, descriptor.location.branch, new_id)
@@ -1016,7 +984,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
is_updated = self._persist_subdag(xblock, user_id, new_structure['blocks'], new_id)
if is_updated:
self.structures.insert(new_structure)
self.db_connection.insert_structure(new_structure)
# update the index entry if appropriate
if index_entry is not None:
@@ -1115,31 +1083,18 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
'''Deprecated, use update_item.'''
raise NotImplementedError('use update_item')
def update_course_index(self, course_locator, new_values_dict, update_versions=False):
def update_course_index(self, updated_index_entry):
"""
Change the given course's index entry for the given fields. new_values_dict
should be a subset of the dict returned by get_course_index_info.
It cannot include '_id' (will raise IllegalArgument).
Provide update_versions=True if you intend this to replace the versions hash.
Change the given course's index entry.
Note, this operation can be dangerous and break running courses.
If the dict includes versions and not update_versions, it will raise an exception.
If the dict includes edited_on or edited_by, it will raise an exception
Does not return anything useful.
"""
# TODO how should this log the change? edited_on and edited_by for this entry
# has the semantic of who created the course and when; so, changing those will lose
# that information.
if '_id' in new_values_dict:
raise ValueError("Cannot override _id")
if 'edited_on' in new_values_dict or 'edited_by' in new_values_dict:
raise ValueError("Cannot set edited_on or edited_by")
if not update_versions and 'versions' in new_values_dict:
raise ValueError("Cannot override versions without setting update_versions")
self.course_index.update({'_id': course_locator.course_id},
{'$set': new_values_dict})
self.db_connection.update_course_index(updated_index_entry)
def delete_item(self, usage_locator, user_id, delete_children=False, force=False):
"""
@@ -1182,7 +1137,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
remove_subtree(usage_locator.usage_id)
# update index if appropriate and structures
self.structures.insert(new_structure)
self.db_connection.insert_structure(new_structure)
result = CourseLocator(version_guid=new_id)
@@ -1204,11 +1159,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param course_id: uses course_id rather than locator to emphasize its global effect
"""
index = self.course_index.find_one({'_id': course_id})
index = self.db_connection.get_course_index(course_id)
if index is None:
raise ItemNotFoundError(course_id)
# this is the only real delete in the system. should it do something else?
self.course_index.remove(index['_id'])
self.db_connection.delete_course_index(index['_id'])
def get_errored_courses(self):
"""
@@ -1296,7 +1251,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
block['fields']["children"] = [
usage_id for usage_id in block['fields']["children"] if usage_id in original_structure['blocks']
]
self.structures.update({'_id': original_structure['_id']}, original_structure)
self.db_connection.update_structure(original_structure)
# clear cache again b/c inheritance may be wrong over orphans
self._clear_cache(original_structure['_id'])
@@ -1379,7 +1334,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
else:
return None
else:
index_entry = self.course_index.find_one({'_id': locator.course_id})
index_entry = self.db_connection.get_course_index(locator.course_id)
is_head = (
locator.version_guid is None or
index_entry['versions'][locator.branch] == locator.version_guid
@@ -1424,9 +1379,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
:param course_locator:
:param new_id:
"""
self.course_index.update(
{"_id": index_entry["_id"]},
{"$set": {"versions.{}".format(branch): new_id}})
index_entry['versions'][branch] = new_id
self.db_connection.update_course_index(index_entry)
def _partition_fields_by_scope(self, category, fields):
"""

View File

@@ -274,7 +274,9 @@ class TestLocationMapper(unittest.TestCase):
course_id=prob_locator.course_id, branch='draft', usage_id=prob_locator.usage_id
)
prob_location = loc_mapper().translate_locator_to_location(prob_locator)
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', 'draft'))
# Even though the problem was set as draft, we always return revision=None to work
# with old mongo/draft modulestores.
self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None))
prob_locator = BlockUsageLocator(
course_id=new_style_course_id, usage_id='problem2', branch='production'
)

View File

@@ -59,9 +59,9 @@ class TestMigration(unittest.TestCase):
dbref = self.loc_mapper.db
dbref.drop_collection(self.loc_mapper.location_map)
split_db = self.split_mongo.db
split_db.drop_collection(split_db.course_index)
split_db.drop_collection(split_db.structures)
split_db.drop_collection(split_db.definitions)
split_db.drop_collection(self.split_mongo.db_connection.course_index)
split_db.drop_collection(self.split_mongo.db_connection.structures)
split_db.drop_collection(self.split_mongo.db_connection.definitions)
# old_mongo doesn't give a db attr, but all of the dbs are the same
dbref.drop_collection(self.old_mongo.collection)

View File

@@ -1018,41 +1018,29 @@ class TestCourseCreation(SplitModuleTest):
Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc.
"""
locator = CourseLocator(course_id="GreekHero", branch='draft')
modulestore().update_course_index(locator, {'org': 'funkyU'})
course_info = modulestore().get_course_index_info(locator)
course_info['org'] = 'funkyU'
modulestore().update_course_index(course_info)
course_info = modulestore().get_course_index_info(locator)
self.assertEqual(course_info['org'], 'funkyU')
modulestore().update_course_index(locator, {'org': 'moreFunky', 'prettyid': 'Ancient Greek Demagods'})
course_info['org'] = 'moreFunky'
course_info['prettyid'] = 'Ancient Greek Demagods'
modulestore().update_course_index(course_info)
course_info = modulestore().get_course_index_info(locator)
self.assertEqual(course_info['org'], 'moreFunky')
self.assertEqual(course_info['prettyid'], 'Ancient Greek Demagods')
self.assertRaises(ValueError, modulestore().update_course_index, locator, {'_id': 'funkygreeks'})
with self.assertRaises(ValueError):
modulestore().update_course_index(
locator,
{'edited_on': datetime.datetime.now(UTC)}
)
with self.assertRaises(ValueError):
modulestore().update_course_index(
locator,
{'edited_by': 'sneak'}
)
self.assertRaises(ValueError, modulestore().update_course_index, locator,
{'versions': {'draft': self.GUID_D1}})
# an allowed but not necessarily recommended way to revert the draft version
versions = course_info['versions']
versions['draft'] = self.GUID_D1
modulestore().update_course_index(locator, {'versions': versions}, update_versions=True)
modulestore().update_course_index(course_info)
course = modulestore().get_course(locator)
self.assertEqual(str(course.location.version_guid), self.GUID_D1)
# an allowed but not recommended way to publish a course
versions['published'] = self.GUID_D1
modulestore().update_course_index(locator, {'versions': versions}, update_versions=True)
modulestore().update_course_index(course_info)
course = modulestore().get_course(CourseLocator(course_id=locator.course_id, branch="published"))
self.assertEqual(str(course.location.version_guid), self.GUID_D1)
@@ -1068,9 +1056,9 @@ class TestCourseCreation(SplitModuleTest):
self.assertEqual(new_course.location.usage_id, 'top')
self.assertEqual(new_course.category, 'chapter')
# look at db to verify
db_structure = modulestore().structures.find_one({
'_id': new_course.location.as_object_id(new_course.location.version_guid)
})
db_structure = modulestore().db_connection.get_structure(
new_course.location.as_object_id(new_course.location.version_guid)
)
self.assertIsNotNone(db_structure, "Didn't find course")
self.assertNotIn('course', db_structure['blocks'])
self.assertIn('top', db_structure['blocks'])

View File

@@ -97,16 +97,15 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
if len(draft_verticals) > 0:
draft_course_dir = export_fs.makeopendir('drafts')
for draft_vertical in draft_verticals:
if getattr(draft_vertical, 'is_draft', False):
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id)
# Don't try to export orphaned items.
if len(parent_locs) > 0:
logging.debug('parent_locs = {0}'.format(parent_locs))
draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url()
sequential = modulestore.get_item(Location(parent_locs[0]))
index = sequential.children.index(draft_vertical.location.url())
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
draft_vertical.export_to_xml(draft_course_dir)
parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id)
# Don't try to export orphaned items.
if len(parent_locs) > 0:
logging.debug('parent_locs = {0}'.format(parent_locs))
draft_vertical.xml_attributes['parent_sequential_url'] = Location(parent_locs[0]).url()
sequential = modulestore.get_item(Location(parent_locs[0]))
index = sequential.children.index(draft_vertical.location.url())
draft_vertical.xml_attributes['index_in_children_list'] = str(index)
draft_vertical.export_to_xml(draft_course_dir)
def export_extra_content(export_fs, modulestore, course_id, course_location, category_type, dirname, file_suffix=''):

View File

@@ -25,9 +25,8 @@ if Backbone?
@add model
model
retrieveAnotherPage: (mode, options={}, sort_options={})->
@current_page += 1
data = { page: @current_page }
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 }
switch mode
when 'search'
url = DiscussionUtil.urlFor 'search'
@@ -59,6 +58,7 @@ if Backbone?
@reset new_collection
@pages = response.num_pages
@current_page = response.page
error: error
sortByDate: (thread) ->
#

View File

@@ -36,12 +36,15 @@ if Backbone?
event.preventDefault()
@newPostForm.slideUp(300)
hideDiscussion: ->
@$("section.discussion").slideUp()
@toggleDiscussionBtn.removeClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Show Discussion")
@showed = false
toggleDiscussion: (event) ->
if @showed
@$("section.discussion").slideUp()
@toggleDiscussionBtn.removeClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Show Discussion")
@showed = false
@hideDiscussion()
else
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Hide Discussion")
@@ -51,9 +54,17 @@ if Backbone?
@showed = true
else
$elem = @toggleDiscussionBtn
@loadPage $elem
@loadPage(
$elem,
=>
@hideDiscussion()
DiscussionUtil.discussionAlert(
"Sorry",
"We had some trouble loading the discussion. Please try again."
)
)
loadPage: ($elem)=>
loadPage: ($elem, error) =>
discussionId = @$el.data("discussion-id")
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}"
DiscussionUtil.safeAjax
@@ -63,6 +74,7 @@ if Backbone?
type: "GET"
dataType: 'json'
success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId)
error: error
renderDiscussion: ($elem, response, textStatus, discussionId) =>
window.user = new DiscussionUser(response.user_info)
@@ -131,5 +143,14 @@ if Backbone?
navigateToPage: (event) =>
event.preventDefault()
window.history.pushState({}, window.document.title, event.target.href)
currPage = @page
@page = $(event.target).data('page-number')
@loadPage($(event.target))
@loadPage(
$(event.target),
=>
@page = currPage
DiscussionUtil.discussionAlert(
"Sorry",
"We had some trouble loading the threads you requested. Please try again."
)
)

View File

@@ -87,6 +87,13 @@ class @DiscussionUtil
"notifications_status" : "/notification_prefs/status"
}[name]
@makeFocusTrap: (elem) ->
elem.keydown(
(event) ->
if event.which == 9 # Tab
event.preventDefault()
)
@discussionAlert: (header, body) ->
if $("#discussion-alert").length == 0
alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none")
@@ -99,12 +106,7 @@ class @DiscussionUtil
" <button class='dismiss'>OK</button>" +
"</div>"
)
# Capture focus
alertDiv.find("button").keydown(
(event) ->
if event.which == 9 # Tab
event.preventDefault()
)
@makeFocusTrap(alertDiv.find("button"))
alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none")
alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200})
$("body").append(alertDiv).append(alertTrigger)

View File

@@ -124,8 +124,11 @@ if Backbone?
loadMorePages: (event) ->
if event
event.preventDefault()
@$(".more-pages").html('<div class="loading-animation"><span class="sr">Loading more threads</span></div>')
@$(".more-pages").html('<div class="loading-animation" tabindex=0><span class="sr" role="alert">Loading more threads</span></div>')
@$(".more-pages").addClass("loading")
loadingDiv = @$(".more-pages .loading-animation")
DiscussionUtil.makeFocusTrap(loadingDiv)
loadingDiv.focus()
options = {}
switch @mode
when 'search'
@@ -156,7 +159,11 @@ if Backbone?
$(".post-list a").first()?.focus()
)
@collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy})
error = =>
@renderThreads()
DiscussionUtil.discussionAlert("Sorry", "We had some trouble loading more threads. Please try again.")
@collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}, error)
renderThread: (thread) =>
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()))

View File

@@ -1,13 +1,13 @@
(function () {
var update = function () {
// Whenever a value changes create a new serialized version of this
// problem's inputs and set the hidden input fields value to equal it.
var parent = $(this).closest('.problems-wrapper');
// problem's inputs and set the hidden input field's value to equal it.
var parent = $(this).closest('section.choicetextinput');
// find the closest parent problems-wrapper and use that as the problem
// grab the input id from the input
// real_input is the hidden input field
var real_input = $('input.choicetextvalue', parent);
var all_inputs = $('.choicetextinput .ctinput', parent);
var all_inputs = $('input.ctinput', parent);
var user_inputs = {};
$(all_inputs).each(function (index, elt) {
var node = $(elt);

File diff suppressed because one or more lines are too long