Skip to content
Open
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
3 changes: 2 additions & 1 deletion cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from xmodule.exceptions import NotFoundError as XModuleNotFoundError
from xmodule.modulestore.django import XBlockI18nService, modulestore
from xmodule.partitions.partitions_service import PartitionService
from xmodule.services import SettingsService, TeamsConfigurationService
from xmodule.services import SettingsService, TeamsConfigurationService, XQueueService
from xmodule.studio_editable import has_author_view
from xmodule.util.sandboxing import SandboxService
from xmodule.util.builtin_assets import add_webpack_js_to_fragment
Expand Down Expand Up @@ -230,6 +230,7 @@ def _prepare_runtime_for_preview(request, block):
'replace_urls': ReplaceURLService,
'video_config': VideoConfigService(),
'discussion_config_service': DiscussionConfigService(),
'xqueue': XQueueService(block),
}

block.runtime.get_block_for_descriptor = partial(_load_preview_block, request)
Expand Down
9 changes: 8 additions & 1 deletion lms/djangoapps/courseware/block_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.partitions.partitions_service import PartitionService
from xmodule.util.sandboxing import SandboxService
from xmodule.services import EventPublishingService, RebindUserService, SettingsService, TeamsConfigurationService
from xmodule.services import (
EventPublishingService,
RebindUserService,
SettingsService,
TeamsConfigurationService,
XQueueService
)
from common.djangoapps.static_replace.services import ReplaceURLService
from common.djangoapps.static_replace.wrapper import replace_urls_wrapper
from lms.djangoapps.courseware.access import get_user_role, has_access
Expand Down Expand Up @@ -639,6 +645,7 @@ def inner_get_block(block: XBlock) -> XBlock | None:
'enrollments': EnrollmentsService(),
'video_config': VideoConfigService(),
'discussion_config_service': DiscussionConfigService(),
'xqueue': XQueueService,
}

runtime.get_block_for_descriptor = inner_get_block
Expand Down
4 changes: 3 additions & 1 deletion openedx/core/djangoapps/xblock/runtime/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from xmodule.errortracker import make_error_tracker
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import XBlockI18nService
from xmodule.services import EventPublishingService, RebindUserService
from xmodule.services import EventPublishingService, RebindUserService, XQueueService
from xmodule.util.sandboxing import SandboxService
from common.djangoapps.edxmako.services import MakoService
from common.djangoapps.static_replace.services import ReplaceURLService
Expand Down Expand Up @@ -350,6 +350,8 @@ def service(self, block: XBlock, service_name: str):
elif service_name == 'discussion_config_service':
from openedx.core.djangoapps.discussions.services import DiscussionConfigService
return DiscussionConfigService()
elif service_name == 'xqueue':
return XQueueService(block)

# Otherwise, fall back to the base implementation which loads services
# defined in the constructor:
Expand Down
86 changes: 6 additions & 80 deletions xmodule/capa/tests/test_xqueue_interface.py
Original file line number Diff line number Diff line change
@@ -1,94 +1,21 @@
"""Test the XQueue service and interface."""
"""Test the XQueue interface."""

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

import pytest
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

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


@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))
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"

@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True)
def test_construct_callback_with_flag_enabled(self, mock_flag): # pylint: disable=unused-argument
"""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.use_edx_submissions_for_xqueue", return_value=False)
def test_construct_callback_with_flag_disabled(self, mock_flag): # pylint: disable=unused-argument
"""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}"

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"

def test_default_queuename(self):
"""Check the format of the default queue name."""
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.use_edx_submissions_for_xqueue", 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): # pylint: disable=unused-argument
def test_send_to_queue_with_flag_enabled(mock_send_to_submission):
"""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)
xqueue_interface = XQueueInterface(url, django_auth, block=block, use_submission_service=True)

header = json.dumps(
{
Expand All @@ -114,14 +41,13 @@ def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag): #


@pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False)
@patch("xmodule.capa.xqueue_interface.XQueueInterface._http_post")
def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag): # pylint: disable=unused-argument
def test_send_to_queue_with_flag_disabled(mock_http_post):
"""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)
xqueue_interface = XQueueInterface(url, django_auth, block=block, use_submission_service=False)

header = json.dumps(
{
Expand Down
103 changes: 5 additions & 98 deletions xmodule/capa/xqueue_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,8 @@
from typing import TYPE_CHECKING, Dict, Optional

import requests
from django.conf import settings
from django.urls import reverse
from opaque_keys.edx.keys import CourseKey
from requests.auth import HTTPBasicAuth

from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
from xmodule.capa.xqueue_submission import XQueueInterfaceSubmission

if TYPE_CHECKING:
Expand All @@ -29,34 +25,6 @@
CONNECT_TIMEOUT = 3.05 # seconds
READ_TIMEOUT = 10 # seconds

# .. toggle_name: send_to_submission_course.enable
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_description: Enables use of the submissions service instead of legacy xqueue for course problem submissions.
# .. toggle_default: False
# .. toggle_use_cases: opt_in
# .. toggle_creation_date: 2024-04-03
# .. toggle_expiration_date: 2025-08-12
# .. toggle_will_remain_in_codebase: True
# .. toggle_tickets: none
# .. toggle_status: supported
SEND_TO_SUBMISSION_COURSE_FLAG = CourseWaffleFlag("send_to_submission_course.enable", __name__)


def use_edx_submissions_for_xqueue(course_key: CourseKey | None = None) -> bool:
"""
Determines whether edx-submissions should be used instead of legacy XQueue.

This helper abstracts the toggle logic so that the rest of the codebase is not tied
to specific feature flag mechanics or rollout strategies.

Args:
course_key (CourseKey | None): Optional course key. If None, fallback to site-level toggle.

Returns:
bool: True if edx-submissions should be used, False otherwise.
"""
return SEND_TO_SUBMISSION_COURSE_FLAG.is_enabled(course_key)


def make_hashkey(seed):
"""
Expand Down Expand Up @@ -108,6 +76,7 @@ def __init__(
django_auth: Dict[str, str],
requests_auth: Optional[HTTPBasicAuth] = None,
block: "ProblemBlock" = None,
use_submission_service: bool = False,
):
"""
Initializes the XQueue interface.
Expand All @@ -119,13 +88,15 @@ def __init__(
block ('ProblemBlock', optional): Added as a parameter only to extract the course_id
to check the course waffle flag `send_to_submission_course.enable`.
This can be removed after the legacy xqueue is deprecated. Defaults to None.
use_submission_service (bool): If True, use the edx-submissions service instead of XQueue.
"""
self.url = url
self.auth = django_auth
self.session = requests.Session()
self.session.auth = requests_auth
self.block = block
self.submission = XQueueInterfaceSubmission(self.block)
self.use_submission_service = use_submission_service

def send_to_queue(self, header, body, files_to_upload=None):
"""
Expand All @@ -134,7 +105,7 @@ def send_to_queue(self, header, body, files_to_upload=None):
header: JSON-serialized dict in the format described in 'xqueue_interface.make_xheader'

body: Serialized data for the receipient behind the queueing service. The operation of
xqueue is agnostic to the contents of 'body'
xqueue is agnostic to the contents of 'body'

files_to_upload: List of file objects to be uploaded to xqueue along with queue request

Expand Down Expand Up @@ -184,11 +155,10 @@ def _send_to_queue(self, header, body, files_to_upload):
)
return self._http_post(self.url + "/xqueue/submit/", payload, files=files)

course_key = self.block.scope_ids.usage_id.context_key
header_info = json.loads(header)
queue_key = header_info["lms_key"] # pylint: disable=unused-variable

if use_edx_submissions_for_xqueue(course_key):
if self.use_submission_service:
submission = self.submission.send_to_submission( # pylint: disable=unused-variable
header, body, queue_key, files
)
Expand All @@ -213,66 +183,3 @@ def _http_post(self, url, data, files=None):
return 1, f"unexpected HTTP status code [{response.status_code}]"

return parse_xreply(response.text)


class XQueueService:
"""
XBlock service providing an interface to the XQueue service.

Args:
block: The `ProblemBlock` instance.
"""

def __init__(self, block: "ProblemBlock"):
basic_auth = settings.XQUEUE_INTERFACE.get("basic_auth")
requests_auth = HTTPBasicAuth(*basic_auth) if basic_auth else None
self._interface = XQueueInterface(
settings.XQUEUE_INTERFACE["url"], settings.XQUEUE_INTERFACE["django_auth"], requests_auth, block=block
)

self._block = block

@property
def interface(self):
"""
Returns the XQueueInterface instance.
"""
return self._interface

def construct_callback(self, dispatch: str = "score_update") -> str:
"""
Return a fully qualified callback URL for the external queueing system.
"""
course_key = self._block.scope_ids.usage_id.context_key
userid = str(self._block.scope_ids.user_id)
mod_id = str(self._block.scope_ids.usage_id)

callback_type = "xqueue_callback"

relative_xqueue_callback_url = reverse(
callback_type,
kwargs={
"course_id": str(course_key),
"userid": userid,
"mod_id": mod_id,
"dispatch": dispatch,
},
)

xqueue_callback_url_prefix = settings.XQUEUE_INTERFACE.get("callback_url", settings.LMS_ROOT_URL)
return f"{xqueue_callback_url_prefix}{relative_xqueue_callback_url}"

@property
def default_queuename(self) -> str:
"""
Returns the default queue name for the current course.
"""
course_id = self._block.scope_ids.usage_id.context_key
return f"{course_id.org}-{course_id.course}".replace(" ", "_")

@property
def waittime(self) -> int:
"""
Returns the number of seconds to wait in between calls to XQueue.
"""
return settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
6 changes: 3 additions & 3 deletions xmodule/capa_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@
from xmodule.x_module import XModuleMixin, XModuleToXBlockMixin, shim_xmodule_js
from xmodule.xml_block import XmlMixin

from .capa.xqueue_interface import XQueueService

log = logging.getLogger("edx.courseware")

# Make '_' a no-op so we can scrape strings. Using lambda instead of
Expand Down Expand Up @@ -144,6 +142,7 @@ def from_json(self, value):
@XBlock.needs("i18n")
@XBlock.needs("cache")
@XBlock.needs("sandbox")
@XBlock.needs("xqueue")
@XBlock.needs("replace_urls")
@XBlock.wants("call_to_action")
class _BuiltInProblemBlock( # pylint: disable=too-many-public-methods,too-many-instance-attributes,too-many-ancestors
Expand Down Expand Up @@ -856,6 +855,7 @@ def new_lcp(self, state, text=None):

sandbox_service = self.runtime.service(self, "sandbox")
cache_service = self.runtime.service(self, "cache")
xqueue_service = self.runtime.service(self, "xqueue")

is_studio = getattr(self.runtime, "is_author_mode", False)

Expand All @@ -870,7 +870,7 @@ def new_lcp(self, state, text=None):
render_template=render_to_string,
resources_fs=self.runtime.resources_fs,
seed=seed, # Why do we do this if we have self.seed?
xqueue=None if is_studio else XQueueService(self),
xqueue=None if is_studio else xqueue_service,
matlab_api_key=self.matlab_api_key,
)

Expand Down
Loading
Loading