Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FC-73 Implementing xqueue_submission service to send student response to edx-submission #36258

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@
xqueue_callback,
name='xqueue_callback',
),

re_path(
r'^courses/{}/xqueue/(?P<userid>[^/]*)/(?P<mod_id>.*?)/(?P<dispatch>[^/]*)$'.format(
settings.COURSE_ID_PATTERN,
),
xqueue_callback,
name='callback_submission',
),

# TODO: These views need to be updated before they work
path('calculate', util_views.calculate),
Expand Down
5 changes: 3 additions & 2 deletions scripts/xsslint_thresholds.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"django-trans-escape-variable-mismatch": 0,
"django-trans-invalid-escape-filter": 0,
"django-trans-missing-escape": 0,
"django-trans-escape-filter-parse-error": 0,
"javascript-concat-html": 2,
"javascript-escape": 1,
"javascript-jquery-append": 2,
Expand Down Expand Up @@ -36,5 +37,5 @@
"python-wrap-html": 0,
"underscore-not-escaped": 2
},
"total": 64
}
"total": 65
}
2 changes: 2 additions & 0 deletions xmodule/capa/capa_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class LoncapaSystem(object):
See :class:`DescriptorSystem` for documentation of other attributes.

"""

def __init__(
self,
ajax_url,
Expand Down Expand Up @@ -130,6 +131,7 @@ class LoncapaProblem(object):
"""
Main class for capa Problems.
"""

def __init__(self, problem_text, id, capa_system, capa_block, # pylint: disable=redefined-builtin
state=None, seed=None, minimal_init=False, extract_tree=True):
"""
Expand Down
50 changes: 50 additions & 0 deletions xmodule/capa/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# errors.py
"""
Custom error handling for the XQueue submission interface.
"""

class XQueueSubmissionError(Exception):
"""Base class for all XQueue submission errors."""
# No es necesario el `pass`, la clase ya hereda de Exception.

class JSONParsingError(XQueueSubmissionError):
"""Raised when JSON parsing fails."""
MESSAGE = "Error parsing {name}: {error}"

def __init__(self, name, error):
super().__init__(self.MESSAGE.format(name=name, error=error))

class MissingKeyError(XQueueSubmissionError):
"""Raised when a required key is missing."""
MESSAGE = "Missing key: {key}"

def __init__(self, key):
super().__init__(self.MESSAGE.format(key=key))

class ValidationError(XQueueSubmissionError):
"""Raised when a validation check fails."""
MESSAGE = "Validation error: {error}"

def __init__(self, error):
super().__init__(self.MESSAGE.format(error=error))

class TypeErrorSubmission(XQueueSubmissionError):
"""Raised when an invalid type is encountered."""
MESSAGE = "Type error: {error}"

def __init__(self, error):
super().__init__(self.MESSAGE.format(error=error))

class RuntimeErrorSubmission(XQueueSubmissionError):
"""Raised for runtime errors."""
MESSAGE = "Runtime error: {error}"

def __init__(self, error):
super().__init__(self.MESSAGE.format(error=error))

class GetSubmissionParamsError(XQueueSubmissionError):
"""Raised when there is an issue getting submission parameters."""
MESSAGE = "Block instance is not defined!"

def __init__(self):
super().__init__(self.MESSAGE)
18 changes: 8 additions & 10 deletions xmodule/capa/inputtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@

log = logging.getLogger(__name__)

#########################################################################

registry = TagRegistry() # pylint: disable=invalid-name


Expand Down Expand Up @@ -408,7 +406,7 @@ class OptionInput(InputTypeBase):

Example:

<optioninput options="('Up','Down')" correct="Up"/><text>The location of the sky</text>
<optioninput options="('Up', 'Down')" correct="Up"/><text>The location of the sky</text>

# TODO: allow ordering to be randomized
"""
Expand Down Expand Up @@ -505,7 +503,7 @@ def setup(self):
raise Exception(msg)

self.choices = self.extract_choices(self.xml, i18n)
self._choices_map = dict(self.choices,)
self._choices_map = dict(self.choices, )

@classmethod
def get_attributes(cls):
Expand Down Expand Up @@ -602,16 +600,16 @@ def get_attributes(cls):
Register the attributes.
"""
return [
Attribute('params', None), # extra iframe params
Attribute('params', None), # extra iframe params
Attribute('html_file', None),
Attribute('gradefn', "gradefn"),
Attribute('get_statefn', None), # Function to call in iframe
# to get current state.
# to get current state.
Attribute('initial_state', None), # JSON string to be used as initial state
Attribute('set_statefn', None), # Function to call iframe to
# set state
Attribute('width', "400"), # iframe width
Attribute('height', "300"), # iframe height
# set state
Attribute('width', "400"), # iframe width
Attribute('height', "300"), # iframe height
# Title for the iframe, which should be supplied by the author of the problem. Not translated
# because we are in a class method and therefore do not have access to capa_system.i18n.
# Note that the default "display name" for the problem is also not translated.
Expand Down Expand Up @@ -1626,7 +1624,7 @@ class ChoiceTextGroup(InputTypeBase):
CheckboxProblem:
<problem>
<startouttext/>
A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\) , 2, 3, 4 ,5 ,6
A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\), 2, 3, 4, 5, 6
and records the results. The first number they pick is \(\sqrt{2}\) Given this information
select the correct choices and fill in numbers to make them accurate.
<endouttext/>
Expand Down
1 change: 1 addition & 0 deletions xmodule/capa/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class TagRegistry(object):

(A dictionary with some extra error checking.)
"""

def __init__(self):
self._mapping = {}

Expand Down
1 change: 1 addition & 0 deletions xmodule/capa/tests/test_answer_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

class CapaAnswerPoolTest(unittest.TestCase):
"""Capa Answer Pool Test"""

def setUp(self):
super(CapaAnswerPoolTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
self.system = test_capa_system()
Expand Down
1 change: 1 addition & 0 deletions xmodule/capa/tests/test_customrender.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class HelperTest(unittest.TestCase):
'''
Make sure that our helper function works!
'''

def check(self, d):
xml = etree.XML(test_capa_system().render_template('blah', d))
assert d == extract_context(xml)
Expand Down
43 changes: 43 additions & 0 deletions xmodule/capa/tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""
Unit tests for custom error handling in the XQueue submission interface.
"""

import pytest
from xmodule.capa.errors import (
JSONParsingError,
MissingKeyError,
ValidationError,
TypeErrorSubmission,
RuntimeErrorSubmission,
GetSubmissionParamsError
)

def test_json_parsing_error():
with pytest.raises(JSONParsingError) as excinfo:
raise JSONParsingError("test_name", "test_error")
assert str(excinfo.value) == "Error parsing test_name: test_error"

def test_missing_key_error():
with pytest.raises(MissingKeyError) as excinfo:
raise MissingKeyError("test_key")
assert str(excinfo.value) == "Missing key: test_key"

def test_validation_error():
with pytest.raises(ValidationError) as excinfo:
raise ValidationError("test_error")
assert str(excinfo.value) == "Validation error: test_error"

def test_type_error_submission():
with pytest.raises(TypeErrorSubmission) as excinfo:
raise TypeErrorSubmission("test_error")
assert str(excinfo.value) == "Type error: test_error"

def test_runtime_error_submission():
with pytest.raises(RuntimeErrorSubmission) as excinfo:
raise RuntimeErrorSubmission("test_error")
assert str(excinfo.value) == "Runtime error: test_error"

def test_get_submission_params_error():
with pytest.raises(GetSubmissionParamsError) as excinfo:
raise GetSubmissionParamsError()
assert str(excinfo.value) == "Block instance is not defined!"
2 changes: 1 addition & 1 deletion xmodule/capa/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def test_remove_markup(self):
Test for markup removal with nh3.
"""
assert remove_markup('The <mark>Truth</mark> is <em>Out There</em> & you need to <strong>find</strong> it') ==\
'The Truth is Out There &amp; you need to find it'
'The Truth is Out There &amp; you need to find it'

@ddt.data(
'When the root level failš the whole hierarchy won’t work anymore.',
Expand Down
110 changes: 98 additions & 12 deletions xmodule/capa/tests/test_xqueue_interface.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
"""Test the XQueue service and interface."""

from unittest import TestCase
from unittest.mock import Mock
from unittest.mock import Mock, patch

from django.conf import settings
from django.test.utils import override_settings
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xblock.fields import ScopeIds
import pytest
import json

from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa.xqueue_interface import XQueueInterface, XQueueService


@pytest.mark.django_db
@skip_unless_lms
class XQueueServiceTest(TestCase):
"""Test the XQueue service methods."""

def setUp(self):
super().setUp()
location = BlockUsageLocator(CourseLocator("test_org", "test_course", "test_run"), "problem", "ExampleProblem")
self.block = Mock(scope_ids=ScopeIds('user1', 'mock_problem', location, location))
location = BlockUsageLocator(
CourseLocator("test_org", "test_course", "test_run"),
"problem",
"ExampleProblem",
)
self.block = Mock(scope_ids=ScopeIds("user1", "mock_problem", location, location))
self.block.max_score = Mock(return_value=10) # Mock max_score method
self.service = XQueueService(self.block)

def test_interface(self):
"""Test that the `XQUEUE_INTERFACE` settings are passed from the service to the interface."""
assert isinstance(self.service.interface, XQueueInterface)
assert self.service.interface.url == 'http://sandbox-xqueue.edx.org'
assert self.service.interface.auth['username'] == 'lms'
assert self.service.interface.auth['password'] == '***REMOVED***'
assert self.service.interface.session.auth.username == 'anant'
assert self.service.interface.session.auth.password == 'agarwal'

def test_construct_callback(self):
"""Test that the XQueue callback is initialized correctly, and can be altered through the settings."""
assert self.service.interface.url == "http://sandbox-xqueue.edx.org"
assert self.service.interface.auth["username"] == "lms"
assert self.service.interface.auth["password"] == "***REMOVED***"
assert self.service.interface.session.auth.username == "anant"
assert self.service.interface.session.auth.password == "agarwal"

@patch("xmodule.capa.xqueue_interface.is_flag_active", return_value=True)
def test_construct_callback_with_flag_enabled(self, mock_flag):
"""Test construct_callback when the waffle flag is enabled."""
usage_id = self.block.scope_ids.usage_id
course_id = str(usage_id.course_key)
callback_url = f"courses/{course_id}/xqueue/user1/{usage_id}"

assert self.service.construct_callback() == f"{settings.LMS_ROOT_URL}/{callback_url}/score_update"
assert self.service.construct_callback("alt_dispatch") == (
f"{settings.LMS_ROOT_URL}/{callback_url}/alt_dispatch"
)

custom_callback_url = "http://alt.url"
with override_settings(XQUEUE_INTERFACE={**settings.XQUEUE_INTERFACE, "callback_url": custom_callback_url}):
assert self.service.construct_callback() == f"{custom_callback_url}/{callback_url}/score_update"

@patch("xmodule.capa.xqueue_interface.is_flag_active", return_value=False)
def test_construct_callback_with_flag_disabled(self, mock_flag):
"""Test construct_callback when the waffle flag is disabled."""
usage_id = self.block.scope_ids.usage_id
callback_url = f'courses/{usage_id.context_key}/xqueue/user1/{usage_id}'

Expand All @@ -44,11 +70,71 @@ def test_construct_callback(self):

def test_default_queuename(self):
"""Check the format of the default queue name."""
assert self.service.default_queuename == 'test_org-test_course'
assert self.service.default_queuename == "test_org-test_course"

def test_waittime(self):
"""Check that the time between requests is retrieved correctly from the settings."""
assert self.service.waittime == 5

with override_settings(XQUEUE_WAITTIME_BETWEEN_REQUESTS=15):
assert self.service.waittime == 15


@pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.is_flag_active", return_value=True)
@patch("xmodule.capa.xqueue_submission.XQueueInterfaceSubmission.send_to_submission")
def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag):
"""Test send_to_queue when the waffle flag is enabled."""
url = "http://example.com/xqueue"
django_auth = {"username": "user", "password": "pass"}
block = Mock() # Mock block for the constructor
xqueue_interface = XQueueInterface(url, django_auth, block=block)

header = json.dumps({
"lms_callback_url": (
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
"xqueue/block@item_id/type@problem"
),
})
body = json.dumps({
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
})
files_to_upload = None

mock_send_to_submission.return_value = {"submission": "mock_submission"}
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload)

mock_send_to_submission.assert_called_once_with(header, body, {})


@pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.is_flag_active", return_value=False)
@patch("xmodule.capa.xqueue_interface.XQueueInterface._http_post")
def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag):
"""Test send_to_queue when the waffle flag is disabled."""
url = "http://example.com/xqueue"
django_auth = {"username": "user", "password": "pass"}
block = Mock() # Mock block for the constructor
xqueue_interface = XQueueInterface(url, django_auth, block=block)

header = json.dumps({
"lms_callback_url": (
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
"xqueue/block@item_id/type@problem"
),
})
body = json.dumps({
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
})
files_to_upload = None

mock_http_post.return_value = (0, "Submission sent successfully")
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload)

mock_http_post.assert_called_once_with(
"http://example.com/xqueue/xqueue/submit/",
{"xqueue_header": header, "xqueue_body": body},
files={},
)
Loading