Use runtime-provided XQueueService instead of constructing it in ProblemBlock (#37998)

* fix: move xqueue services
This commit is contained in:
Irtaza Akram
2026-02-19 11:02:00 +05:00
committed by GitHub
parent c70bfe980a
commit 76018183d4
11 changed files with 215 additions and 193 deletions

View File

@@ -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(
{
@@ -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(
{

View File

@@ -8,12 +8,8 @@ import logging
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:
@@ -29,34 +25,6 @@ XQUEUE_TIMEOUT = 35 # seconds
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):
"""
@@ -108,6 +76,7 @@ class XQueueInterface: # pylint: disable=too-few-public-methods
django_auth: Dict[str, str],
requests_auth: Optional[HTTPBasicAuth] = None,
block: "ProblemBlock" = None,
use_submission_service: bool = False,
):
"""
Initializes the XQueue interface.
@@ -119,6 +88,7 @@ class XQueueInterface: # pylint: disable=too-few-public-methods
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
@@ -126,6 +96,7 @@ class XQueueInterface: # pylint: disable=too-few-public-methods
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):
"""
@@ -134,7 +105,7 @@ class XQueueInterface: # pylint: disable=too-few-public-methods
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
@@ -184,11 +155,10 @@ class XQueueInterface: # pylint: disable=too-few-public-methods
)
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
)
@@ -213,66 +183,3 @@ class XQueueInterface: # pylint: disable=too-few-public-methods
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

View File

@@ -61,8 +61,6 @@ from xmodule.util.builtin_assets import add_css_to_fragment, add_webpack_js_to_f
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
@@ -144,6 +142,7 @@ class Randomization(String): # pylint: disable=too-few-public-methods
@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
@@ -856,6 +855,7 @@ class _BuiltInProblemBlock( # pylint: disable=too-many-public-methods,too-many-
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)
@@ -870,7 +870,7 @@ class _BuiltInProblemBlock( # pylint: disable=too-many-public-methods,too-many-
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,
)

View File

@@ -6,11 +6,14 @@ Module contains various XModule/XBlock services
import inspect
import logging
from functools import partial
from typing import TYPE_CHECKING
from config_models.models import ConfigurationModel
from django.conf import settings
from django.urls import reverse
from eventtracking import tracker
from edx_when.field_data import DateLookupFieldData
from requests.auth import HTTPBasicAuth
from xblock.reference.plugins import Service
from xblock.runtime import KvsFieldData
@@ -22,9 +25,14 @@ from lms.djangoapps.courseware.field_overrides import OverrideFieldData
from lms.djangoapps.courseware.model_data import DjangoKeyValueStore, FieldDataCache
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
from xmodule.capa.xqueue_interface import XQueueInterface
from lms.djangoapps.grades.api import signals as grades_signals
if TYPE_CHECKING:
from xmodule.capa_block import ProblemBlock
log = logging.getLogger(__name__)
@@ -308,3 +316,98 @@ class EventPublishingService(Service):
# in order to avoid duplicate work and possibly conflicting semantics.
if not getattr(block, 'has_custom_completion', False):
self.completion_service.submit_completion(block.scope_ids.usage_id, 1.0)
# .. 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__)
class XQueueService:
"""
XBlock service providing an interface to the XQueue service.
Args:
block: The `ProblemBlock` instance.
"""
def __init__(self, block: "ProblemBlock"):
self._block = block
basic_auth = settings.XQUEUE_INTERFACE.get("basic_auth")
requests_auth = HTTPBasicAuth(*basic_auth) if basic_auth else None
use_submission = self.use_edx_submissions_for_xqueue()
self._interface = XQueueInterface(
settings.XQUEUE_INTERFACE["url"],
settings.XQUEUE_INTERFACE["django_auth"],
requests_auth,
block=block,
use_submission_service=use_submission,
)
@property
def interface(self):
"""
Returns the XQueueInterface instance.
"""
return self._interface
def use_edx_submissions_for_xqueue(self) -> 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.
Returns:
bool: True if edx-submissions should be used, False otherwise.
"""
return SEND_TO_SUBMISSION_COURSE_FLAG.is_enabled(self._block.scope_ids.usage_id.context_key)
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

View File

@@ -21,13 +21,13 @@ from xblock.core import XBlock
from xblock.field_data import DictFieldData
from xblock.fields import Reference, ReferenceList, ReferenceValueDict, ScopeIds
from xmodule.capa.xqueue_interface import XQueueService
from xmodule.assetstore import AssetMetadata
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.draft_and_published import ModuleStoreDraftAndPublished
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.modulestore.xml import CourseLocationManager
from xmodule.services import XQueueService
from xmodule.tests.helpers import StubReplaceURLService, mock_render_template, StubMakoService, StubUserService
from xmodule.util.sandboxing import SandboxService
from xmodule.x_module import DoNothingCache, XModuleMixin, ModuleStoreRuntime
@@ -161,7 +161,8 @@ def get_test_system(
'field-data': DictFieldData({}),
'sandbox': SandboxService(contentstore, course_id),
'video_config': VideoConfigService(),
'discussion_config_service': DiscussionConfigService()
'discussion_config_service': DiscussionConfigService(),
'xqueue': XQueueService,
}
descriptor_system.get_block_for_descriptor = get_block # lint-amnesty, pylint: disable=attribute-defined-outside-init
@@ -218,7 +219,8 @@ def prepare_block_runtime(
'field-data': DictFieldData({}),
'sandbox': SandboxService(contentstore, course_id),
'video_config': VideoConfigService(),
'discussion_config_service': DiscussionConfigService()
'discussion_config_service': DiscussionConfigService(),
'xqueue': XQueueService,
}
if add_overrides:

View File

@@ -203,6 +203,7 @@ if submission[0] == '':
@ddt.ddt
@skip_unless_lms
@pytest.mark.django_db
class ProblemBlockTest(unittest.TestCase): # pylint: disable=too-many-public-methods
"""Tests for various problem types in XBlocks."""
@@ -2844,6 +2845,7 @@ class ProblemBlockTest(unittest.TestCase): # pylint: disable=too-many-public-me
@ddt.ddt
@pytest.mark.django_db
class ProblemBlockXMLTest(unittest.TestCase):
"""Tests XML strings for various problem types in XBlocks."""
@@ -3709,6 +3711,7 @@ class ComplexEncoderTest(unittest.TestCase):
@skip_unless_lms
@UseUnsafeCodejail()
@pytest.mark.django_db
class ProblemCheckTrackingTest(unittest.TestCase):
"""
Ensure correct tracking information is included in events emitted during problem checks.

View File

@@ -119,6 +119,7 @@ class CapaFactoryWithDelay:
return block
@pytest.mark.django_db
class XModuleQuizAttemptsDelayTest(unittest.TestCase):
"""
Class to test delay between quiz attempts.

View File

@@ -2,20 +2,22 @@
Tests for SettingsService
"""
import unittest
from unittest import mock
from unittest import TestCase, mock
from unittest.mock import Mock, patch
import pytest
from django.test import TestCase
import ddt
import pytest
from config_models.models import ConfigurationModel
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 xblock.runtime import Mixologist
from opaque_keys.edx.locator import CourseLocator
from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.lib.teams_config import TeamsConfig
from xmodule.capa.xqueue_interface import XQueueInterface
from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService, XQueueService
class _DummyBlock:
@@ -163,3 +165,71 @@ class TestTeamsConfigurationService(ConfigurationServiceBaseClass):
def test_get_teamsconfiguration(self):
teams_config = self.configuration_service.get_teams_configuration(self.course.id)
assert teams_config == self.teams_config
@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.services.XQueueService.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."""
self.service = XQueueService(self.block)
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.services.XQueueService.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."""
self.service = XQueueService(self.block)
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