From d67776848d0d6a7f9bd1eb55027a7c7ed5bb937a Mon Sep 17 00:00:00 2001 From: gabrielC1409 Date: Thu, 20 Feb 2025 17:20:33 -0400 Subject: [PATCH] fix: Workflow update to integrate the waffle flag course and be able to send to edx-submission test: Adjust styles feat: course unit - edit iframe modal window (#35777) Adds logic to support the functionality of editing xblocks via the legacy modal editing window. docs: fixing make docs command. (#36280) * docs: fixing make docs command. fix: import authoring filter from content_authoring instead (#36109) Correctly name authoring subdomain according to DDD docs: https://openedx.atlassian.net/wiki/spaces/AC/pages/663224968/edX+DDD+Bounded+Contexts fix: ADR document update for change from Xqueue to edx-submission --- .../contentstore/asset_storage_handlers.py | 4 +- .../contentstore/tests/test_filters.py | 2 +- cms/djangoapps/contentstore/views/block.py | 38 +- .../contentstore/views/tests/test_block.py | 59 + cms/static/cms/js/main.js | 11 + cms/static/js/views/container.js | 22 +- cms/static/js/views/modals/base_modal.js | 21 + cms/static/js/views/modals/edit_xblock.js | 25 + cms/static/js/views/pages/container.js | 44 +- .../js/views/pages/container_subviews.js | 4 +- .../sass/course-unit-mfe-iframe-bundle.scss | 95 +- cms/templates/container_editor.html | 133 ++ cms/urls.py | 3 + docs/decisions/0022-create-waffle-switch.rst | 58 - docs/docs_settings.py | 6 - docs/lms-openapi.yaml | 1773 ++++++++++++++++- lms/envs/common.py | 2 + lms/urls.py | 8 + pylint.log | 0 requirements/edx/base.txt | 3 +- requirements/edx/development.txt | 3 +- requirements/edx/doc.txt | 3 +- requirements/edx/testing.txt | 3 +- xmodule/capa/capa_problem.py | 8 +- xmodule/capa/inputtypes.py | 37 +- xmodule/capa/responsetypes.py | 8 +- xmodule/capa/tests/test_inputtypes.py | 47 +- xmodule/capa/tests/test_responsetypes.py | 11 +- xmodule/capa/tests/test_xqueue_interface.py | 106 +- xmodule/capa/tests/test_xqueue_submission.py | 66 +- xmodule/capa/xqueue_interface.py | 39 +- xmodule/capa/xqueue_submission.py | 129 +- .../0005-send-data-to-edx-submission.rst | 118 ++ 33 files changed, 2610 insertions(+), 279 deletions(-) create mode 100644 cms/templates/container_editor.html delete mode 100644 docs/decisions/0022-create-waffle-switch.rst create mode 100644 pylint.log create mode 100644 xmodule/docs/decisions/0005-send-data-to-edx-submission.rst diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py index 97fcb312574..e49c6fe1f7a 100644 --- a/cms/djangoapps/contentstore/asset_storage_handlers.py +++ b/cms/djangoapps/contentstore/asset_storage_handlers.py @@ -25,7 +25,7 @@ from common.djangoapps.util.json_request import JsonResponse from openedx.core.djangoapps.contentserver.caching import del_cached_content from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx_filters.course_authoring.filters import LMSPageURLRequested +from openedx_filters.content_authoring.filters import LMSPageURLRequested from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -717,7 +717,7 @@ def get_asset_json(display_name, content_type, date, location, thumbnail_locatio asset_url = StaticContent.serialize_asset_key_with_slash(location) ## .. filter_implemented_name: LMSPageURLRequested - ## .. filter_type: org.openedx.course_authoring.lms.page.url.requested.v1 + ## .. filter_type: org.openedx.content_authoring.lms.page.url.requested.v1 lms_root, _ = LMSPageURLRequested.run_filter( url=configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), org=location.org, diff --git a/cms/djangoapps/contentstore/tests/test_filters.py b/cms/djangoapps/contentstore/tests/test_filters.py index 13bdfa07473..4011ae728b3 100644 --- a/cms/djangoapps/contentstore/tests/test_filters.py +++ b/cms/djangoapps/contentstore/tests/test_filters.py @@ -48,7 +48,7 @@ def setUp(self): # pylint: disable=arguments-differ @override_settings( OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.course_authoring.lms.page.url.requested.v1": { + "org.openedx.content_authoring.lms.page.url.requested.v1": { "pipeline": [ "common.djangoapps.util.tests.test_filters.TestPageURLRequestedPipelineStep", ], diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index e6b41dc261d..1de45f716d7 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -9,13 +9,14 @@ from django.db import transaction from django.http import Http404, HttpResponse from django.utils.translation import gettext as _ +from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment from cms.djangoapps.contentstore.utils import load_services_for_studio from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW -from common.djangoapps.edxmako.shortcuts import render_to_string +from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string from common.djangoapps.student.auth import ( has_studio_read_access, has_studio_write_access, @@ -44,6 +45,8 @@ is_unit, ) from .preview import get_preview_fragment +from .component import _get_item_in_course +from ..utils import get_container_handler_context from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( handle_xblock, @@ -302,6 +305,39 @@ def xblock_view_handler(request, usage_key_string, view_name): return HttpResponse(status=406) +@xframe_options_exempt +@require_http_methods(["GET"]) +@login_required +def xblock_edit_view(request, usage_key_string): + """ + Return rendered xblock edit view. + + Allows editing of an XBlock specified by the usage key. + """ + usage_key = usage_key_with_run(usage_key_string) + if not has_studio_read_access(request.user, usage_key.course_key): + raise PermissionDenied() + + store = modulestore() + + with store.bulk_operations(usage_key.course_key): + course, xblock, _, __ = _get_item_in_course(request, usage_key) + container_handler_context = get_container_handler_context(request, usage_key, course, xblock) + + fragment = get_preview_fragment(request, xblock, {}) + + hashed_resources = { + hash_resource(resource): resource._asdict() for resource in fragment.resources + } + + container_handler_context.update({ + "action_name": "edit", + "resources": list(hashed_resources.items()), + }) + + return render_to_response('container_editor.html', container_handler_context) + + @require_http_methods("GET") @login_required @expect_json diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index cfbbcac5cde..93e382fb7fb 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -23,6 +23,7 @@ from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from pyquery import PyQuery from pytz import UTC +from bs4 import BeautifulSoup from web_fragments.fragment import Fragment from webob import Response from xblock.core import XBlockAside @@ -4538,3 +4539,61 @@ def test_update_clobbers(self): user_id=user.id, ) self.check_updated(source_block, destination_block.location) + + +class TestXblockEditView(CourseTestCase): + """ + Test xblock_edit_view. + """ + + def setUp(self): + super().setUp() + self.chapter = self._create_block(self.course, "chapter", "Week 1") + self.sequential = self._create_block(self.chapter, "sequential", "Lesson 1") + self.vertical = self._create_block(self.sequential, "vertical", "Unit") + self.html = self._create_block(self.vertical, "html", "HTML") + self.child_container = self._create_block( + self.vertical, "split_test", "Split Test" + ) + self.child_vertical = self._create_block( + self.child_container, "vertical", "Child Vertical" + ) + self.video = self._create_block(self.child_vertical, "video", "My Video") + self.store = modulestore() + + self.store.publish(self.vertical.location, self.user.id) + + def _create_block(self, parent, category, display_name, **kwargs): + """ + creates a block in the module store, without publishing it. + """ + return BlockFactory.create( + parent=parent, + category=category, + display_name=display_name, + publish_item=False, + user_id=self.user.id, + **kwargs, + ) + + def test_xblock_edit_view(self): + url = reverse_usage_url("xblock_edit_handler", self.video.location) + resp = self.client.get_html(url) + self.assertEqual(resp.status_code, 200) + + html_content = resp.content.decode(resp.charset) + self.assertIn("var decodedActionName = 'edit';", html_content) + + def test_xblock_edit_view_contains_resources(self): + url = reverse_usage_url("xblock_edit_handler", self.video.location) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + + html_content = resp.content.decode(resp.charset) + soup = BeautifulSoup(html_content, "html.parser") + + resource_links = [link["href"] for link in soup.find_all("link", {"rel": "stylesheet"})] + script_sources = [script["src"] for script in soup.find_all("script") if script.get("src")] + + self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}") + self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}") diff --git a/cms/static/cms/js/main.js b/cms/static/cms/js/main.js index c7a2507cf27..1f55dda7062 100644 --- a/cms/static/cms/js/main.js +++ b/cms/static/cms/js/main.js @@ -47,6 +47,17 @@ define([ title: gettext("Studio's having trouble saving your work"), message: message }); + if (window.self !== window.top) { + try { + window.parent.postMessage({ + type: 'studioAjaxError', + message: 'Sends a message when an AJAX error occurs', + payload: {} + }, document.referrer); + } catch (e) { + console.error(e); + } + } console.log('Studio AJAX Error', { // eslint-disable-line no-console url: event.currentTarget.URL, response: jqXHR.responseText, diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index 7bf3372c614..8615eb486fa 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -70,17 +70,6 @@ function($, _, XBlockView, ModuleUtils, gettext, StringUtils, NotificationView) newParent = undefined; }, update: function(event, ui) { - try { - window.parent.postMessage( - { - type: 'refreshPositions', - message: 'Refresh positions of all xblocks', - payload: {} - }, document.referrer - ); - } catch (e) { - console.error(e); - } // When dragging from one ol to another, this method // will be called twice (once for each list). ui.sender will // be null if the change is related to the list the element @@ -137,6 +126,17 @@ function($, _, XBlockView, ModuleUtils, gettext, StringUtils, NotificationView) if (successCallback) { successCallback(); } + try { + window.parent.postMessage( + { + type: 'refreshPositions', + message: 'Refresh positions of all xblocks', + payload: {} + }, document.referrer + ); + } catch (e) { + console.error(e); + } // Update publish and last modified information from the server. xblockInfo.fetch(); } diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js index 65b7f06ae02..b2e63403f88 100644 --- a/cms/static/js/views/modals/base_modal.js +++ b/cms/static/js/views/modals/base_modal.js @@ -109,6 +109,18 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], }, hide: function() { + try { + window.parent.postMessage( + { + type: 'hideXBlockEditorModal', + message: 'Sends a message when the modal window is hided', + payload: {} + }, document.referrer + ); + } catch (e) { + console.error(e); + } + // Completely remove the modal from the DOM this.undelegateEvents(); this.$el.html(''); @@ -119,6 +131,15 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], event.preventDefault(); event.stopPropagation(); // Make sure parent modals don't see the click } + try { + window.parent.postMessage({ + type: 'closeXBlockEditorModal', + message: 'Sends a message when the modal window is closed', + payload: {} + }, document.referrer); + } catch (e) { + console.error(e); + } this.hide(); }, diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index b5b69c721b1..586d27d8b28 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -83,6 +83,11 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE }, getXBlockUpstreamLink: function() { + if (!this.xblockElement || !this.xblockElement.length) { + console.error('xblockElement is empty or not defined'); + return; + } + const usageKey = this.xblockElement.data('locator'); $.ajax({ url: '/api/contentstore/v2/downstreams/' + usageKey, @@ -219,6 +224,16 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE }, onSave: function() { + try { + window.parent.postMessage({ + type: 'saveEditedXBlockData', + message: 'Sends a message when the xblock data is saved', + payload: {} + }, document.referrer); + } catch (e) { + console.error(e); + } + var refresh = this.editOptions.refresh; this.hide(); if (refresh) { @@ -230,6 +245,16 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE // Notify child views to stop listening events Backbone.trigger('xblock:editorModalHidden'); + try { + window.parent.postMessage({ + type: 'closeXBlockEditorModal', + message: 'Sends a message when the modal window is closed', + payload: {} + }, document.referrer); + } catch (e) { + console.error(e); + } + BaseModal.prototype.hide.call(this); // Notify the runtime that the modal has been hidden diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index a18045b8bd8..dc44b15238a 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -149,6 +149,9 @@ function($, _, Backbone, gettext, BasePage, case 'refreshXBlock': this.render(); break; + case 'completeXBlockEditing': + this.refreshXBlock(xblockElement, false); + break; case 'completeManageXBlockAccess': this.refreshXBlock(xblockElement, false); break; @@ -507,6 +510,18 @@ function($, _, Backbone, gettext, BasePage, window.location.href = destinationUrl; return; } + + if (this.options.isIframeEmbed) { + return window.parent.postMessage( + { + type: 'editXBlock', + message: 'Sends a message when the legacy modal window is shown', + payload: { + id: this.findXBlockElement(event.target).data('locator') + } + }, document.referrer + ); + } } var xblockElement = this.findXBlockElement(event.target), @@ -1050,23 +1065,20 @@ function($, _, Backbone, gettext, BasePage, }, viewXBlockContent: function(event) { - try { - if (this.options.isIframeEmbed) { - event.preventDefault(); - var usageId = event.currentTarget.href.split('/').pop() || ''; - window.parent.postMessage( - { - type: 'handleViewXBlockContent', - payload: { - usageId: usageId, - }, - }, document.referrer - ); - return true; + try { + if (this.options.isIframeEmbed) { + event.preventDefault(); + var usageId = event.currentTarget.href.split('/').pop() || ''; + window.parent.postMessage({ + type: 'handleViewXBlockContent', + message: 'View the content of the XBlock', + payload: { usageId }, + }, document.referrer); + return true; + } + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); - } }, toggleSaveButton: function() { diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index cfb47c7cd89..cbe08fe1fd0 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -527,7 +527,9 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H tagValueElement.className = 'tagging-label-value'; tagContentElement.appendChild(tagValueElement); - parentElement.appendChild(tagContentElement); + if (parentElement) { + parentElement.appendChild(tagContentElement); + } if (tag.children.length > 0) { var tagIconElement = document.createElement('span'), diff --git a/cms/static/sass/course-unit-mfe-iframe-bundle.scss b/cms/static/sass/course-unit-mfe-iframe-bundle.scss index c9b111b9128..10754b7a51b 100644 --- a/cms/static/sass/course-unit-mfe-iframe-bundle.scss +++ b/cms/static/sass/course-unit-mfe-iframe-bundle.scss @@ -1,11 +1,14 @@ @import 'cms/theme/variables-v1'; @import 'elements/course-unit-mfe-iframe'; -body { - min-width: 800px; +html { + body { + min-width: 800px; + background: transparent; + } } -.wrapper { +[class*="view-"] .wrapper { .inner-wrapper { max-width: 100%; } @@ -114,7 +117,6 @@ body { background-color: $primary; color: $white; border-color: $transparent; - color: $white; } &:focus { @@ -171,6 +173,11 @@ body { } } + .edit-xblock-modal select { + background-color: $white; + width: 100%; + } + &.wrapper-modal-window .modal-window .modal-actions a { color: $text-color; background-color: $transparent; @@ -441,6 +448,10 @@ body { } } } + + .studio-xblock-wrapper::marker { + content: ''; + } } .view-container .content-primary { @@ -457,7 +468,7 @@ body { @extend %button-styles; position: relative; - top: 7px; + top: -7px; .fa-pencil { display: none; @@ -561,19 +572,40 @@ body { } } -[class*="view-"] .modal-lg.modal-editor .modal-header .editor-modes .action-item { - .editor-button, - .settings-button { - @extend %light-button; - } +body [class*="view-"] .openassessment_editor_buttons.xblock-actions { + padding: 15px 2% 3px 2%; } -[class*="view-"] .wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary { - @extend %primary-button; +[class*="view-"] { + .modal-lg { + max-width: 1200px; + } + + .modal-lg.modal-editor .modal-header .editor-modes .action-item { + .editor-button, + .settings-button { + @extend %light-button; + } + } + + .wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary { + @extend %primary-button; + } + + #openassessment-editor { + #oa_basic_settings_editor #openassessment_title_editor_wrapper input, input[type=number] { + width: 48%; + } + } } -.wrapper-comp-settings { +[class*="view-"] div.wrapper-comp-settings { .list-input.settings-list { + input:not([type="file"]):not([type="number"]), + select { + width: 48%; + } + .metadata-list-enum .create-setting { @extend %modal-actions-button; @@ -597,6 +629,7 @@ body { .list-input.settings-list { .field.comp-setting-entry.is-set .setting-input { color: $text-color; + margin-bottom: 5px; } select { @@ -784,3 +817,39 @@ select { .wrapper-xblock .xblock-header-primary .header-actions .wrapper-nav-sub { z-index: $zindex-dropdown; } + +.xblock-studio_view-drag-and-drop-v2 .xblock--drag-and-drop--editor { + .zone-align-select, + .item-styles-form input, + .drag-builder textarea, + .target-image-form textarea { + width: 100%; + } + + .target-image-form input[type="text"] { + width: 100%; + + &.background-url { + margin-bottom: 10px; + } + + &.autozone-layout { + &.autozone-layout-cols, + &.autozone-layout-rows { + width: auto; + } + } + + &.autozone-size { + &.autozone-size-width, + &.autozone-size-height { + width: auto; + } + } + } + + .feedback-tab input:not([type=checkbox]), + .xblock--drag-and-drop--editor .feedback-tab select { + width: 100%; + } +} diff --git a/cms/templates/container_editor.html b/cms/templates/container_editor.html new file mode 100644 index 00000000000..e7585d7b966 --- /dev/null +++ b/cms/templates/container_editor.html @@ -0,0 +1,133 @@ +## coding=utf-8 +## mako + +## Pages currently use v1 styling by default. Once the Pattern Library +## rollout has been completed, this default can be switched to v2. +<%! main_css = "style-main-v1" %> + +<%! course_unit_mfe_iframe_css = "course-unit-mfe-iframe-bundle" %> + +## Standard imports +<%namespace name='static' file='static_content.html'/> +<%! +from django.utils.translation import gettext as _ +from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES +from lms.djangoapps.branding import api as branding_api +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +from cms.djangoapps.contentstore.helpers import xblock_type_display_name +from openedx.core.release import RELEASE_LINE +%> +<%def name="online_help_token()"> +<% + return "container" +%> + + +<%page expression_filter="h"/> + + + + <% + jsi18n_path = "js/i18n/{language}/djangojs.js".format(language=LANGUAGE_CODE) + %> + + % if getattr(settings, 'CAPTURE_CONSOLE_LOG', False): + + % endif + + + + % if settings.DEBUG: + + % endif + + <%static:css group='style-vendor'/> + <%static:css group='style-vendor-tinymce-content'/> + <%static:css group='style-vendor-tinymce-skin'/> + <%static:css group='${self.attr.course_unit_mfe_iframe_css}'/> + + % if uses_bootstrap: + + % else: + <%static:css group='${self.attr.main_css}'/> + % endif + + <%include file="widgets/segment-io.html" /> + + <%block name="header_extras"> + % for template_name in templates: + + % endfor + + + % if not settings.STUDIO_FRONTEND_CONTAINER_URL: + + + % endif + + % for _, resource in resources: + % if resource['kind'] == 'url' and resource['mimetype'] == 'text/css': + + % endif + % endfor + + + + + + + + <%static:js group='base_vendor' /> + <%static:webpack entry='commons' /> + + + + + <%block name='page_bundle'> + <%static:webpack entry="js/factories/container"> + require(['js/models/xblock_info', 'js/views/modals/edit_xblock'], + function (XBlockInfo, EditXBlockModal) { + var decodedActionName = '${action_name|n, decode.utf8}'; + var encodedXBlockDetails = ${xblock_info | n, dump_js_escaped_json}; + + if (decodedActionName === 'edit') { + var editXBlockModal = new EditXBlockModal(); + var xblockInfoInstance = new XBlockInfo(encodedXBlockDetails); + + editXBlockModal.edit([], xblockInfoInstance, {}); + } + }); + + + + diff --git a/cms/urls.py b/cms/urls.py index 50781b4bb3b..d01e89d9d27 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -18,6 +18,7 @@ import openedx.core.djangoapps.lang_pref.views from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore import views as contentstore_views +from cms.djangoapps.contentstore.views.block import xblock_edit_view from cms.djangoapps.contentstore.views.organization import OrganizationListView from openedx.core.apidocs import api_info from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance @@ -150,6 +151,8 @@ name='xblock_outline_handler'), re_path(fr'^xblock/container/{settings.USAGE_KEY_PATTERN}$', contentstore_views.xblock_container_handler, name='xblock_container_handler'), + re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}/action/edit$', xblock_edit_view, + name='xblock_edit_handler'), re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}/(?P[^/]+)$', contentstore_views.xblock_view_handler, name='xblock_view_handler'), re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}?$', contentstore_views.xblock_handler, diff --git a/docs/decisions/0022-create-waffle-switch.rst b/docs/decisions/0022-create-waffle-switch.rst deleted file mode 100644 index 055c40bf155..00000000000 --- a/docs/decisions/0022-create-waffle-switch.rst +++ /dev/null @@ -1,58 +0,0 @@ -############################################################### -Integration of Waffle Switch for XQueue Submission -############################################################### - -Status -****** - -**Pending** *2025-02-11* - -Implementation in progress. - -Context -******* - -In the `edx-platform` repository, there was a need to implement a mechanism that allows conditional execution of a new functionality: sending a student's response within an exercise to the `created_submission` function. This mechanism should be easily toggleable without requiring code changes or deployments. - -Decision -******** - -A `waffle switch` named `xqueue_submission.enabled` was introduced within the Django admin interface. When this switch is activated, it enables the functionality to send data to the `send_to_submission` function, which parses and forwards the data to the `created_submission` function. - -The `created_submission` function resides in the `edx-submissions` repository and is responsible for storing the data in the submissions database. - -Implementation Details ----------------------- - -This functionality was implemented within the `edx-platform` repository by modifying the following files: - -1. **`xmodule/capa/xqueue_interfaces.py`** - - The `waffle switch` **`xqueue_submission.enabled`** was added here. - - This switch is checked before invoking `send_to_submission`, ensuring that the submission logic is only executed when enabled. - -2. **`xmodule/capa/xqueue_submission.py`** - - This file contains the newly implemented logic that parses the student’s response. - - It processes and formats the data before calling `created_submission`, ensuring that it is correctly stored in the **edx-submissions** repository. - -Consequences -************ - -Positive: ---------- - -- **Flexibility:** The use of a `waffle switch` allows administrators to enable or disable the new submission functionality without modifying the codebase or redeploying the application. -- **Control:** Administrators can manage the feature directly from the Django admin interface, providing a straightforward method to toggle the feature as needed. -- **Modular Design:** The logic was added in a way that allows future modifications without affecting existing submission workflows. - -Negative: ---------- - -- **Potential Misconfiguration:** If the `waffle switch` is not properly managed, there could be inconsistencies in submission processing. -- **Admin Overhead:** Requires monitoring to ensure the toggle is enabled when needed. - -References -********** - -- Commit implementing the change: [f50afcc301bdc3eeb42a6dc2c051ffb2d799f868#diff-9b4290d2b574f54e4eca7831368727f7ddbac8292aa75ba4b28651d4bf2bbe6b](https://github.com/aulasneo/edx-platform/commit/f50afcc301bdc3eeb42a6dc2c051ffb2d799f868#diff-9b4290d2b574f54e4eca7831368727f7ddbac8292aa75ba4b28651d4bf2bbe6b) -- Open edX Feature Toggles Documentation: [Feature Toggles — edx-platform documentation](https://docs.openedx.org/projects/edx-platform/en/latest/references/featuretoggles.html) -- `edx-submissions` Repository: [openedx/edx-submissions](https://github.com/openedx/edx-submissions) \ No newline at end of file diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 3b4cafed857..0e011163903 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -38,13 +38,7 @@ "cms.djangoapps.xblock_config.apps.XBlockConfig", "lms.djangoapps.lti_provider", "openedx.core.djangoapps.content.search", - "openedx.core.djangoapps.content_libraries", "openedx.core.djangoapps.content_staging", - "openedx.core.djangoapps.bookmarks", - "openedx.core.djangoapps.discussions", - "openedx.core.djangoapps.theming", - "lms.djangoapps.program_enrollments", - "push_notifications", ] ) diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml index 664c8fed03d..32f67203b45 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -122,6 +122,105 @@ paths: in: path required: true type: string + /bookmarks/v1/bookmarks/: + get: + operationId: bookmarks_v1_bookmarks_list + summary: Get a paginated list of bookmarks for a user. + description: |- + The list can be filtered by passing parameter "course_id=" + to only include bookmarks from a particular course. + + The bookmarks are always sorted in descending order by creation date. + + Each page in the list contains 10 bookmarks by default. The page + size can be altered by passing parameter "page_size=". + + To include the optional fields pass the values in "fields" parameter + as a comma separated list. Possible values are: + + * "display_name" + * "path" + + **Example Requests** + + GET /api/bookmarks/v1/bookmarks/?course_id={course_id1}&fields=display_name,path + parameters: + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + - name: course_id + in: query + description: The id of the course to limit the list + type: string + - name: fields + in: query + description: 'The fields to return: display_name, path.' + type: string + responses: + '200': + description: '' + tags: + - bookmarks + post: + operationId: bookmarks_v1_bookmarks_create + summary: Create a new bookmark for a user. + description: |- + The POST request only needs to contain one parameter "usage_id". + + Http400 is returned if the format of the request is not correct, + the usage_id is invalid or a block corresponding to the usage_id + could not be found. + + **Example Requests** + + POST /api/bookmarks/v1/bookmarks/ + Request data: {"usage_id": } + parameters: [] + responses: + '201': + description: '' + tags: + - bookmarks + parameters: [] + /bookmarks/v1/bookmarks/{username},{usage_id}/: + get: + operationId: bookmarks_v1_bookmarks_read + summary: Get a specific bookmark for a user. + description: |- + **Example Requests** + + GET /api/bookmarks/v1/bookmarks/{username},{usage_id}?fields=display_name,path + parameters: [] + responses: + '200': + description: '' + tags: + - bookmarks + delete: + operationId: bookmarks_v1_bookmarks_delete + description: DELETE /api/bookmarks/v1/bookmarks/{username},{usage_id} + parameters: [] + responses: + '204': + description: '' + tags: + - bookmarks + parameters: + - name: username + in: path + required: true + type: string + - name: usage_id + in: path + required: true + type: string /bulk_enroll/v1/bulk_enroll: post: operationId: bulk_enroll_v1_bulk_enroll_create @@ -1749,6 +1848,114 @@ paths: in: path required: true type: string + /course_live/course/{course_id}/: + get: + operationId: course_live_course_read + summary: Handle HTTP/GET requests + description: Handle HTTP/GET requests + parameters: + - name: course_id + in: path + description: The course for which to get provider list + type: string + required: true + responses: + '200': + description: '' + schema: + $ref: '#/definitions/CourseLiveConfiguration' + '401': + description: The requester is not authenticated. + '403': + description: The requester cannot access the specified course. + '404': + description: The requested course does not exist. + tags: + - course_live + post: + operationId: course_live_course_create + summary: Handle HTTP/POST requests + description: Handle HTTP/POST requests + parameters: + - name: course_id + in: path + description: The course for which to get provider list + type: string + required: true + - name: lti_1p1_client_key + in: path + description: The LTI provider's client key + type: string + required: true + - name: lti_1p1_client_secret + in: path + description: The LTI provider's client secretL + type: string + required: true + - name: lti_1p1_launch_url + in: path + description: The LTI provider's launch URL + type: string + required: true + - name: provider_type + in: path + description: The LTI provider's launch URL + type: string + required: true + - name: lti_config + in: query + description: 'The lti_config object with required additional parameters ' + type: object + responses: + '200': + description: '' + schema: + $ref: '#/definitions/CourseLiveConfiguration' + '400': + description: Required parameters are missing. + '401': + description: The requester is not authenticated. + '403': + description: The requester cannot access the specified course. + '404': + description: The requested course does not exist. + tags: + - course_live + parameters: + - name: course_id + in: path + required: true + type: string + /course_live/iframe/{course_id}/: + get: + operationId: course_live_iframe_read + description: Handle HTTP/GET requests + parameters: [] + responses: + '200': + description: '' + tags: + - course_live + parameters: + - name: course_id + in: path + required: true + type: string + /course_live/providers/{course_id}/: + get: + operationId: course_live_providers_read + description: A view for retrieving Program live IFrame . + parameters: [] + responses: + '200': + description: '' + tags: + - course_live + parameters: + - name: course_id + in: path + required: true + type: string /course_modes/v1/courses/{course_id}/: get: operationId: course_modes_v1_courses_read @@ -2936,6 +3143,168 @@ paths: in: path required: true type: string + /courseware/celebration/{course_key_string}: + post: + operationId: courseware_celebration_create + description: Handle a POST request. + parameters: [] + responses: + '201': + description: '' + tags: + - courseware + parameters: + - name: course_key_string + in: path + required: true + type: string + /courseware/course/{course_key_string}: + get: + operationId: courseware_course_read + summary: '**Use Cases**' + description: |- + Request details for a course + + **Example Requests** + + GET /api/courseware/course/{course_key} + + **Response Values** + + Body consists of the following fields: + + * access_expiration: An object detailing when access to this course will expire + * expiration_date: (str) When the access expires, in ISO 8601 notation + * masquerading_expired_course: (bool) Whether this course is expired for the masqueraded user + * upgrade_deadline: (str) Last chance to upgrade, in ISO 8601 notation (or None if can't upgrade anymore) + * upgrade_url: (str) Upgrade linke (or None if can't upgrade anymore) + * celebrations: An object detailing which celebrations to render + * first_section: (bool) If the first section celebration should render + Note: Also uses information from frontend so this value is not final + * streak_length_to_celebrate: (int) The streak length to celebrate for the learner + * streak_discount_enabled: (bool) If the frontend should render an upgrade discount for hitting the streak + * weekly_goal: (bool) If the weekly goal celebration should render + * course_goals: + * selected_goal: + * days_per_week: (int) The number of days the learner wants to learn per week + * subscribed_to_reminders: (bool) Whether the learner wants email reminders about their goal + * weekly_learning_goal_enabled: Flag indicating if this feature is enabled for this call + * effort: A textual description of the weekly hours of effort expected + in the course. + * end: Date the course ends, in ISO 8601 notation + * enrollment: Enrollment status of authenticated user + * mode: `audit`, `verified`, etc + * is_active: boolean + * enrollment_end: Date enrollment ends, in ISO 8601 notation + * enrollment_start: Date enrollment begins, in ISO 8601 notation + * entrance_exam_data: An object containing information about the course's entrance exam + * entrance_exam_current_score: (float) The users current score on the entrance exam + * entrance_exam_enabled: (bool) If the course has an entrance exam + * entrance_exam_id: (str) The block id for the entrance exam if enabled. Will be a section (chapter) + * entrance_exam_minimum_score_pct: (float) The minimum score a user must receive on the entrance exam + to unlock the remainder of the course. Returned as a float (i.e. 0.7 for 70%) + * entrance_exam_passed: (bool) Indicates if the entrance exam has been passed + * id: A unique identifier of the course; a serialized representation + of the opaque key identifying the course. + * language: The language code for the course + * media: An object that contains named media items. Included here: + * course_image: An image to show for the course. Represented + as an object with the following fields: + * uri: The location of the image + * name: Name of the course + * offer: An object detailing upgrade discount information + * code: (str) Checkout code + * expiration_date: (str) Expiration of offer, in ISO 8601 notation + * original_price: (str) Full upgrade price without checkout code; includes currency symbol + * discounted_price: (str) Upgrade price with checkout code; includes currency symbol + * percentage: (int) Amount of discount + * upgrade_url: (str) Checkout URL + * org: Name of the organization that owns the course + * related_programs: A list of objects that contains program data related to the given course including: + * progress: An object containing program progress: + * complete: (int) Number of complete courses in the program (a course is completed if the user has + earned a certificate for any of the nested course runs) + * in_progress: (int) Number of courses in the program that are in progress (a course is in progress if + the user has enrolled in any of the nested course runs) + * not_started: (int) Number of courses in the program that have not been started + * slug: (str) The program type + * title: (str) The title of the program + * url: (str) The link to the program's landing page + * uuid: (str) A unique identifier of the program + * short_description: A textual description of the course + * start: Date the course begins, in ISO 8601 notation + * start_display: Readably formatted start of the course + * start_type: Hint describing how `start_display` is set. One of: + * `"string"`: manually set by the course author + * `"timestamp"`: generated from the `start` timestamp + * `"empty"`: no start date is specified + * pacing: Course pacing. Possible values: instructor, self + * user_timezone: User's chosen timezone setting (or null for browser default) + * user_has_passing_grade: Whether or not the effective user's grade is equal to or above the courses minimum + passing grade + * course_exit_page_is_active: Flag for the learning mfe on whether or not the course exit page should display + * certificate_data: data regarding the effective user's certificate for the given course + * verify_identity_url: URL for a learner to verify their identity. Only returned for learners enrolled in a + verified mode. Will update to reverify URL if necessary. + * linkedin_add_to_profile_url: URL to add the effective user's certificate to a LinkedIn Profile. + * user_needs_integrity_signature: Whether the user needs to sign the integrity agreement for the course + * learning_assistant_enabled: Whether the Xpert Learning Assistant is enabled for the requesting user + + **Parameters:** + + requested_fields (optional) comma separated list: + If set, then only those fields will be returned. + username (optional) username to masquerade as (if requesting user is staff) + + **Returns** + + * 200 on success with above fields. + * 400 if an invalid parameter was sent or the username was not provided + for an authenticated request. + * 403 if a user who does not have permission to masquerade as + another user specifies a username other than their own. + * 404 if the course is not available or cannot be seen. + parameters: [] + responses: + '200': + description: '' + tags: + - courseware + parameters: + - name: course_key_string + in: path + required: true + type: string + /courseware/resume/{course_key_string}: + get: + operationId: courseware_resume_read + description: Return response to a GET request. + parameters: [] + responses: + '200': + description: '' + tags: + - courseware + parameters: + - name: course_key_string + in: path + required: true + type: string + /courseware/sequence/{usage_key_string}: + get: + operationId: courseware_sequence_read + description: Return response to a GET request. + parameters: [] + responses: + '200': + description: '' + tags: + - courseware + parameters: + - name: usage_key_string + in: path + required: true + type: string /credit/v1/courses/: get: operationId: credit_v1_courses_list @@ -5245,17 +5614,16 @@ paths: description: A unique integer value identifying this Experiment Key-Value Pair. required: true type: integer - /instructor_task/v1/schedules/{course_id}/bulk_email/: + /grades/v1/courses/: get: - operationId: instructor_task_v1_schedules_bulk_email_list - description: Read only view to list all scheduled bulk email messages for a - course-run. + operationId: grades_v1_courses_list + description: Gets a course progress status. parameters: - - name: page + - name: cursor in: query - description: A page number within the paginated result set. + description: The pagination cursor value. required: false - type: integer + type: string - name: page_size in: query description: Number of results to return per page. @@ -5264,24 +5632,528 @@ paths: responses: '200': description: '' - schema: - required: - - count - - results - type: object - properties: - count: - type: integer - next: - type: string - format: uri - x-nullable: true - previous: - type: string - format: uri - x-nullable: true - results: - type: array + tags: + - grades + parameters: [] + /grades/v1/courses/{course_id}/: + get: + operationId: grades_v1_courses_read + description: Gets a course progress status. + parameters: [] + responses: + '200': + description: '' + tags: + - grades + parameters: + - name: course_id + in: path + required: true + type: string + /grades/v1/gradebook/{course_id}/: + get: + operationId: grades_v1_gradebook_read + description: |- + Checks for course author access for the given course by the requesting user. + Calls the view function if has access, otherwise raises a 403. + parameters: [] + responses: + '200': + description: '' + tags: + - grades + parameters: + - name: course_id + in: path + required: true + type: string + /grades/v1/gradebook/{course_id}/bulk-update: + post: + operationId: grades_v1_gradebook_bulk-update_create + description: |- + Checks for course author access for the given course by the requesting user. + Calls the view function if has access, otherwise raises a 403. + parameters: [] + responses: + '201': + description: '' + tags: + - grades + parameters: + - name: course_id + in: path + required: true + type: string + /grades/v1/gradebook/{course_id}/grading-info: + get: + operationId: grades_v1_gradebook_grading-info_list + description: |- + Checks for course author access for the given course by the requesting user. + Calls the view function if has access, otherwise raises a 403. + parameters: + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + tags: + - grades + parameters: + - name: course_id + in: path + required: true + type: string + /grades/v1/policy/courses/{course_id}/: + get: + operationId: grades_v1_policy_courses_read + summary: '**Use Case**' + description: |- + Get the course grading policy. + + **Example requests**: + + GET /api/grades/v1/policy/courses/{course_id}/ + + **Response Values** + + * assignment_type: The type of the assignment, as configured by course + staff. For example, course staff might make the assignment types Homework, + Quiz, and Exam. + + * count: The number of assignments of the type. + + * dropped: Number of assignments of the type that are dropped. + + * weight: The weight, or effect, of the assignment type on the learner's + final grade. + parameters: + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + tags: + - grades + parameters: + - name: course_id + in: path + required: true + type: string + /grades/v1/section_grades_breakdown/: + get: + operationId: grades_v1_section_grades_breakdown_list + summary: '**Use Cases**' + description: |- + Get a list of all grades for all sections, optionally filtered by a course ID or list of usernames. + + **Example Requests** + + GET /api/grades/v1/section_grades_breakdown + + GET /api/grades/v1/section_grades_breakdown?course_id={course_id} + + GET /api/grades/v1/section_grades_breakdown?username={username},{username},{username} + + GET /api/grades/v1/section_grades_breakdown?course_id={course_id}&username={username} + + **Query Parameters for GET** + + * course_id: Filters the result to course grade status for the course corresponding to the + given course ID. The value must be URL encoded. Optional. + + * username: List of comma-separated usernames. Filters the result to the course grade status + of the given users. Optional. + + * page_size: Number of results to return per page. Optional. + + **Response Values** + + If the request for information about the course grade status is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * results: A list of the course grading status matching the request. + + * course_id: Course ID of the course in the course grading status. + + * user: Username of the user in the course enrollment. + + * passed: Boolean flag for user passing the course. + + * current_grade: An integer representing the current grade of the course. + + * section_breakdown: A summary of each course section's grade. + + A dictionary in the section_breakdown list has the following keys: + * percent: A float percentage for the section. + * label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3". + * detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)" + * category: A string identifying the category. + * prominent: A boolean value indicating that this section should be displayed as more prominent + than other items. + + * next: The URL to the next page of results, or null if this is the + last page. + + * previous: The URL to the next page of results, or null if this + is the first page. + + If the user is not logged in, a 401 error is returned. + + If the user is not global staff, a 403 error is returned. + + If the specified course_id is not valid or any of the specified usernames + are not valid, a 400 error is returned. + + If the specified course_id does not correspond to a valid course or if all the specified + usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an + empty 'results' field. + parameters: + - name: cursor + in: query + description: The pagination cursor value. + required: false + type: string + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + tags: + - grades + parameters: [] + /grades/v1/submission_history/{course_id}/: + get: + operationId: grades_v1_submission_history_read + description: |- + Get submission history details. This submission history is related to only + ProblemBlock and it doesn't support LegacyLibraryContentBlock or ContentLibraries + as of now. + + **Usecases**: + + Users with GlobalStaff status can retrieve everyone's submission history. + + **Example Requests**: + + GET /api/grades/v1/submission_history/{course_id} + GET /api/grades/v1/submission_history/{course_id}/?username={username} + + **Query Parameters for GET** + + * course_id: Course id to retrieve submission history. + * username: Single username for which this view will retrieve the submission history details. + + **Response Values**: + + If there's an error while getting the submission history an empty response will + be returned. + The submission history response has the following attributes: + + * Results: A list of submission history: + * course_id: Course id + * course_name: Course name + * user: Username + * problems: List of problems + * location: problem location + * name: problem's display name + * submission_history: List of submission history + * state: State of submission. + * grade: Grade. + * max_grade: Maximum possible grade. + * data: problem's data. + parameters: [] + responses: + '200': + description: '' + tags: + - grades + parameters: + - name: course_id + in: path + required: true + type: string + /grades/v1/subsection/{subsection_id}/: + get: + operationId: grades_v1_subsection_read + description: |- + Returns subection grade data, override grade data and a history of changes made to + a specific users specific subsection grade. + parameters: [] + responses: + '200': + description: '' + tags: + - grades + parameters: + - name: subsection_id + in: path + required: true + type: string + /instructor/v1/reports/{course_id}: + get: + operationId: instructor_v1_reports_read + summary: List report CSV files that are available for download for this course. + description: |- + **Use Cases** + + Lists reports available for download + + **Example Requests**: + + GET /api/instructor/v1/reports/{course_id} + + **Response Values** + ```json + { + "downloads": [ + { + "url": "https://1.mock.url", + "link": "mock_file_name_1", + "name": "mock_file_name_1" + } + ] + } + ``` + + The report name will depend on the type of report generated. For example a + problem responses report for an entire course might be called: + + edX_DemoX_Demo_Course_student_state_from_block-v1_edX+DemoX+Demo_Course+type@course+block@course_2021-04-30-0918.csv + parameters: + - name: course_id + in: path + description: ID for the course whose reports need to be listed. + type: string + required: true + - name: report_name + in: query + description: Filter results to only return details of for the report with + the specified name. + type: string + responses: + '200': + description: '' + schema: + $ref: '#/definitions/ReportDownloadsList' + '401': + description: The requesting user is not authenticated. + '403': + description: The requesting user lacks access to the course. + '404': + description: The requested course does not exist. + tags: + - instructor + parameters: + - name: course_id + in: path + required: true + type: string + /instructor/v1/reports/{course_id}/generate/problem_responses: + post: + operationId: instructor_v1_reports_generate_problem_responses_create + summary: Initiate generation of a CSV file containing all student answers + description: |- + to a given problem. + + **Example requests** + + POST /api/instructor/v1/reports/{course_id}/generate/problem_responses { + "problem_locations": [ + "{usage_key1}", + "{usage_key2}", + "{usage_key3}" + ] + } + POST /api/instructor/v1/reports/{course_id}/generate/problem_responses { + "problem_locations": ["{usage_key}"], + "problem_types_filter": ["problem"] + } + + **POST Parameters** + + A POST request can include the following parameters: + + * problem_location: A list of usage keys for the blocks to include in + the report. If the location is a block that contains other blocks, + (such as the course, section, subsection, or unit blocks) then all + blocks under that block will be included in the report. + * problem_types_filter: Optional. A comma-separated list of block types + to include in the report. If set, only blocks of the specified types + will be included in the report. + + To get data on all the poll and survey blocks in a course, you could + POST the usage key of the course for `problem_location`, and + "poll, survey" as the value for `problem_types_filter`. + + + **Example Response:** + If initiation is successful (or generation task is already running): + ```json + { + "status": "The problem responses report is being created. ...", + "task_id": "4e49522f-31d9-431a-9cff-dd2a2bf4c85a" + } + ``` + + Responds with BadRequest if any of the provided problem locations are faulty. + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/ProblemResponseReportPostParams' + - name: course_id + in: path + description: ID of the course for which report is to be generate. + type: string + required: true + responses: + '200': + description: '' + schema: + $ref: '#/definitions/ProblemResponsesReportStatus' + '400': + description: The provided parameters were invalid. Make sure you've provided + at least one valid usage key for `problem_locations`. + '401': + description: The requesting user is not authenticated. + '403': + description: The requesting user lacks access to the course. + tags: + - instructor + parameters: + - name: course_id + in: path + required: true + type: string + /instructor/v1/tasks/{course_id}: + get: + operationId: instructor_v1_tasks_read + summary: List instructor tasks filtered by `course_id`. + description: |- + **Use Cases** + + Lists currently running instructor tasks + + **Parameters** + - With no arguments, lists running tasks. + - `problem_location_str` lists task history for problem + - `problem_location_str` and `unique_student_identifier` lists task + history for problem AND student (intersection) + + **Example Requests**: + + GET /courses/{course_id}/instructor/api/v0/tasks + + **Response Values** + ```json + { + "tasks": [ + { + "status": "Incomplete", + "task_type": "grade_problems", + "task_id": "2519ff31-22d9-4a62-91e2-55495895b355", + "created": "2019-01-15T18:00:15.902470+00:00", + "task_input": "{}", + "duration_sec": "unknown", + "task_message": "No status information available", + "requester": "staff", + "task_state": "PROGRESS" + } + ] + } + ``` + parameters: + - name: course_id + in: path + description: ID for the course whose tasks need to be listed. + type: string + required: true + - name: problem_location_str + in: query + description: Filter instructor tasks to this problem location. + type: string + - name: unique_student_identifier + in: query + description: Filter tasks to a singe problem and a single student. Must be + used in combination with `problem_location_str`. + type: string + responses: + '200': + description: '' + schema: + $ref: '#/definitions/InstructorTasksList' + '401': + description: The requesting user is not authenticated. + '403': + description: The requesting user lacks access to the course. + '404': + description: The requested course does not exist. + tags: + - instructor + parameters: + - name: course_id + in: path + required: true + type: string + /instructor_task/v1/schedules/{course_id}/bulk_email/: + get: + operationId: instructor_task_v1_schedules_bulk_email_list + description: Read only view to list all scheduled bulk email messages for a + course-run. + parameters: + - name: page + in: query + description: A page number within the paginated result set. + required: false + type: integer + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + schema: + required: + - count + - results + type: object + properties: + count: + type: integer + next: + type: string + format: uri + x-nullable: true + previous: + type: string + format: uri + x-nullable: true + results: + type: array items: $ref: '#/definitions/ScheduledBulkEmail' tags: @@ -5677,6 +6549,23 @@ paths: tags: - mfe_config parameters: [] + /mfe_context: + get: + operationId: mfe_context_list + description: |- + Returns + - dynamic registration fields + - dynamic optional fields + - the context for third party auth providers + - user country code + - the currently running pipeline. + parameters: [] + responses: + '200': + description: '' + tags: + - mfe_context + parameters: [] /mobile/{api_version}/course_info/blocks/: get: operationId: mobile_course_info_blocks_list @@ -7014,6 +7903,319 @@ paths: in: path required: true type: string + /program_enrollments/v1/integration-reset: + post: + operationId: program_enrollments_v1_integration-reset_create + description: Reset enrollment and user data for organization + parameters: [] + responses: + '201': + description: '' + tags: + - program_enrollments + parameters: [] + /program_enrollments/v1/programs/enrollments/: + get: + operationId: program_enrollments_v1_programs_enrollments_list + description: How to respond to a GET request to this endpoint + parameters: [] + responses: + '200': + description: '' + tags: + - program_enrollments + parameters: [] + /program_enrollments/v1/programs/readonly_access/: + get: + operationId: program_enrollments_v1_programs_readonly_access_list + description: How to respond to a GET request to this endpoint + parameters: [] + responses: + '200': + description: '' + tags: + - program_enrollments + parameters: [] + /program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/enrollments/: + get: + operationId: program_enrollments_v1_programs_courses_enrollments_list + description: Get a list of students enrolled in a course within a program. + parameters: + - name: cursor + in: query + description: The pagination cursor value. + required: false + type: string + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + tags: + - program_enrollments + post: + operationId: program_enrollments_v1_programs_courses_enrollments_create + description: Enroll a list of students in a course in a program + parameters: [] + responses: + '201': + description: '' + tags: + - program_enrollments + put: + operationId: program_enrollments_v1_programs_courses_enrollments_update + description: Create or Update the program course enrollments of a list of learners + parameters: [] + responses: + '200': + description: '' + tags: + - program_enrollments + patch: + operationId: program_enrollments_v1_programs_courses_enrollments_partial_update + description: Modify the program course enrollments of a list of learners + parameters: [] + responses: + '200': + description: '' + tags: + - program_enrollments + parameters: + - name: program_uuid + in: path + required: true + type: string + - name: course_id + in: path + required: true + type: string + /program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/grades/: + get: + operationId: program_enrollments_v1_programs_courses_grades_list + description: Defines the GET list endpoint for ProgramCourseGrade objects. + parameters: + - name: cursor + in: query + description: The pagination cursor value. + required: false + type: string + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + tags: + - program_enrollments + parameters: + - name: program_uuid + in: path + required: true + type: string + - name: course_id + in: path + required: true + type: string + /program_enrollments/v1/programs/{program_uuid}/enrollments/: + get: + operationId: program_enrollments_v1_programs_enrollments_list + description: Defines the GET list endpoint for ProgramEnrollment objects. + parameters: + - name: cursor + in: query + description: The pagination cursor value. + required: false + type: string + - name: page_size + in: query + description: Number of results to return per page. + required: false + type: integer + responses: + '200': + description: '' + tags: + - program_enrollments + post: + operationId: program_enrollments_v1_programs_enrollments_create + description: Create program enrollments for a list of learners + parameters: [] + responses: + '201': + description: '' + tags: + - program_enrollments + put: + operationId: program_enrollments_v1_programs_enrollments_update + description: Create/update program enrollments for a list of learners + parameters: [] + responses: + '200': + description: '' + tags: + - program_enrollments + patch: + operationId: program_enrollments_v1_programs_enrollments_partial_update + description: Update program enrollments for a list of learners + parameters: [] + responses: + '200': + description: '' + tags: + - program_enrollments + parameters: + - name: program_uuid + in: path + required: true + type: string + /program_enrollments/v1/programs/{program_uuid}/overview/: + get: + operationId: program_enrollments_v1_programs_overview_read + description: |- + A view for getting data associated with a user's course enrollments + as part of a program enrollment. + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/CourseRunOverviewList' + tags: + - program_enrollments + parameters: + - name: program_uuid + in: path + required: true + type: string + /program_enrollments/v1/users/{username}/programs/{program_uuid}/courses: + get: + operationId: program_enrollments_v1_users_programs_courses_list + summary: Get an overview of each of a user's course enrollments associated with + a program. + description: |- + This endpoint exists to get an overview of each course-run enrollment + that a user has for course-runs within a given program. + Fields included are the title, upcoming due dates, etc. + This API endpoint is intended for use with the + [Program Learner Portal MFE](https://github.com/openedx/frontend-app-learner-portal-programs). + + It is important to note that the set of enrollments that this endpoint returns + is different than a user's set of *program-course-run enrollments*. + Specifically, this endpoint may include course runs that are *within* + the specified program but were not *enrolled in* via the specified program. + + **Example Response:** + ```json + { + "next": null, + "previous": null, + "results": [ + { + "course_run_id": "edX+AnimalsX+Aardvarks", + "display_name": "Astonishing Aardvarks", + "course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/course/", + "start_date": "2017-02-05T05:00:00Z", + "end_date": "2018-02-05T05:00:00Z", + "course_run_status": "completed" + "emails_enabled": true, + "due_dates": [ + { + "name": "Introduction: What even is an aardvark?", + "url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/jump_to/ + block-v1:edX+AnimalsX+Aardvarks+type@chapter+block@1414ffd5143b4b508f739b563ab468b7", + "date": "2017-05-01T05:00:00Z" + }, + { + "name": "Quiz: Aardvark or Anteater?", + "url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Aardvarks/jump_to/ + block-v1:edX+AnimalsX+Aardvarks+type@sequential+block@edx_introduction", + "date": "2017-03-05T00:00:00Z" + } + ], + "micromasters_title": "Animals", + "certificate_download_url": "https://courses.edx.org/certificates/123" + }, + { + "course_run_id": "edX+AnimalsX+Baboons", + "display_name": "Breathtaking Baboons", + "course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Baboons/course/", + "start_date": "2018-02-05T05:00:00Z", + "end_date": null, + "course_run_status": "in_progress" + "emails_enabled": false, + "due_dates": [], + "micromasters_title": "Animals", + "certificate_download_url": "https://courses.edx.org/certificates/123", + "resume_course_run_url": "https://courses.edx.org/courses/course-v1:edX+AnimalsX+Baboons/jump_to/ + block-v1:edX+AnimalsX+Baboons+type@sequential+block@edx_introduction" + } + ] + } + ``` + parameters: + - name: cursor + in: query + description: The pagination cursor value. + required: false + type: string + - name: page_size + in: query + description: Number of results to return per page. Defaults to 10. Maximum + is 25. + type: integer + - name: username + in: path + description: The username of the user for which enrollment overviews will + be fetched. For now, this must be the requesting user; otherwise, 403 will + be returned. In the future, global staff users may be able to supply other + usernames. + type: string + required: true + - name: program_uuid + in: path + description: UUID of a program. Enrollments will be returned for course runs + in this program. + type: string + required: true + responses: + '200': + description: '' + schema: + $ref: '#/definitions/PageOfCourseRunOverview' + '401': + description: The requester is not authenticated. + '403': + description: The requester cannot access the specified program and/or the + requester may not retrieve this data for the specified user. + '404': + description: The requested program does not exist. + tags: + - program_enrollments + parameters: + - name: username + in: path + required: true + type: string + - name: program_uuid + in: path + required: true + type: string + /send_account_activation_email: + post: + operationId: send_account_activation_email_create + description: Returns status code. + parameters: [] + responses: + '201': + description: '' + tags: + - send_account_activation_email + parameters: [] /team/v0/bulk_team_membership/{course_id}: get: operationId: team_v0_bulk_team_membership_read @@ -7395,16 +8597,81 @@ paths: in: path required: true type: string + /third_party_auth_context: + get: + operationId: third_party_auth_context_list + description: |- + Returns + - dynamic registration fields + - dynamic optional fields + - the context for third party auth providers + - user country code + - the currently running pipeline. + parameters: [] + responses: + '200': + description: '' + tags: + - third_party_auth_context + parameters: [] /toggles/v0/state/: get: - operationId: toggles_v0_state_list - description: Expose toggle state report dict as a view. + operationId: toggles_v0_state_list + description: Expose toggle state report dict as a view. + parameters: [] + responses: + '200': + description: '' + tags: + - toggles + parameters: [] + /user/v1/account/password_reset/: + get: + operationId: user_v1_account_password_reset_list + description: HTTP end-point for GETting a description of the password reset + form. + parameters: [] + responses: + '200': + description: '' + tags: + - user + parameters: [] + /user/v1/account/password_reset/token/validate/: + post: + operationId: user_v1_account_password_reset_token_validate_create + description: HTTP end-point to validate password reset token. + parameters: [] + responses: + '201': + description: '' + tags: + - user + parameters: [] + /user/v1/account/registration/: + get: + operationId: user_v1_account_registration_list + description: HTTP end-points for creating a new user. parameters: [] responses: '200': description: '' tags: - - toggles + - user + post: + operationId: user_v1_account_registration_create + summary: Create the user's account. + description: |- + You must send all required form fields with the request. + + You can optionally send a "course_id" param to indicate in analytics + events that the user registered while enrolling in a particular course. + parameters: [] + responses: + '201': + description: '' + tags: + - user parameters: [] /user/v1/accounts: get: @@ -8194,6 +9461,126 @@ paths: description: A unique integer value identifying this user. required: true type: integer + /user/v1/validation/registration: + post: + operationId: user_v1_validation_registration_create + summary: POST /api/user/v1/validation/registration/ + description: |- + Expects request of the form + ``` + { + "name": "Dan the Validator", + "username": "mslm", + "email": "mslm@gmail.com", + "confirm_email": "mslm@gmail.com", + "password": "password123", + "country": "PK" + } + ``` + where each key is the appropriate form field name and the value is + user input. One may enter individual inputs if needed. Some inputs + can get extra verification checks if entered along with others, + like when the password may not equal the username. + parameters: [] + responses: + '201': + description: '' + tags: + - user + parameters: [] + /user/v2/account/registration/: + get: + operationId: user_v2_account_registration_list + description: HTTP end-points for creating a new user. + parameters: [] + responses: + '200': + description: '' + tags: + - user + post: + operationId: user_v2_account_registration_create + summary: Create the user's account. + description: |- + You must send all required form fields with the request. + + You can optionally send a "course_id" param to indicate in analytics + events that the user registered while enrolling in a particular course. + parameters: [] + responses: + '201': + description: '' + tags: + - user + parameters: [] + /user/{api_version}/account/login_session/: + get: + operationId: user_account_login_session_list + description: HTTP end-points for logging in users. + parameters: [] + responses: + '200': + description: '' + tags: + - user + post: + operationId: user_account_login_session_create + summary: POST /user/{api_version}/account/login_session/ + description: Returns 200 on success, and a detailed error message otherwise. + parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + email: + type: string + password: + type: string + responses: + '200': + description: '' + schema: + type: object + properties: + success: + type: boolean + value: + type: string + error_code: + type: string + '400': + description: '' + schema: + type: object + properties: + success: + type: boolean + value: + type: string + error_code: + type: string + '403': + description: '' + schema: + type: object + properties: + success: + type: boolean + value: + type: string + error_code: + type: string + tags: + - user + security: + - csrf: [] + parameters: + - name: api_version + in: path + required: true + type: string /user_tours/discussion_tours/{tour_id}/: get: operationId: user_tours_discussion_tours_read @@ -9540,6 +10927,65 @@ definitions: disable_progress_graph: title: Disable progress graph type: boolean + Lti: + required: + - lti_config + type: object + properties: + lti_1p1_client_key: + title: Lti 1p1 client key + description: Client key provided by the LTI tool provider. + type: string + maxLength: 255 + lti_1p1_client_secret: + title: Lti 1p1 client secret + description: Client secret provided by the LTI tool provider. + type: string + maxLength: 255 + lti_1p1_launch_url: + title: Lti 1p1 launch url + description: The URL of the external tool that initiates the launch. + type: string + maxLength: 255 + version: + title: Version + type: string + enum: + - lti_1p1 + - lti_1p3 + lti_config: + title: Lti config + type: object + CourseLiveConfiguration: + required: + - provider_type + type: object + properties: + course_key: + title: Course key + type: string + readOnly: true + minLength: 1 + provider_type: + title: LTI provider + description: The LTI provider's id + type: string + maxLength: 50 + minLength: 1 + enabled: + title: Enabled + description: If disabled, the LTI in the associated course will be disabled. + type: boolean + lti_configuration: + $ref: '#/definitions/Lti' + pii_sharing_allowed: + title: Pii sharing allowed + type: string + readOnly: true + free_tier: + title: Free tier + description: True, if LTI credential are provided by Org globally + type: boolean course_modes.CourseMode: required: - course_id @@ -10399,6 +11845,151 @@ definitions: type: string format: date-time readOnly: true + ReportDownload: + description: Report Download + required: + - url + - name + - link + type: object + properties: + url: + title: Url + description: URL from which report can be downloaded. + type: string + format: uri + minLength: 1 + name: + title: Name + description: Name of report. + type: string + minLength: 1 + link: + title: Link + description: HTML anchor tag that contains the name and link. + type: string + minLength: 1 + ReportDownloadsList: + required: + - downloads + type: object + properties: + downloads: + description: List of report downloads + type: array + items: + $ref: '#/definitions/ReportDownload' + ProblemResponseReportPostParams: + required: + - problem_locations + type: object + properties: + problem_locations: + description: 'A list of usage keys for the blocks to include in the report. ' + type: array + items: + description: A usage key location for a section or a problem. If the location + is a block that contains other blocks, (such as the course, section, subsection, + or unit blocks) then all blocks under that block will be included in the + report. + type: string + minLength: 1 + problem_types_filter: + description: 'A list of problem/block types to generate the report for. This + field can be omitted if the report should include details of allblock types. ' + type: array + items: + type: string + minLength: 1 + ProblemResponsesReportStatus: + required: + - status + - task_id + type: object + properties: + status: + title: Status + description: User-friendly text describing current status of report generation. + type: string + minLength: 1 + task_id: + title: Task id + description: A unique id for the report generation task. It can be used to + query the latest report generation status. + type: string + format: uuid + InstructorTask: + required: + - status + - task_type + - task_id + - created + - task_input + - requester + - task_state + - duration_sec + - task_message + type: object + properties: + status: + title: Status + description: Current status of task. + type: string + minLength: 1 + task_type: + title: Task type + description: Identifies the kind of task being performed, e.g. rescoring. + type: string + minLength: 1 + task_id: + title: Task id + description: The celery ID for the task. + type: string + minLength: 1 + created: + title: Created + description: The date and time when the task was created. + type: string + format: date-time + task_input: + title: Task input + description: The input parameters for the task. The format and content of + this data will depend on the kind of task being performed. For instanceit + may contain the problem locations for a problem resources task. + type: object + additionalProperties: + type: string + x-nullable: true + requester: + title: Requester + description: The username of the user who initiated this task. + type: string + minLength: 1 + task_state: + title: Task state + description: The last knows state of the celery task. + type: string + minLength: 1 + duration_sec: + title: Duration sec + description: Task duration information, if known + type: string + minLength: 1 + task_message: + title: Task message + description: User-friendly task status information, if available. + type: string + minLength: 1 + InstructorTasksList: + required: + - tasks + type: object + properties: + tasks: + description: List of instructor tasks. + type: array + items: + $ref: '#/definitions/InstructorTask' ScheduledBulkEmail: required: - task @@ -10661,6 +12252,132 @@ definitions: title: Logo url type: string minLength: 1 + DueDate: + required: + - name + - url + - date + type: object + properties: + name: + title: Name + type: string + minLength: 1 + url: + title: Url + type: string + minLength: 1 + date: + title: Date + type: string + format: date-time + CourseRunOverview: + required: + - course_run_id + - display_name + - course_run_url + - start_date + - end_date + - course_run_status + - due_dates + type: object + properties: + course_run_id: + title: Course run id + description: ID for the course run. + type: string + minLength: 1 + display_name: + title: Display name + description: Display name of the course run. + type: string + minLength: 1 + resume_course_run_url: + title: Resume course run url + description: The absolute url that takes the user back to their position in + the course run; if absent, user has not made progress in the course. + type: string + minLength: 1 + course_run_url: + title: Course run url + description: The absolute url for the course run. + type: string + minLength: 1 + start_date: + title: Start date + description: Start date for the course run; null if no start date. + type: string + format: date-time + end_date: + title: End date + description: End date for the course run; null if no end date. + type: string + format: date-time + course_run_status: + title: Course run status + description: The user's status of the course run. + type: string + enum: + - in_progress + - upcoming + - completed + emails_enabled: + title: Emails enabled + description: Boolean representing whether emails are enabled for the course;if + absent, the bulk email feature is either not enable at the platformlevel + or is not enabled for the course; if True or False, bulk emailfeature is + enabled, and value represents whether or not user wantsto receive emails. + type: boolean + due_dates: + description: List of subsection due dates for the course run. Due dates are + only returned if the course run is in progress. + type: array + items: + $ref: '#/definitions/DueDate' + micromasters_title: + title: Micromasters title + description: Title of the MicroMasters program that the course run is a part + of; if absent, the course run is not a part of a MicroMasters program. + type: string + minLength: 1 + certificate_download_url: + title: Certificate download url + description: URL to download a certificate, if available; if absent, certificate + is not downloadable. + type: string + minLength: 1 + CourseRunOverviewList: + required: + - course_runs + type: object + properties: + course_runs: + type: array + items: + $ref: '#/definitions/CourseRunOverview' + PageOfCourseRunOverview: + required: + - results + type: object + properties: + previous: + title: Previous + description: Link to the previous page or results, or null if this is the + first. + type: string + format: uri + minLength: 1 + next: + title: Next + description: Link to the next page of results, or null if this is the last. + type: string + format: uri + minLength: 1 + results: + description: The list of result objects on this page. + type: array + items: + $ref: '#/definitions/CourseRunOverview' UserMapping: type: object properties: diff --git a/lms/envs/common.py b/lms/envs/common.py index 166966db45b..77f3d35af70 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3060,6 +3060,8 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx.core.djangoapps.staticfiles.apps.EdxPlatformStaticFilesConfig', 'django_celery_results', + + # Common Initialization 'openedx.core.djangoapps.common_initialization.apps.CommonInitializationConfig', diff --git a/lms/urls.py b/lms/urls.py index 9c7dd433a6f..3435b963d89 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -347,6 +347,14 @@ xqueue_callback, name='xqueue_callback', ), + + re_path( + r'^courses/{}/xqueue/(?P[^/]*)/(?P.*?)/(?P[^/]*)$'.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), diff --git a/pylint.log b/pylint.log new file mode 100644 index 00000000000..e69de29bb2d diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 695f3ac3d49..548a3bce87b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -499,6 +499,7 @@ edx-opaque-keys[django]==2.11.0 # edx-when # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 edx-organizations==6.13.0 # via -r requirements/edx/kernel.in @@ -820,7 +821,7 @@ openedx-events==9.18.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.12.0 +openedx-filters==2.0.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 6b6975ca42c..49a94d8875b 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -790,6 +790,7 @@ edx-opaque-keys[django]==2.11.0 # edx-when # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 edx-organizations==6.13.0 # via @@ -1371,7 +1372,7 @@ openedx-events==9.18.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.12.0 +openedx-filters==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 993408b415d..1c9f9e7bbb0 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -584,6 +584,7 @@ edx-opaque-keys[django]==2.11.0 # edx-when # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 edx-organizations==6.13.0 # via -r requirements/edx/base.txt @@ -993,7 +994,7 @@ openedx-events==9.18.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.12.0 +openedx-filters==2.0.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index cbff922d548..c39c6c39958 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -608,6 +608,7 @@ edx-opaque-keys[django]==2.11.0 # edx-when # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 edx-organizations==6.13.0 # via -r requirements/edx/base.txt @@ -1041,7 +1042,7 @@ openedx-events==9.18.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.12.0 +openedx-filters==2.0.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock diff --git a/xmodule/capa/capa_problem.py b/xmodule/capa/capa_problem.py index 3bf5b78cd4a..dd7d4670370 100644 --- a/xmodule/capa/capa_problem.py +++ b/xmodule/capa/capa_problem.py @@ -32,6 +32,8 @@ import xmodule.capa.inputtypes as inputtypes import xmodule.capa.responsetypes as responsetypes import xmodule.capa.xqueue_interface as xqueue_interface +import xmodule.capa.xqueue_submission as xqueue_submission +from xmodule.capa.xqueue_interface import get_flag_by_name from xmodule.capa.correctmap import CorrectMap from xmodule.capa.safe_exec import safe_exec from xmodule.capa.util import contextualize_text, convert_files_to_filenames, get_course_id_from_capa_block @@ -432,8 +434,12 @@ def get_recentmost_queuetime(self): for answer_id in self.correct_map if self.correct_map.is_queued(answer_id) ] + if get_flag_by_name('send_to_submission_course.enable'): + data_format = xqueue_submission.dateformat + else: + data_format = xqueue_interface.dateformat queuetimes = [ - datetime.strptime(qt_str, xqueue_interface.dateformat).replace(tzinfo=UTC) + datetime.strptime(qt_str, data_format).replace(tzinfo=UTC) for qt_str in queuetime_strs ] diff --git a/xmodule/capa/inputtypes.py b/xmodule/capa/inputtypes.py index 8dff5776868..6d463b8a003 100644 --- a/xmodule/capa/inputtypes.py +++ b/xmodule/capa/inputtypes.py @@ -56,18 +56,16 @@ from lxml import etree -from xmodule.capa.xqueue_interface import XQUEUE_TIMEOUT +from xmodule.capa.xqueue_interface import XQUEUE_TIMEOUT, get_flag_by_name from openedx.core.djangolib.markup import HTML, Text from xmodule.stringify import stringify_children -from . import xqueue_interface +from . import xqueue_interface, xqueue_submission from .registry import TagRegistry from .util import sanitize_html log = logging.getLogger(__name__) -######################################################################### - registry = TagRegistry() # pylint: disable=invalid-name @@ -408,7 +406,7 @@ class OptionInput(InputTypeBase): Example: - The location of the sky + The location of the sky # TODO: allow ordering to be randomized """ @@ -423,8 +421,8 @@ def parse_options(options): id==description for now. TODO: make it possible to specify different id and descriptions. """ # convert single quotes inside option values to html encoded string - options = re.sub(r"([a-zA-Z])('|\\')([a-zA-Z])", r"\1'\3", options) - options = re.sub(r"\\'", r"'", options) # replace already escaped single quotes + options = re.sub(r"([a-zA-Z])('|\\')([a-zA-Z])", r"\1& #39;\3", options) + options = re.sub(r"\\'", r"& #39;", options) # replace already escaped single quotes # parse the set of possible options lexer = shlex.shlex(options[1:-1]) @@ -434,7 +432,7 @@ def parse_options(options): # remove quotes # convert escaped single quotes (html encoded string) back to single quotes - tokens = [x[1:-1].replace("'", "'") for x in lexer] + tokens = [x[1:-1].replace("& #39;", "'") for x in lexer] # make list of (option_id, option_description), with description=id return [(t, t) for t in tokens] @@ -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): @@ -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. @@ -987,7 +985,10 @@ def _plot_data(self, data): # construct xqueue headers qinterface = self.capa_system.xqueue.interface - qtime = datetime.utcnow().strftime(xqueue_interface.dateformat) + if get_flag_by_name('send_to_submission_course.enable'): + qtime = datetime.utcnow().strftime(xqueue_submission.dateformat) + else: + qtime = datetime.utcnow().strftime(xqueue_interface.dateformat) callback_url = self.capa_system.xqueue.construct_callback('ungraded_response') anonymous_student_id = self.capa_system.anonymous_student_id # TODO: Why is this using self.capa_system.seed when we have self.seed??? @@ -1086,9 +1087,9 @@ def get_attributes(cls): def setup(self): """ - if value is of the form [x,y] then parse it and send along coordinates of previous answer + if value is of the form [x, y] then parse it and send along coordinates of previous answer """ - m = re.match(r'\[([0-9]+),([0-9]+)]', + m = re.match(r'\[([0-9]+), ([0-9]+)]', self.value.strip().replace(' ', '')) if m: # Note: we subtract 15 to compensate for the size of the dot on the screen. @@ -1626,7 +1627,7 @@ class ChoiceTextGroup(InputTypeBase): CheckboxProblem: - 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. diff --git a/xmodule/capa/responsetypes.py b/xmodule/capa/responsetypes.py index 73378e7c0a2..7a76606cc74 100644 --- a/xmodule/capa/responsetypes.py +++ b/xmodule/capa/responsetypes.py @@ -37,6 +37,8 @@ import xmodule.capa.safe_exec as safe_exec import xmodule.capa.xqueue_interface as xqueue_interface +import xmodule.capa.xqueue_submission as xqueue_submission +from xmodule.capa.xqueue_interface import get_flag_by_name from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.grade_utils import round_away_from_zero @@ -2675,8 +2677,10 @@ def get_score(self, student_answers): #------------------------------------------------------------ qinterface = self.capa_system.xqueue.interface - qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) - + if get_flag_by_name('send_to_submission_course.enable'): + qtime = datetime.strftime(datetime.now(UTC), xqueue_submission.dateformat) + else: + qtime = datetime.strftime(datetime.now(UTC), xqueue_submission.dateformat) anonymous_student_id = self.capa_system.anonymous_student_id # Generate header diff --git a/xmodule/capa/tests/test_inputtypes.py b/xmodule/capa/tests/test_inputtypes.py index 4e14bc42b76..272f5cc621c 100644 --- a/xmodule/capa/tests/test_inputtypes.py +++ b/xmodule/capa/tests/test_inputtypes.py @@ -476,10 +476,12 @@ def test_rendering(self): assert context == expected +@pytest.mark.django_db class MatlabTest(unittest.TestCase): """ Test Matlab input types """ + def setUp(self): super(MatlabTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments self.rows = '10' @@ -928,6 +930,40 @@ def test_matlab_sanitize_msg(self): expected = "" assert self.the_input._get_render_context()['msg'] == expected # pylint: disable=protected-access + @patch('xmodule.capa.inputtypes.get_flag_by_name', return_value=True) + @patch('xmodule.capa.inputtypes.datetime') + def test_plot_data_with_flag_active(self, mock_datetime, mock_get_flag_by_name): + """ + Test that the correct date format is used when the flag is active. + """ + mock_datetime.utcnow.return_value.strftime.return_value = 'formatted_date_with_flag' + data = {'submission': 'x = 1234;'} + response = self.the_input.handle_ajax("plot", data) + self.the_input.capa_system.xqueue.interface.send_to_queue.assert_called_with(header=ANY, body=ANY) + assert response['success'] + assert self.the_input.input_state['queuekey'] is not None + assert self.the_input.input_state['queuestate'] == 'queued' + assert 'formatted_date_with_flag' in self.the_input.capa_system.xqueue.interface.send_to_queue.call_args[1][ + 'body' + ] + + @patch('xmodule.capa.inputtypes.get_flag_by_name', return_value=False) + @patch('xmodule.capa.inputtypes.datetime') + def test_plot_data_with_flag_inactive(self, mock_datetime, mock_get_flag_by_name): + """ + Test that the correct date format is used when the flag is inactive. + """ + mock_datetime.utcnow.return_value.strftime.return_value = 'formatted_date_without_flag' + data = {'submission': 'x = 1234;'} + response = self.the_input.handle_ajax("plot", data) + self.the_input.capa_system.xqueue.interface.send_to_queue.assert_called_with(header=ANY, body=ANY) + assert response['success'] + assert self.the_input.input_state['queuekey'] is not None + assert self.the_input.input_state['queuestate'] == 'queued' + assert 'formatted_date_without_flag' in self.the_input.capa_system.xqueue.interface.send_to_queue.call_args[1][ + 'body' + ] + def html_tree_equal(received, expected): """ @@ -947,6 +983,7 @@ class SchematicTest(unittest.TestCase): """ Check that schematic inputs work """ + def test_rendering(self): height = '12' width = '33' @@ -1002,6 +1039,7 @@ class ImageInputTest(unittest.TestCase): """ Check that image inputs work """ + def check(self, value, egx, egy): # lint-amnesty, pylint: disable=missing-function-docstring height = '78' width = '427' @@ -1044,7 +1082,7 @@ def check(self, value, egx, egy): # lint-amnesty, pylint: disable=missing-funct def test_with_value(self): # Check that compensating for the dot size works properly. - self.check('[50,40]', 35, 25) + print("Context:", self.check('[50,40]', 35, 25)) def test_without_value(self): self.check('', 0, 0) @@ -1061,6 +1099,7 @@ class CrystallographyTest(unittest.TestCase): """ Check that crystallography inputs work """ + def test_rendering(self): height = '12' width = '33' @@ -1102,6 +1141,7 @@ class VseprTest(unittest.TestCase): """ Check that vsepr inputs work """ + def test_rendering(self): height = '12' width = '33' @@ -1149,6 +1189,7 @@ class ChemicalEquationTest(unittest.TestCase): """ Check that chemical equation inputs work. """ + def setUp(self): super(ChemicalEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments self.size = "42" @@ -1244,6 +1285,7 @@ class FormulaEquationTest(unittest.TestCase): """ Check that formula equation inputs work. """ + def setUp(self): super(FormulaEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments self.size = "42" @@ -1392,6 +1434,7 @@ class DragAndDropTest(unittest.TestCase): """ Check that drag and drop inputs work """ + def test_rendering(self): path_to_images = '/dummy-static/images/' @@ -1466,6 +1509,7 @@ class AnnotationInputTest(unittest.TestCase): """ Make sure option inputs work """ + def test_rendering(self): xml_str = ''' @@ -1626,6 +1670,7 @@ class TestStatus(unittest.TestCase): """ Tests for Status class """ + def test_str(self): """ Test stringifing Status objects diff --git a/xmodule/capa/tests/test_responsetypes.py b/xmodule/capa/tests/test_responsetypes.py index e8df8894c78..6e52175c53f 100644 --- a/xmodule/capa/tests/test_responsetypes.py +++ b/xmodule/capa/tests/test_responsetypes.py @@ -39,6 +39,8 @@ ) from xmodule.capa.util import convert_files_to_filenames from xmodule.capa.xqueue_interface import dateformat +import xmodule.capa.xqueue_submission as xqueue_submission +from xmodule.capa.xqueue_interface import get_flag_by_name class ResponseTest(unittest.TestCase): @@ -929,6 +931,7 @@ def test_empty_answer_graded_as_incorrect(self): self.assert_grade(problem, " ", "incorrect") +@pytest.mark.django_db class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = CodeResponseXMLFactory @@ -944,8 +947,12 @@ def setUp(self): @staticmethod def make_queuestate(key, time): """Create queuestate dict""" - timestr = datetime.strftime(time, dateformat) - return {'key': key, 'time': timestr} + if get_flag_by_name('send_to_submission_course.enable'): + timestr = datetime.strftime(time, xqueue_submission.dateformat) + return {'key': key, 'time': timestr} + else: + timestr = datetime.strftime(time, dateformat) + return {'key': key, 'time': timestr} def test_is_queued(self): """ diff --git a/xmodule/capa/tests/test_xqueue_interface.py b/xmodule/capa/tests/test_xqueue_interface.py index 46c18869ba9..037f901f48b 100644 --- a/xmodule/capa/tests/test_xqueue_interface.py +++ b/xmodule/capa/tests/test_xqueue_interface.py @@ -7,12 +7,11 @@ from django.test.utils import override_settings from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from xblock.fields import ScopeIds -from waffle.testutils import override_switch +import pytest import json from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.xqueue_interface import XQueueInterface, XQueueService -import pytest @pytest.mark.django_db @@ -22,34 +21,56 @@ class XQueueServiceTest(TestCase): 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.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 - callback_url = f'courses/{usage_id.context_key}/xqueue/user1/{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' + 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' + 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}" + + 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' + 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.""" @@ -60,45 +81,50 @@ def test_waittime(self): @pytest.mark.django_db -@override_switch('xqueue_submission.enabled', active=True) -@patch('xmodule.capa.xqueue_submission.XQueueInterfaceSubmission.send_to_submission') -def test_send_to_queue_with_waffle_enabled(mock_send_to_submission): +@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"} - requests_auth = None - xqueue_interface = XQueueInterface(url, django_auth, requests_auth) + xqueue_interface = XQueueInterface(url, django_auth) header = json.dumps({ - 'lms_callback_url': 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/block@item_id/type@problem', + "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' + "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'} + 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 -@override_switch('xqueue_submission.enabled', active=False) -@patch('xmodule.capa.xqueue_interface.XQueueInterface._http_post') -def test_send_to_queue_with_waffle_disabled(mock_http_post): - +@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"} - requests_auth = None - xqueue_interface = XQueueInterface(url, django_auth, requests_auth) + xqueue_interface = XQueueInterface(url, django_auth) header = json.dumps({ - 'lms_callback_url': 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/block@item_id/type@problem', + "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' + "student_info": json.dumps({"anonymous_student_id": "student_id"}), + "student_response": "student_answer", }) files_to_upload = None @@ -106,7 +132,7 @@ def test_send_to_queue_with_waffle_disabled(mock_http_post): 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={} + "http://example.com/xqueue/xqueue/submit/", + {"xqueue_header": header, "xqueue_body": body}, + files={}, ) diff --git a/xmodule/capa/tests/test_xqueue_submission.py b/xmodule/capa/tests/test_xqueue_submission.py index 5c6cf8ba0f3..e1b0e6a0d89 100644 --- a/xmodule/capa/tests/test_xqueue_submission.py +++ b/xmodule/capa/tests/test_xqueue_submission.py @@ -1,23 +1,38 @@ +""" +Tests for XQueueInterfaceSubmission. +""" + import json import pytest from unittest.mock import Mock, patch -from django.conf import settings from xmodule.capa.xqueue_submission import XQueueInterfaceSubmission from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator -from opaque_keys.edx.keys import UsageKey, CourseKey from xblock.fields import ScopeIds @pytest.fixture def xqueue_service(): - location = BlockUsageLocator(CourseLocator("test_org", "test_course", "test_run"), "problem", "ExampleProblem") + """ + Fixture that returns an instance of XQueueInterfaceSubmission. + """ + location = BlockUsageLocator( + CourseLocator("test_org", "test_course", "test_run"), + "problem", + "ExampleProblem" + ) block = Mock(scope_ids=ScopeIds('user1', 'mock_problem', location, location)) return XQueueInterfaceSubmission() def test_extract_item_data(): + """ + Test extracting item data from an xqueue submission. + """ header = json.dumps({ - 'lms_callback_url': 'http://example.com/courses/course-v1:org+course+run/xqueue/5/block-v1:org+course+run+type@problem+block@item_id/score_update', + 'lms_callback_url': ( + 'http://example.com/courses/course-v1:org+course+run/xqueue/5/' + 'block-v1:org+course+run+type@problem+block@item_id/score_update' + ), }) payload = json.dumps({ 'student_info': json.dumps({'anonymous_student_id': 'student_id'}), @@ -27,10 +42,14 @@ def test_extract_item_data(): with patch('lms.djangoapps.courseware.models.StudentModule.objects.filter') as mock_filter: mock_filter.return_value.first.return_value = Mock(grade=0.85) - student_item, student_answer, queue_name, grader, score = XQueueInterfaceSubmission().extract_item_data(header, payload) + student_item, student_answer, queue_name, grader, score = ( + XQueueInterfaceSubmission().extract_item_data(header, payload) + ) assert student_item == { - 'item_id': 'block-v1:org+course+run+type@problem+block@item_id', + 'item_id': ( + 'block-v1:org+course+run+type@problem+block@item_id' + ), 'item_type': 'problem', 'course_id': 'course-v1:org+course+run', 'student_id': 'student_id' @@ -44,8 +63,14 @@ def test_extract_item_data(): @pytest.mark.django_db @patch('submissions.api.create_submission') def test_send_to_submission(mock_create_submission, xqueue_service): + """ + Test sending a submission to the grading system. + """ header = json.dumps({ - 'lms_callback_url': 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/5/block-v1:test_org+test_course+test_run+type@problem+block@item_id/score_update', + 'lms_callback_url': ( + 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/5/' + 'block-v1:test_org+test_course+test_run+type@problem+block@item_id/score_update' + ), }) body = json.dumps({ 'student_info': json.dumps({'anonymous_student_id': 'student_id'}), @@ -58,10 +83,10 @@ def test_send_to_submission(mock_create_submission, xqueue_service): mock_create_submission.return_value = {'submission': 'mock_submission'} - # Llamada a send_to_submission + # Call send_to_submission result = xqueue_service.send_to_submission(header, body) - # Afirmaciones + # Assertions assert 'submission' in result assert result['submission'] == 'mock_submission' mock_create_submission.assert_called_once_with( @@ -76,3 +101,26 @@ def test_send_to_submission(mock_create_submission, xqueue_service): grader='test.py', score=0.85 ) + + +@pytest.mark.django_db +@patch('submissions.api.create_submission') +def test_send_to_submission_with_missing_fields(mock_create_submission, xqueue_service): + """ + Test send_to_submission with missing required fields. + """ + header = json.dumps({ + 'lms_callback_url': 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/5/block@item_id' + }) + body = json.dumps({ + 'student_info': json.dumps({'anonymous_student_id': 'student_id'}), + 'grader_payload': json.dumps({'grader': 'test.py'}) + }) + + # Call send_to_submission + result = xqueue_service.send_to_submission(header, body) + + # Assertions + assert "error" in result + assert "Validation error" in result["error"] + mock_create_submission.assert_not_called() diff --git a/xmodule/capa/xqueue_interface.py b/xmodule/capa/xqueue_interface.py index 0c6b495bfbb..341c6bdc99e 100644 --- a/xmodule/capa/xqueue_interface.py +++ b/xmodule/capa/xqueue_interface.py @@ -5,20 +5,20 @@ import hashlib import json +import re import logging import requests from django.conf import settings from django.urls import reverse from requests.auth import HTTPBasicAuth -from waffle import switch_is_active from xmodule.capa.xqueue_submission import XQueueInterfaceSubmission if TYPE_CHECKING: from xmodule.capa_block import ProblemBlock log = logging.getLogger(__name__) -dateformat = '%Y-%m-%dT%H:%M:%S' +dateformat = '%Y%m%d%H%M%S' XQUEUE_METRIC_NAME = 'edxapp.xqueue' @@ -28,6 +28,34 @@ READ_TIMEOUT = 10 # seconds +def is_flag_active(flag_name, course_id): + """ + Look for the waffle flag by name and course_id. + """ + from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel as waffle + flag = waffle.objects.filter(waffle_flag=flag_name, course_id=course_id, enabled=True).first() + return flag and flag.enabled + + +def get_flag_by_name(flag_name): + """ + Look for the waffle flag by name. + """ + from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel + flag = WaffleFlagCourseOverrideModel.objects.filter(waffle_flag=flag_name, enabled=True).first() + return flag and flag.enabled + + +def get_course_id(callback_url): + """ + Extract course_id from the callback URL + """ + course_id_match = re.search(r'(course-v1:[^\/]+)', callback_url) + if not course_id_match: + raise ValueError("The callback_url does not contain the required information.") + return course_id_match.group(1) + + def make_hashkey(seed): """ Generate a string key by hashing @@ -137,7 +165,9 @@ def _send_to_queue(self, header, body, files_to_upload): # lint-amnesty, pylint for f in files_to_upload: files.update({f.name: f}) - if switch_is_active('xqueue_submission.enabled'): + header_info = json.loads(header) + course_id = get_course_id(header_info['lms_callback_url']) + if is_flag_active('send_to_submission_course.enable', course_id): # Use the new edx-submissions workflow submission = XQueueInterfaceSubmission().send_to_submission(header, body, files) log.error(submission) @@ -193,11 +223,10 @@ def construct_callback(self, dispatch: str = 'score_update') -> str: """ Return a fully qualified callback URL for external queueing system. """ - if switch_is_active('callback_submission.enabled'): + if get_flag_by_name('send_to_submission_course.enable'): dispatch_callback = "callback_submission" else: dispatch_callback = 'xqueue_callback' - relative_xqueue_callback_url = reverse( dispatch_callback, kwargs=dict( diff --git a/xmodule/capa/xqueue_submission.py b/xmodule/capa/xqueue_submission.py index 6282883ac70..b92b75ecf0c 100644 --- a/xmodule/capa/xqueue_submission.py +++ b/xmodule/capa/xqueue_submission.py @@ -1,82 +1,81 @@ +""" +xqueue_submission.py + +This module handles the extraction and processing of student submission data +from edx-submission. +""" -import hashlib import json import logging -import requests -from django.conf import settings -from django.urls import reverse -from requests.auth import HTTPBasicAuth import re -from typing import Dict, Optional, TYPE_CHECKING -from opaque_keys.edx.keys import CourseKey, UsageKey -from django.core.exceptions import ObjectDoesNotExist -if TYPE_CHECKING: - from xmodule.capa_block import ProblemBlock +from opaque_keys.edx.keys import CourseKey log = logging.getLogger(__name__) dateformat = '%Y-%m-%dT%H:%M:%S' -XQUEUE_METRIC_NAME = 'edxapp.xqueue' -# Wait time for response from Xqueue. -XQUEUE_TIMEOUT = 35 # seconds -CONNECT_TIMEOUT = 3.05 # seconds -READ_TIMEOUT = 10 # seconds +class XQueueInterfaceSubmission: + """Interface to the external grading system.""" + def _parse_json(self, data, name): + """Helper function to parse JSON safely.""" + try: + return json.loads(data) if isinstance(data, str) else data + except json.JSONDecodeError as e: + raise ValueError(f"Error parsing {name}: {e}") from e -class XQueueInterfaceSubmission: - """ - Interface to the external grading system - """ + def _extract_identifiers(self, callback_url): + """Extracts identifiers from the callback URL.""" + item_id_match = re.search(r'block@([^\/]+)', callback_url) + item_type_match = re.search(r'type@([^+]+)', callback_url) + course_id_match = re.search(r'(course-v1:[^\/]+)', callback_url) + + if not item_id_match or not item_type_match or not course_id_match: + raise ValueError("The callback_url does not contain the required information.") + + return item_id_match.group(1), item_type_match.group(1), course_id_match.group(1) def extract_item_data(self, header, payload): + """ + Extracts student submission data from the given header and payload. + """ from lms.djangoapps.courseware.models import StudentModule from opaque_keys.edx.locator import BlockUsageLocator - if isinstance(header, str): - try: - header = json.loads(header) - except json.JSONDecodeError as e: - raise ValueError(f"Error to header: {e}") - - if isinstance(payload, str): - try: - payload = json.loads(payload) - except json.JSONDecodeError as e: - raise ValueError(f"Error to payload: {e}") + + header = self._parse_json(header, "header") + payload = self._parse_json(payload, "payload") callback_url = header.get('lms_callback_url') queue_name = header.get('queue_name', 'default') if not callback_url: - raise ValueError("El header is not content 'lms_callback_url'.") + raise ValueError("The header does not contain 'lms_callback_url'.") - item_id = re.search(r'block@([^\/]+)', callback_url).group(1) - item_type = re.search(r'type@([^+]+)', callback_url).group(1) - course_id = re.search(r'(course-v1:[^\/]+)', callback_url).group(1) + item_id, item_type, course_id = self._extract_identifiers(callback_url) + + student_info = self._parse_json(payload["student_info"], "student_info") try: - student_info = json.loads(payload["student_info"]) - except json.JSONDecodeError as e: - raise ValueError(f"Error to student_info: {e}") + full_block_id = f"block-v1:{course_id.replace('course-v1:', '')}+type@{item_type}+block@{item_id}" + usage_key = BlockUsageLocator.from_string(full_block_id) + except Exception as e: + raise ValueError(f"Error creating BlockUsageLocator. Invalid ID: {full_block_id}, Error: {e}") from e - usage_key = BlockUsageLocator.from_string(item_id) - course_key = CourseKey.from_string(course_id) + try: + course_key = CourseKey.from_string(course_id) + except Exception as e: + raise ValueError(f"Error creating CourseKey: {e}") from e - full_block_id = f"block-v1:{course_id.replace('course-v1:', '')}+type@{item_type}+block@{item_id}" try: - grader_payload = payload["grader_payload"] - if isinstance(grader_payload, str): - grader_payload = json.loads(grader_payload) + grader_payload = self._parse_json(payload["grader_payload"], "grader_payload") grader = grader_payload.get("grader", '') - except json.JSONDecodeError as e: - raise ValueError(f"Error grader_payload: {e}") except KeyError as e: - raise ValueError(f"Error payload: {e}") + raise ValueError(f"Error in payload: {e}") from e student_id = student_info.get("anonymous_student_id") if not student_id: - raise ValueError("The field 'anonymous_student_id' is not student_info.") + raise ValueError("The field 'anonymous_student_id' is missing from student_info.") student_dict = { 'item_id': full_block_id, @@ -87,7 +86,7 @@ def extract_item_data(self, header, payload): student_answer = payload.get("student_response") if student_answer is None: - raise ValueError("The field 'student_response' do not exist.") + raise ValueError("The field 'student_response' does not exist.") student_module = StudentModule.objects.filter( module_state_key=usage_key, @@ -96,27 +95,37 @@ def extract_item_data(self, header, payload): log.error(f"student_module: {student_module}") - if student_module and student_module.grade is not None: - score = student_module.grade - else: - score = None + score = student_module.grade if student_module and student_module.grade is not None else None log.error(f"Score: {student_id}: {score}") return student_dict, student_answer, queue_name, grader, score def send_to_submission(self, header, body, files_to_upload=None): + """ + Submits the extracted student data to the edx-submissions system. + """ from submissions.api import create_submission + try: student_item, answer, queue_name, grader, score = self.extract_item_data(header, body) + return create_submission(student_item, answer, queue_name=queue_name, grader=grader, score=score) + except json.JSONDecodeError as e: + log.error(f"JSON decoding error: {e}") + return {"error": "Invalid JSON format"} + + except KeyError as e: + log.error(f"Missing key: {e}") + return {"error": f"Missing key: {e}"} - log.error(f"student_item: {student_item}") - log.error(f"header: {header}") - log.error(f"body: {body}") - log.error(f"grader: {grader}") + except ValueError as e: + log.error(f"Validation error: {e}") + return {"error": f"Validation error: {e}"} - submission = create_submission(student_item, answer, queue_name=queue_name, grader=grader, score=score) + except TypeError as e: + log.error(f"Type error: {e}") + return {"error": f"Type error: {e}"} - return submission - except Exception as e: - return {"error": str(e)} + except RuntimeError as e: + log.error(f"Runtime error: {e}") + return {"error": f"Runtime error: {e}"} diff --git a/xmodule/docs/decisions/0005-send-data-to-edx-submission.rst b/xmodule/docs/decisions/0005-send-data-to-edx-submission.rst new file mode 100644 index 00000000000..a0117a8b1be --- /dev/null +++ b/xmodule/docs/decisions/0005-send-data-to-edx-submission.rst @@ -0,0 +1,118 @@ +######################################################### +implementation to send student response to edx-submission +######################################################### + +Status +****** + +Accepted. + +2025-02-21 + +Context +******* + +On the Open edX platform, student responses to assignments are traditionally submitted to the `XQueue` service for assessment. However, a need was identified to allow certain responses to be submitted to the `edx-submission` service, which offers a more modern and efficient architecture for handling submissions. To facilitate a controlled transition and allow for A/B testing, the introduction of a *waffle flag* was proposed to enable dynamic selection of the submission service based on the specific course. + +Decision +******** + +A course-level waffle flag called `send_to_submission_course.enable` has been implemented. This flag can be set via the Django admin, allowing administrators to enable or disable the `edx-submission` submission functionality for specific courses without requiring any code changes. + +Key changes include: + +1. **Waffle Flag Definition**: The `send_to_submission_course.enable` flag was created in the Django admin, associating it with the corresponding `course_id`. + +2. **`xqueue_interfaces.py` Modification**: In the `xmodule/capa/xqueue_interfaces.py` file, a condition was added that checks the state of the *waffle flag*. If the flag is on for a given course, student responses are sent to the `edx-submission` service using the `send_to_submission` function. If the flag is off, the flow continues sending responses to `XQueue`. + +3. **`construct_callback` Method Update**: Within the `XQueueService` class in `xqueue_interfaces.py`, the `construct_callback` method was modified. This method generates the callback URL that `XQueue` or `edx-submission` will use to return the evaluation results. The method now checks the state of the `send_to_submission_course.enable` *waffle flag* to determine whether the callback URL should point to the `edx-submission` handler (`callback_submission`) or to the original `XQueue` handler (`xqueue_callback`). + +4. **Implementation of `send_to_submission` in `xqueue_submission.py`**: The `send_to_submission` function was developed in the `xqueue_submission.py` file. This function is responsible for: + - **Parse Submission Data**: Extracts and processes relevant information from the student response, including identifiers such as `course_id`, `item_id`, and `student_id`. + + - **Interaction with `edx-submission`**: Uses the API provided by `edx-submission` to create a new submission record in the submissions database, ensuring that the student response is stored and processed appropriately. + +Consequences +************ + +**Positives:** + +- **Flexibility**: Administrators can enable or disable the `edx-submission` submission functionality on a per-course basis, facilitating controlled testing and a smooth transition. + +- **Improved Submission Handling**: By using `edx-submission`, you can take advantage of a more modern architecture for processing responses. + +**Negatives:** + +- **Additional Complexity**: The introduction of a new flag-based flow adds complexity to the code, which can increase maintenance effort. + +- **Potential Inconsistency**: If flag states are not properly managed, there could be inconsistencies in submission handling across courses. + +References +********** + +- **Relevant commits**: [Implementation of the Waffle Flag and modification of xqueue_interfaces.py](https://github.com/aulasneo/edx-platform/commit/f50afcc301bdc3eeb42a 6dc2c051ffb2d799f868#diff-9b4290d2b574f54e4eca7831368727f7ddbac8292aa75ba4b28651d4bf2bbe6b) + +- **Feature Toggles documentation in Open edX**: [Feature Toggles — edx-platform documentation](https://docs.openedx.org/projects/edx-platform/en/latest/references/featuretoggles.html) + +- **edx-submissions repository**: [openedx/edx-submissions](https://github.com/openedx/edx-submissions) + +- **edx-platform repository**: [openedx/edx-platform](https://github.com/openedx/edx-platform) +######################################################### +implementation to send student response to edx-submission +######################################################### + +Status +****** + +Accepted. + +2025-02-21 + +Context +******* + +On the Open edX platform, student responses to assignments are traditionally submitted to the `XQueue` service for assessment. However, a need was identified to allow certain responses to be submitted to the `edx-submission` service, which offers a more modern and efficient architecture for handling submissions. To facilitate a controlled transition and allow for A/B testing, the introduction of a *waffle flag* was proposed to enable dynamic selection of the submission service based on the specific course. + +Decision +******** + +A course-level waffle flag called `send_to_submission_course.enable` has been implemented. This flag can be set via the Django admin, allowing administrators to enable or disable the `edx-submission` submission functionality for specific courses without requiring any code changes. + +Key changes include: + +1. **Waffle Flag Definition**: The `send_to_submission_course.enable` flag was created in the Django admin, associating it with the corresponding `course_id`. + +2. **`xqueue_interfaces.py` Modification**: In the `xmodule/capa/xqueue_interfaces.py` file, a condition was added that checks the state of the *waffle flag*. If the flag is on for a given course, student responses are sent to the `edx-submission` service using the `send_to_submission` function. If the flag is off, the flow continues sending responses to `XQueue`. + +3. **`construct_callback` Method Update**: Within the `XQueueService` class in `xqueue_interfaces.py`, the `construct_callback` method was modified. This method generates the callback URL that `XQueue` or `edx-submission` will use to return the evaluation results. The method now checks the state of the `send_to_submission_course.enable` *waffle flag* to determine whether the callback URL should point to the `edx-submission` handler (`callback_submission`) or to the original `XQueue` handler (`xqueue_callback`). + +4. **Implementation of `send_to_submission` in `xqueue_submission.py`**: The `send_to_submission` function was developed in the `xqueue_submission.py` file. This function is responsible for: + - **Parse Submission Data**: Extracts and processes relevant information from the student response, including identifiers such as `course_id`, `item_id`, and `student_id`. + + - **Interaction with `edx-submission`**: Uses the API provided by `edx-submission` to create a new submission record in the submissions database, ensuring that the student response is stored and processed appropriately. + +Consequences +************ + +**Positives:** + +- **Flexibility**: Administrators can enable or disable the `edx-submission` submission functionality on a per-course basis, facilitating controlled testing and a smooth transition. + +- **Improved Submission Handling**: By using `edx-submission`, you can take advantage of a more modern architecture for processing responses. + +**Negatives:** + +- **Additional Complexity**: The introduction of a new flag-based flow adds complexity to the code, which can increase maintenance effort. + +- **Potential Inconsistency**: If flag states are not properly managed, there could be inconsistencies in submission handling across courses. + +References +********** + +- **Relevant commits**: [Implementation of the Waffle Flag and modification of xqueue_interfaces.py](https://github.com/aulasneo/edx-platform/commit/f50afcc301bdc3eeb42a 6dc2c051ffb2d799f868#diff-9b4290d2b574f54e4eca7831368727f7ddbac8292aa75ba4b28651d4bf2bbe6b) + +- **Feature Toggles documentation in Open edX**: [Feature Toggles — edx-platform documentation](https://docs.openedx.org/projects/edx-platform/en/latest/references/featuretoggles.html) + +- **edx-submissions repository**: [openedx/edx-submissions](https://github.com/openedx/edx-submissions) + +- **edx-platform repository**: [openedx/edx-platform](https://github.com/openedx/edx-platform)