diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 6f7d10a9d858..6600430a7a89 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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 @@ -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) diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py index 767d4033a73f..00828c31c767 100644 --- a/lms/djangoapps/courseware/block_render.py +++ b/lms/djangoapps/courseware/block_render.py @@ -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 @@ -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 diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py index 041450d8a341..e833105b636d 100644 --- a/openedx/core/djangoapps/xblock/runtime/runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/runtime.py @@ -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 @@ -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: diff --git a/xmodule/capa/tests/test_xqueue_interface.py b/xmodule/capa/tests/test_xqueue_interface.py index 027a2753434b..afaa58c5fc23 100644 --- a/xmodule/capa/tests/test_xqueue_interface.py +++ b/xmodule/capa/tests/test_xqueue_interface.py @@ -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( { diff --git a/xmodule/capa/xqueue_interface.py b/xmodule/capa/xqueue_interface.py index c86fe5f817a4..84ffebba697c 100644 --- a/xmodule/capa/xqueue_interface.py +++ b/xmodule/capa/xqueue_interface.py @@ -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: @@ -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): """ @@ -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. @@ -119,6 +88,7 @@ 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 @@ -126,6 +96,7 @@ def __init__( 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 @@ 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 @@ -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 ) @@ -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 diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index bc6310345873..b2635d479972 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -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 @@ -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 @@ -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) @@ -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, ) diff --git a/xmodule/services.py b/xmodule/services.py index 25c680a9653a..f742d9793dc1 100644 --- a/xmodule/services.py +++ b/xmodule/services.py @@ -6,11 +6,14 @@ 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.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 @@ def _handle_deprecated_progress_event(self, block, event): # 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 diff --git a/xmodule/tests/__init__.py b/xmodule/tests/__init__.py index c6db16a8fbc6..4833c4cf0c24 100644 --- a/xmodule/tests/__init__.py +++ b/xmodule/tests/__init__.py @@ -21,13 +21,13 @@ 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_block(block): '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 get_block(block): 'field-data': DictFieldData({}), 'sandbox': SandboxService(contentstore, course_id), 'video_config': VideoConfigService(), - 'discussion_config_service': DiscussionConfigService() + 'discussion_config_service': DiscussionConfigService(), + 'xqueue': XQueueService, } if add_overrides: diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index 3a9bed6c73d5..2db865907bce 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -203,6 +203,7 @@ class CapaFactoryWithFiles(CapaFactory): @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 @@ def test_problem_no_display_name(self, display_name, render_template): @ddt.ddt +@pytest.mark.django_db class ProblemBlockXMLTest(unittest.TestCase): """Tests XML strings for various problem types in XBlocks.""" @@ -3709,6 +3711,7 @@ def test_default(self): @skip_unless_lms @UseUnsafeCodejail() +@pytest.mark.django_db class ProblemCheckTrackingTest(unittest.TestCase): """ Ensure correct tracking information is included in events emitted during problem checks. diff --git a/xmodule/tests/test_delay_between_attempts.py b/xmodule/tests/test_delay_between_attempts.py index 680765bf154f..2fc241cbb14e 100644 --- a/xmodule/tests/test_delay_between_attempts.py +++ b/xmodule/tests/test_delay_between_attempts.py @@ -119,6 +119,7 @@ def create( return block +@pytest.mark.django_db class XModuleQuizAttemptsDelayTest(unittest.TestCase): """ Class to test delay between quiz attempts. diff --git a/xmodule/tests/test_services.py b/xmodule/tests/test_services.py index 3e571756ca5a..f0865153ebb8 100644 --- a/xmodule/tests/test_services.py +++ b/xmodule/tests/test_services.py @@ -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