Skip to content

Commit

Permalink
Merge pull request #263 from mitocw/danial/EDE-338-make-common-proble…
Browse files Browse the repository at this point in the history
…ms-public

make common problems publicly accessible
  • Loading branch information
danialmalik authored Apr 17, 2020
2 parents 7c4c147 + 0a78ed1 commit 6c8ebcd
Show file tree
Hide file tree
Showing 9 changed files with 274 additions and 49 deletions.
67 changes: 59 additions & 8 deletions common/lib/xmodule/xmodule/capa_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import traceback

from django.conf import settings
from django.urls import reverse
from django.utils.http import urlquote_plus
from pytz import utc
from django.utils.encoding import smart_text
from six import text_type
Expand Down Expand Up @@ -62,6 +64,7 @@ class Randomization(String):
"""
Define a field to store how to randomize a problem.
"""

def from_json(self, value):
if value in ("", "true"):
return RANDOMIZATION.ALWAYS
Expand All @@ -76,6 +79,7 @@ class ComplexEncoder(json.JSONEncoder):
"""
Extend the JSON encoder to correctly handle complex numbers
"""

def default(self, obj): # pylint: disable=method-hidden
"""
Print a nicely formatted complex number, or default to the JSON encoder
Expand Down Expand Up @@ -225,6 +229,7 @@ class CapaMixin(ScorableXBlockMixin, CapaFields):
"""
Core logic for Capa Problem, which can be used by XModules or XBlocks.
"""

def __init__(self, *args, **kwargs):
super(CapaMixin, self).__init__(*args, **kwargs)

Expand Down Expand Up @@ -431,6 +436,12 @@ def get_html(self):
'graded': self.graded,
})

def public_view(self, context=None):
"""
Return "student_view" content for public_view too.
"""
return self.student_view(context)

def submit_button_name(self):
"""
Determine the name for the "submit" button.
Expand Down Expand Up @@ -715,6 +726,9 @@ def get_problem_html(self, encapsulate=True, submit_notification=False):
u"Your answers were previously saved. Click '{button_name}' to grade them."
).format(button_name=self.submit_button_name())

# For anonymous users, don't show the attempts count.
attempts_allowed = self.max_attempts if self.runtime.user_id else 0

context = {
'problem': content,
'id': text_type(self.location),
Expand All @@ -726,7 +740,7 @@ def get_problem_html(self, encapsulate=True, submit_notification=False):
'save_button': self.should_show_save_button(),
'answer_available': self.answer_available(),
'attempts_used': self.attempts,
'attempts_allowed': self.max_attempts,
'attempts_allowed': attempts_allowed,
'demand_hint_possible': demand_hint_possible,
'should_enable_next_hint': should_enable_next_hint,
'answer_notification_type': answer_notification_type,
Expand Down Expand Up @@ -851,7 +865,11 @@ def closed(self):
"""
Is the student still allowed to submit answers?
"""
if self.max_attempts is not None and self.attempts >= self.max_attempts:
if (
self.max_attempts is not None and
self.runtime.user_id and
self.attempts >= self.max_attempts
):
return True
if self.is_past_due():
return True
Expand Down Expand Up @@ -887,6 +905,8 @@ def answer_available(self):
"""
Is the user allowed to see an answer?
"""
is_authenticated = bool(self.runtime.user_id)

if not self.correctness_available():
# If correctness is being withheld, then don't show answers either.
return False
Expand All @@ -899,18 +919,18 @@ def answer_available(self):
# unless the problem explicitly prevents it
return True
elif self.showanswer == SHOWANSWER.ATTEMPTED:
return self.attempts > 0 or self.is_past_due()
return (is_authenticated and self.attempts > 0) or self.is_past_due()
elif self.showanswer == SHOWANSWER.ANSWERED:
# NOTE: this is slightly different from 'attempted' -- resetting the problems
# makes lcp.done False, but leaves attempts unchanged.
return self.is_correct()
return is_authenticated and self.is_correct()
elif self.showanswer == SHOWANSWER.CLOSED:
return self.closed()
elif self.showanswer == SHOWANSWER.FINISHED:
return self.closed() or self.is_correct()
return (is_authenticated and self.is_correct()) or self.closed()

elif self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE:
return self.is_correct() or self.is_past_due()
return (is_authenticated and self.is_correct()) or self.is_past_due()
elif self.showanswer == SHOWANSWER.PAST_DUE:
return self.is_past_due()
elif self.showanswer == SHOWANSWER.ALWAYS:
Expand Down Expand Up @@ -1138,7 +1158,8 @@ def publish_grade(self, score=None, only_if_higher=None, **kwargs):
if kwargs.get('grader_response'):
event['grader_response'] = kwargs['grader_response']

self.runtime.publish(self, 'grade', event)
if self.runtime.user_id:
self.runtime.publish(self, 'grade', event)

return {'grade': self.score.raw_earned, 'max_grade': self.score.raw_possible}

Expand Down Expand Up @@ -1463,6 +1484,36 @@ def save_problem(self, data):
event_info['answers'] = answers
_ = self.runtime.service(self, "i18n").ugettext

# Anonymous users cannot save problem.
if not self.runtime.user_id:
event_info['failure'] = 'anonymous_user'
self.track_function_unmask('save_problem_fail', event_info)
next_url = urlquote_plus(reverse('jump_to',
kwargs={
'course_id': self.location.course_key,
'location': self.location
}))

signin_link = u'{signin_link}?next={next_url}'.format(
signin_link=reverse('signin_user'),
next_url=next_url
)
error_message = _(
u'You need to be {signin_link_start}logged in{signin_link_end} '
u'to be able to save your answers.'
)
error_message = Text(error_message).format(
signin_link_start=HTML(
'<span><a class="signin-link" href={signin_link}>'.format(
signin_link=signin_link
)),
signin_link_end=HTML('</a></span>')
)
return {
'success': False,
'msg': error_message
}

# Too late. Cannot submit
if self.closed() and not self.max_attempts == 0:
event_info['failure'] = 'closed'
Expand Down Expand Up @@ -1531,7 +1582,7 @@ def reset_problem(self, _data):
# pylint: enable=line-too-long
}

if not self.is_submitted():
if not self.is_submitted() and self.runtime.user_id:
event_info['failure'] = 'not_done'
self.track_function_unmask('reset_problem_fail', event_info)
return {
Expand Down
4 changes: 4 additions & 0 deletions common/lib/xmodule/xmodule/css/capa/display.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,10 @@ div.problem {
// the notification does not grow in height.
margin-bottom: 8px;

.signin-link {
text-decoration: underline;
}

ol {
list-style: none outside none;
padding: 0;
Expand Down
7 changes: 0 additions & 7 deletions common/lib/xmodule/xmodule/seq_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,13 +465,6 @@ def _render_student_view_for_items(self, context, display_items, fragment, view=
item_type = item.get_icon_class()
usage_id = item.scope_ids.usage_id

if item_type == 'problem' and not is_user_authenticated:
log.info(
'Problem [%s] was not rendered because anonymous access is not allowed for graded content',
usage_id
)
continue

show_bookmark_button = False
is_bookmarked = False

Expand Down
95 changes: 94 additions & 1 deletion common/lib/xmodule/xmodule/tests/test_capa_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
import unittest

import ddt
from django.urls import reverse
from django.utils.encoding import smart_text
from django.utils.http import urlquote_plus
from edx_user_state_client.interface import XBlockUserState
from lxml import etree
from mock import Mock, patch, DEFAULT
from mock import call, ANY, Mock, patch, DEFAULT
import six
import webob
from webob.multidict import MultiDict
Expand All @@ -29,11 +31,14 @@
ResponseError)
from capa.xqueue_interface import XQueueInterface
from xmodule.capa_module import CapaModule, CapaDescriptor, ComplexEncoder
from xmodule.x_module import STUDENT_VIEW, PUBLIC_VIEW
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from xblock.scorable import Score

from openedx.core.djangolib.markup import HTML, Text

from . import get_test_system
from pytz import UTC
from capa.correctmap import CorrectMap
Expand Down Expand Up @@ -578,6 +583,23 @@ def test_closed(self):
due=self.yesterday_str)
self.assertTrue(module.closed())

def test_public_view(self):
"""
Test that public view for capa problems returns
the same content as the student view
"""
html = u'<p>This is a test</p>'

module_system = get_test_system()
module = CapaFactory.create()

module.runtime.render_template.return_value = html

rendered_student_view = module_system.render(module, STUDENT_VIEW, {}).content
rendered_public_view = module_system.render(module, PUBLIC_VIEW, {}).content

self.assertEqual(rendered_student_view, rendered_public_view)

def test_parse_get_params(self):

# Valid GET param dict
Expand Down Expand Up @@ -673,6 +695,39 @@ def test_submit_problem_incorrect(self):
# and that this is considered the first attempt
self.assertEqual(module.lcp.context['attempt'], 1)

def test_submit_problem_with_authenticated_user(self):
"""
Test that publish is called with "grade" event upon submission
if the user is authenticated.
"""
problem = CapaFactory.create(attempts=5)
get_request_dict = {CapaFactory.input_key(): '0'}
grade_publish_call = call(ANY, 'grade', ANY)

problem.runtime.publish = Mock(name='mock_publish')

problem.submit_problem(get_request_dict)

self.assertEqual(problem.runtime.publish.call_count, 2)
problem.runtime.publish.assert_has_calls([grade_publish_call])

def test_submit_problem_with_anonymous_user(self):
"""
Test that publish is not called with "grade" event upon submission
if the user is anonymous.
"""
problem = CapaFactory.create(attempts=5)
get_request_dict = {CapaFactory.input_key(): '0'}
grade_publish_call = call(ANY, 'grade', ANY)

problem.runtime.user_id = None
problem.runtime.publish = Mock(name='mock_publish')

problem.submit_problem(get_request_dict)

self.assertEqual(problem.runtime.publish.call_count, 1)
self.assertNotIn(grade_publish_call, problem.runtime.publish.call_args_list)

def test_submit_problem_closed(self):
module = CapaFactory.create(attempts=3)

Expand Down Expand Up @@ -1224,6 +1279,44 @@ def test_save_problem(self):
# Expect that the result is success
self.assertTrue('success' in result and result['success'])

def test_save_problem_with_anonymous_user(self):
"""
Test that when an anonymous user tries to save the problem,
they are displayed an error message.
"""
module = CapaFactory.create(done=False)
next_url = urlquote_plus(reverse('jump_to', kwargs={
'course_id': module.location.course_key,
'location': module.location
}))

regiteration_link = u'{signin_link}?next={next_url}'.format(
signin_link=reverse('signin_user'),
next_url=next_url
)
expected_message = (
u'You need to be {signin_link_start}logged in{signin_link_end} '
u'to be able to save your answers.'
)
expected_message = Text(expected_message).format(
signin_link_start=HTML(
'<span><a class="signin-link" href={signin_link}>'.format(
signin_link=regiteration_link
)),
signin_link_end=HTML('</a></span>')
)

module.runtime.user_id = None

# Save the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)

self.assertEqual(result['msg'], expected_message)

# Expect that the result is failure
self.assertTrue('success' in result and not result['success'])

def test_save_problem_closed(self):
module = CapaFactory.create(done=False)

Expand Down
Loading

0 comments on commit 6c8ebcd

Please sign in to comment.