diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fc452f7acde7..c41fe40b4d27 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,7 +22,7 @@ openedx/core/djangoapps/enrollments/ @openedx/2U- openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch openedx/core/djangoapps/user_api/ @openedx/2U-aperture -openedx/core/djangoapps/user_authn/ @openedx/2U-vanguards +openedx/core/djangoapps/user_authn/ @openedx/2U-infinity openedx/core/djangoapps/verified_track_content/ @openedx/2u-infinity openedx/features/course_experience/ xmodule/ diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 8ff1f1aa39cc..e40eddb6c99e 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -7,6 +7,7 @@ import urllib from lxml import etree from mimetypes import guess_type +import re from attrs import frozen, Factory from django.conf import settings @@ -447,6 +448,10 @@ def _import_xml_node_to_parent( temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys) child_nodes = list(node) + if issubclass(xblock_class, XmlMixin) and "x-is-pointer-node" in getattr(temp_xblock, "data", ""): + # Undo the "pointer node" hack if needed (e.g. for capa problems) + temp_xblock.data = re.sub(r'([^>]+) x-is-pointer-node="no"', r'\1', temp_xblock.data, count=1) + # Restore the original id_generator runtime.id_generator = original_id_generator diff --git a/cms/envs/common.py b/cms/envs/common.py index 00a384a359c6..20e99974b3fb 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -122,6 +122,16 @@ # Password Validator Settings AUTH_PASSWORD_VALIDATORS ) +from lms.envs.common import ( + USE_EXTRACTED_WORD_CLOUD_BLOCK, + USE_EXTRACTED_ANNOTATABLE_BLOCK, + USE_EXTRACTED_POLL_QUESTION_BLOCK, + USE_EXTRACTED_LTI_BLOCK, + USE_EXTRACTED_HTML_BLOCK, + USE_EXTRACTED_DISCUSSION_BLOCK, + USE_EXTRACTED_PROBLEM_BLOCK, + USE_EXTRACTED_VIDEO_BLOCK, +) from path import Path as path from django.urls import reverse_lazy diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index d3dc51616c75..e199142fe377 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -73,11 +73,17 @@ def user_has_role(user, role): return False -def get_user_permissions(user, course_key, org=None): +def get_user_permissions(user, course_key, org=None, service_variant=None): """ Get the bitmask of permissions that this user has in the given course context. Can also set course_key=None and pass in an org to get the user's permissions for that organization as a whole. + + :param user: a user + :param course_key: a CourseKey or None + :param org: an organization name or None + :param service_variant: the variant of the service (lms or cms). Permissions may differ between the two, + see the HACK comment in the function for more details. """ if org is None: org = course_key.org @@ -103,7 +109,7 @@ def get_user_permissions(user, course_key, org=None): # the LMS and Studio permissions will be separated as a part of this project. Once this is done (and this code is # not removed during its implementation), we can replace the Limited Staff permissions with more granular ones. if course_key and user_has_role(user, CourseLimitedStaffRole(course_key)): - if settings.SERVICE_VARIANT == 'lms': + if (service_variant or settings.SERVICE_VARIANT) == 'lms': return STUDIO_EDIT_CONTENT else: return STUDIO_NO_PERMISSIONS @@ -119,7 +125,7 @@ def get_user_permissions(user, course_key, org=None): return STUDIO_NO_PERMISSIONS -def has_studio_write_access(user, course_key): +def has_studio_write_access(user, course_key, service_variant=None): """ Return True if user has studio write access to the given course. Note that the CMS permissions model is with respect to courses. @@ -131,15 +137,17 @@ def has_studio_write_access(user, course_key): :param user: :param course_key: a CourseKey + :param service_variant: the variant of the service (lms or cms). Permissions may differ between the two, + see the comment in get_user_permissions for more details. """ - return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key)) + return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key, service_variant=service_variant)) -def has_course_author_access(user, course_key): +def has_course_author_access(user, course_key, service_variant=None): """ Old name for has_studio_write_access """ - return has_studio_write_access(user, course_key) + return has_studio_write_access(user, course_key, service_variant=service_variant) def has_studio_advanced_settings_access(user): diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 8e688208af19..ef1e6f887c36 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -715,8 +715,18 @@ def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs) """ Sends login info to Segment """ event_name = None + anonymous_id = "" + additional_params = {} + + try: + request = kwargs['request'] + anonymous_id = request.COOKIES.get('ajs_anonymous_id', "") + except: # pylint: disable=bare-except + pass + if auth_entry == AUTH_ENTRY_LOGIN: event_name = 'edx.bi.user.account.authenticated' + additional_params['anonymous_id'] = anonymous_id elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]: event_name = 'edx.bi.user.account.linked' @@ -724,7 +734,8 @@ def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs) segment.track(kwargs['user'].id, event_name, { 'category': "conversion", 'label': None, - 'provider': kwargs['backend'].name + 'provider': kwargs['backend'].name, + **additional_params }) diff --git a/lms/djangoapps/course_home_api/course_metadata/serializers.py b/lms/djangoapps/course_home_api/course_metadata/serializers.py index 29b92fc7b004..769a00605247 100644 --- a/lms/djangoapps/course_home_api/course_metadata/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/serializers.py @@ -59,3 +59,4 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer): can_view_certificate = serializers.BooleanField() course_modes = CourseModeSerrializer(many=True) is_new_discussion_sidebar_view_enabled = serializers.BooleanField() + has_course_author_access = serializers.BooleanField() diff --git a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py index 23052e5e7d90..43a3974f1126 100644 --- a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py +++ b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py @@ -11,7 +11,12 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseInstructorRole +from common.djangoapps.student.roles import ( + CourseBetaTesterRole, + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole +) from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests from lms.djangoapps.courseware.toggles import ( @@ -247,3 +252,32 @@ def test_discussion_tab_visible(self, visible): assert 'discussion' in tab_ids else: assert 'discussion' not in tab_ids + + @ddt.data( + { + 'course_team_role': None, + 'has_course_author_access': False + }, + { + 'course_team_role': CourseBetaTesterRole, + 'has_course_author_access': False + }, + { + 'course_team_role': CourseStaffRole, + 'has_course_author_access': True + }, + { + 'course_team_role': CourseLimitedStaffRole, + 'has_course_author_access': False + }, + ) + @ddt.unpack + def test_has_course_author_access_for_staff_roles(self, course_team_role, has_course_author_access): + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + + if course_team_role: + course_team_role(self.course.id).add_users(self.user) + + response = self.client.get(self.url) + assert response.status_code == 200 + assert response.data['has_course_author_access'] == has_course_author_access diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py index 02c30ff62e91..ae854b888775 100644 --- a/lms/djangoapps/course_home_api/course_metadata/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/views.py @@ -16,6 +16,7 @@ from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.course_goals.models import UserActivity @@ -140,6 +141,12 @@ def get(self, request, *args, **kwargs): 'can_view_certificate': certificates_viewable_for_course(course), 'course_modes': course_modes, 'is_new_discussion_sidebar_view_enabled': new_discussion_sidebar_view_is_enabled(course_key), + # We check the course author access in the context of CMS here because this field is used + # to determine whether the user can access the course authoring tools in the CMS. + # This is a temporary solution until the course author role is split into "Course Author" and + # "Course Editor" as described in the permission matrix here: + # https://github.com/openedx/platform-roadmap/issues/246 + 'has_course_author_access': has_course_author_access(request.user, course_key, 'cms'), } context = self.get_serializer_context() context['course'] = course diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 88f0ed05ccf7..6e0804db8ca0 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -33,6 +33,8 @@ from django.views.generic import View from edx_django_utils.monitoring import set_custom_attribute, set_custom_attributes_for_course_key from ipware.ip import get_client_ip +from xblock.core import XBlock + from lms.djangoapps.static_template_view.views import render_500 from markupsafe import escape from opaque_keys import InvalidKeyError @@ -1562,6 +1564,10 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta set_custom_attributes_for_course_key(course_key) set_custom_attribute('usage_key', usage_key_string) set_custom_attribute('block_type', usage_key.block_type) + block_class = XBlock.load_class(usage_key.block_type) + if hasattr(block_class, 'is_extracted'): + is_extracted = block_class.is_extracted + set_custom_attribute('block_extracted', is_extracted) requested_view = request.GET.get('view', 'student_view') if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index b24ef618c7ce..3b6a9a223586 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -367,7 +367,7 @@ def test_certificate_regeneration_error(self): # Assert Error Message assert res_json['message'] ==\ - 'Please select one or more certificate statuses that require certificate regeneration.' + 'Please select certificate statuses from the list only.' # Access the url passing 'certificate_statuses' that are not present in db url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) @@ -378,7 +378,8 @@ def test_certificate_regeneration_error(self): res_json = json.loads(response.content.decode('utf-8')) # Assert Error Message - assert res_json['message'] == 'Please select certificate statuses from the list only.' + assert (res_json['message'] == + 'Please select certificate statuses from the list only.') @override_settings(CERT_QUEUE='certificates') @@ -488,9 +489,7 @@ def test_certificate_exception_missing_username_and_email_error(self): assert not res_json['success'] # Assert Error Message - assert res_json['message'] ==\ - 'Student username/email field is required and can not be empty.' \ - ' Kindly fill in username/email and then press "Add to Exception List" button.' + assert res_json['message'] == {'user': ['This field may not be blank.']} def test_certificate_exception_duplicate_user_error(self): """ @@ -604,6 +603,34 @@ def test_certificate_exception_removed_successfully(self): # Verify that certificate exception does not exist assert not certs_api.is_on_allowlist(self.user2, self.course.id) + def test_certificate_exception_removed_successfully_form_url(self): + """ + In case of deletion front-end is sending content-type x-www-form-urlencoded. + Just to handle that some logic added in api and this test is for that part. + Test certificates exception removal api endpoint returns success status + when called with valid course key and certificate exception id + """ + GeneratedCertificateFactory.create( + user=self.user2, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + grade='1.0' + ) + # Verify that certificate exception exists + assert certs_api.is_on_allowlist(self.user2, self.course.id) + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_exception_in_db), + content_type='application/x-www-form-urlencoded', + REQUEST_METHOD='DELETE' + ) + # Assert successful request processing + assert response.status_code == 204 + + # Verify that certificate exception does not exist + assert not certs_api.is_on_allowlist(self.user2, self.course.id) + def test_remove_certificate_exception_invalid_request_error(self): """ Test certificates exception removal api endpoint returns error diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index ccab674d5986..631500fe3246 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -22,7 +22,7 @@ from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied, ValidationError from django.core.validators import validate_email from django.db import IntegrityError, transaction -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound +from django.http import QueryDict, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound from django.shortcuts import redirect from django.urls import reverse from django.utils.decorators import method_decorator @@ -30,7 +30,7 @@ from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie -from django.views.decorators.http import require_POST, require_http_methods +from django.views.decorators.http import require_POST from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_when.api import get_date_for_block @@ -77,9 +77,6 @@ from common.djangoapps.util.views import require_global_staff # pylint: disable=unused-import from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled, create_course_email from lms.djangoapps.certificates import api as certs_api -from lms.djangoapps.certificates.models import ( - CertificateStatuses -) from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.courses import get_course_with_access @@ -110,6 +107,7 @@ AccessSerializer, BlockDueDateSerializer, CertificateSerializer, + CertificateStatusesSerializer, ListInstructorTaskInputSerializer, RoleNameSerializer, SendEmailSerializer, @@ -3308,84 +3306,130 @@ def post(self, request, course_id): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.START_CERTIFICATE_REGENERATION) -@require_POST -@common_exceptions_400 -def start_certificate_regeneration(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class StartCertificateRegeneration(DeveloperErrorViewMixin, APIView): """ Start regenerating certificates for students whose certificate statuses lie with in 'certificate_statuses' entry in POST data. """ - course_key = CourseKey.from_string(course_id) - certificates_statuses = request.POST.getlist('certificate_statuses', []) - if not certificates_statuses: - return JsonResponse( - {'message': _('Please select one or more certificate statuses that require certificate regeneration.')}, - status=400 - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.START_CERTIFICATE_REGENERATION + serializer_class = CertificateStatusesSerializer + http_method_names = ['post'] - # Check if the selected statuses are allowed - allowed_statuses = [ - CertificateStatuses.downloadable, - CertificateStatuses.error, - CertificateStatuses.notpassing, - CertificateStatuses.audit_passing, - CertificateStatuses.audit_notpassing, - ] - if not set(certificates_statuses).issubset(allowed_statuses): - return JsonResponse( - {'message': _('Please select certificate statuses from the list only.')}, - status=400 - ) + @method_decorator(transaction.non_atomic_requests, name='dispatch') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + certificate_statuses 'certificate_statuses' in POST data. + """ + course_key = CourseKey.from_string(course_id) + serializer = self.serializer_class(data=request.data) - task_api.regenerate_certificates(request, course_key, certificates_statuses) - response_payload = { - 'message': _('Certificate regeneration task has been started. ' - 'You can view the status of the generation task in the "Pending Tasks" section.'), - 'success': True - } - return JsonResponse(response_payload) + if not serializer.is_valid(): + return JsonResponse( + {'message': _('Please select certificate statuses from the list only.')}, + status=400 + ) + certificates_statuses = serializer.validated_data['certificate_statuses'] + task_api.regenerate_certificates(request, course_key, certificates_statuses) + response_payload = { + 'message': _('Certificate regeneration task has been started. ' + 'You can view the status of the generation task in the "Pending Tasks" section.'), + 'success': True + } + return JsonResponse(response_payload) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CERTIFICATE_EXCEPTION_VIEW) -@require_http_methods(['POST', 'DELETE']) -def certificate_exception_view(request, course_id): + +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class CertificateExceptionView(DeveloperErrorViewMixin, APIView): """ Add/Remove students to/from the certificate allowlist. - - :param request: HttpRequest object - :param course_id: course identifier of the course for whom to add/remove certificates exception. - :return: JsonResponse object with success/error message or certificate exception data. """ - course_key = CourseKey.from_string(course_id) - # Validate request data and return error response in case of invalid data - try: - certificate_exception, student = parse_request_data_and_get_user(request) - except ValueError as error: - return JsonResponse({'success': False, 'message': str(error)}, status=400) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CERTIFICATE_EXCEPTION_VIEW + serializer_class = CertificateSerializer + http_method_names = ['post', 'delete'] + + @method_decorator(transaction.non_atomic_requests, name='dispatch') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Add certificate exception for a student. + """ + return self._handle_certificate_exception(request, course_id, action="post") + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def delete(self, request, course_id): + """ + Remove certificate exception for a student. + """ + return self._handle_certificate_exception(request, course_id, action="delete") - # Add new Certificate Exception for the student passed in request data - if request.method == 'POST': + def _handle_certificate_exception(self, request, course_id, action): + """ + Handles adding or removing certificate exceptions. + """ + course_key = CourseKey.from_string(course_id) try: - exception = add_certificate_exception(course_key, student, certificate_exception) + data = request.data + except Exception: # pylint: disable=broad-except + return JsonResponse( + { + 'success': False, + 'message': + _('The record is not in the correct format. Please add a valid username or email address.')}, + status=400 + ) + + # Extract and validate the student information + student, error_response = self._get_and_validate_user(data) + + if error_response: + return error_response + + try: + if action == "post": + exception = add_certificate_exception(course_key, student, data) + return JsonResponse(exception) + elif action == "delete": + remove_certificate_exception(course_key, student) + return JsonResponse({}, status=204) except ValueError as error: return JsonResponse({'success': False, 'message': str(error)}, status=400) - return JsonResponse(exception) - # Remove Certificate Exception for the student passed in request data - elif request.method == 'DELETE': + def _get_and_validate_user(self, raw_data): + """ + Extracts the user data from the request and validates the student. + """ + # This is only happening in case of delete. + # because content-type is coming as x-www-form-urlencoded from front-end. + if isinstance(raw_data, QueryDict): + raw_data = list(raw_data.keys())[0] + try: + raw_data = json.loads(raw_data) + except Exception as error: # pylint: disable=broad-except + return None, JsonResponse({'success': False, 'message': str(error)}, status=400) + try: - remove_certificate_exception(course_key, student) + user_data = raw_data.get('user_name', '') or raw_data.get('user_email', '') except ValueError as error: - return JsonResponse({'success': False, 'message': str(error)}, status=400) + return None, JsonResponse({'success': False, 'message': str(error)}, status=400) - return JsonResponse({}, status=204) + serializer_data = self.serializer_class(data={'user': user_data}) + if not serializer_data.is_valid(): + return None, JsonResponse({'success': False, 'message': serializer_data.errors}, status=400) + + student = serializer_data.validated_data.get('user') + if not student: + response_payload = f'{user_data} does not exist in the LMS. Please check your spelling and retry.' + return None, JsonResponse({'success': False, 'message': response_payload}, status=400) + + return student, None def add_certificate_exception(course_key, student, certificate_exception): diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 9d4162afae0e..ea27034d0942 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -83,8 +83,9 @@ # Certificates path('enable_certificate_generation', api.enable_certificate_generation, name='enable_certificate_generation'), path('start_certificate_generation', api.StartCertificateGeneration.as_view(), name='start_certificate_generation'), - path('start_certificate_regeneration', api.start_certificate_regeneration, name='start_certificate_regeneration'), - path('certificate_exception_view/', api.certificate_exception_view, name='certificate_exception_view'), + path('start_certificate_regeneration', api.StartCertificateRegeneration.as_view(), + name='start_certificate_regeneration'), + path('certificate_exception_view/', api.CertificateExceptionView.as_view(), name='certificate_exception_view'), re_path(r'^generate_certificate_exceptions/(?P[^/]*)', api.GenerateCertificateExceptions.as_view(), name='generate_certificate_exceptions'), path('generate_bulk_certificate_exceptions', api.generate_bulk_certificate_exceptions, diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 2ac794bc2943..f7fc685f658c 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -4,10 +4,12 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ from rest_framework import serializers -from .tools import get_student_from_identifier +from lms.djangoapps.certificates.models import CertificateStatuses from lms.djangoapps.instructor.access import ROLES +from .tools import get_student_from_identifier + class RoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-method """ @@ -230,9 +232,33 @@ def __init__(self, *args, **kwargs): self.fields['due_datetime'].required = False +class CertificateStatusesSerializer(serializers.Serializer): + """ + Serializer for validating and serializing certificate status inputs. + + This serializer is used to ensure that the provided certificate statuses + conform to the predefined set of valid statuses defined in the + `CertificateStatuses` enumeration. + """ + certificate_statuses = serializers.ListField( + child=serializers.ChoiceField(choices=[ + CertificateStatuses.downloadable, + CertificateStatuses.error, + CertificateStatuses.notpassing, + CertificateStatuses.audit_passing, + CertificateStatuses.audit_notpassing, + ]), + allow_empty=False # Set to True if you want to allow empty lists + ) + + class CertificateSerializer(serializers.Serializer): """ - Serializer for resetting a students attempts counter or starts a task to reset all students + Serializer for multiple operations related with certificates. + resetting a students attempts counter or starts a task to reset all students + attempts counters + Also Add/Remove students to/from the certificate allowlist. + Also For resetting a students attempts counter or starts a task to reset all students attempts counters. """ user = serializers.CharField( diff --git a/lms/envs/common.py b/lms/envs/common.py index d74c28e75687..7d32c78a47b5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5558,3 +5558,91 @@ def _should_send_learning_badge_events(settings): LMS_COMM_DEFAULT_FROM_EMAIL = "no-reply@example.com" + + +####################### Setting for built-in Blocks Extraction ####################### +# The following Django settings flags have been introduced temporarily to facilitate +# the rollout of the extracted built-in Blocks. Flags will use to toggle between +# the old and new block quickly without putting course content or user state at risk. +# +# Ticket: https://github.com/openedx/edx-platform/issues/35308 + +# .. toggle_name: USE_EXTRACTED_WORD_CLOUD_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Word Cloud XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until https://github.com/openedx/edx-platform/issues/34840 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_WORD_CLOUD_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_ANNOTATABLE_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted annotatable XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until https://github.com/openedx/edx-platform/issues/34841 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_ANNOTATABLE_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_POLL_QUESTION_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted poll question XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until https://github.com/openedx/edx-platform/issues/34839 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_POLL_QUESTION_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_LTI_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted LTI XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_LTI_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_HTML_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted HTML XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_HTML_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_DISCUSSION_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Discussion XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_DISCUSSION_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_PROBLEM_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Problem XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_PROBLEM_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_VIDEO_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Video XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_VIDEO_BLOCK = False diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 6c7390406be1..042ef90c9a40 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -354,6 +354,11 @@ def _track_user_login(user, request): # .. pii: Username and email are sent to Segment here. Retired directly through Segment API call in Tubular. # .. pii_types: email_address, username # .. pii_retirement: third_party + anonymous_id = "" + try: + anonymous_id = request.COOKIES.get('ajs_anonymous_id', "") + except: # pylint: disable=bare-except + pass segment.identify( user.id, {"email": user.email, "username": user.username}, @@ -367,7 +372,12 @@ def _track_user_login(user, request): segment.track( user.id, "edx.bi.user.account.authenticated", - {"category": "conversion", "label": request.POST.get("course_id"), "provider": None}, + { + "category": "conversion", + "label": request.POST.get("course_id"), + "provider": None, + "anonymous_id": anonymous_id, + }, ) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index d9dae51e46b1..4358b17bb6e1 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -1146,7 +1146,7 @@ def test_login(self, include_analytics, mock_segment): mock_segment.track.assert_called_once_with( expected_user_id, 'edx.bi.user.account.authenticated', - {'category': 'conversion', 'provider': None, 'label': track_label} + {'category': 'conversion', 'provider': None, 'label': track_label, 'anonymous_id': ''} ) def test_login_with_username(self): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ed6de1269447..7d432e2a7b7a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -345,6 +345,7 @@ django-statici18n==2.5.0 # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll + # xblocks-contrib django-storages==1.14.3 # via # -c requirements/edx/../constraints.txt @@ -480,6 +481,7 @@ edx-i18n-tools==1.5.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in # ora2 + # xblocks-contrib edx-milestones==0.6.0 # via -r requirements/edx/kernel.in edx-name-affirmation==3.0.1 @@ -809,6 +811,7 @@ openedx-django-pyfs==3.7.0 # via # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 @@ -1277,6 +1280,7 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils + # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via -r requirements/edx/bundled.in xblock-google-drive==0.7.0 @@ -1287,6 +1291,8 @@ xblock-utils==4.0.0 # via # edx-sga # xblock-poll +xblocks-contrib==0.1.0 + # via -r requirements/edx/bundled.in xmlsec==1.3.14 # via python3-saml xss-utils==0.6.0 diff --git a/requirements/edx/bundled.in b/requirements/edx/bundled.in index 5a46c710a6d2..a9394b809f55 100644 --- a/requirements/edx/bundled.in +++ b/requirements/edx/bundled.in @@ -47,3 +47,4 @@ ora2>=4.5.0 # Open Response Assessment XBlock xblock-poll # Xblock for polling users xblock-drag-and-drop-v2 # Drag and Drop XBlock xblock-google-drive # XBlock for google docs and calendar +xblocks-contrib # Package having multiple core XBlocks, https://github.com/openedx/xblocks-contrib?tab=readme-ov-file#xblocks-being-moved-here diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4e5e4fc6801d..1f65f6c90138 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -568,6 +568,7 @@ django-statici18n==2.5.0 # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll + # xblocks-contrib django-storages==1.14.3 # via # -c requirements/edx/../constraints.txt @@ -763,6 +764,7 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 + # xblocks-contrib edx-lint==5.4.1 # via -r requirements/edx/testing.txt edx-milestones==0.6.0 @@ -1362,6 +1364,7 @@ openedx-django-pyfs==3.7.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via # -r requirements/edx/doc.txt @@ -2266,6 +2269,7 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils + # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via # -r requirements/edx/doc.txt @@ -2284,6 +2288,10 @@ xblock-utils==4.0.0 # -r requirements/edx/testing.txt # edx-sga # xblock-poll +xblocks-contrib==0.1.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt xmlsec==1.3.14 # via # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 946e31c433de..2388cb4a0e1d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -419,6 +419,7 @@ django-statici18n==2.5.0 # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll + # xblocks-contrib django-storages==1.14.3 # via # -c requirements/edx/../constraints.txt @@ -564,6 +565,7 @@ edx-i18n-tools==1.5.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # ora2 + # xblocks-contrib edx-milestones==0.6.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 @@ -979,6 +981,7 @@ openedx-django-pyfs==3.7.0 # -r requirements/edx/base.txt # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 @@ -1585,6 +1588,7 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils + # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via -r requirements/edx/base.txt xblock-google-drive==0.7.0 @@ -1596,6 +1600,8 @@ xblock-utils==4.0.0 # -r requirements/edx/base.txt # edx-sga # xblock-poll +xblocks-contrib==0.1.0 + # via -r requirements/edx/base.txt xmlsec==1.3.14 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 3bdd5c6e6f2c..672e70166503 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -445,6 +445,7 @@ django-statici18n==2.5.0 # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll + # xblocks-contrib django-storages==1.14.3 # via # -c requirements/edx/../constraints.txt @@ -585,6 +586,7 @@ edx-i18n-tools==1.5.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # ora2 + # xblocks-contrib edx-lint==5.4.1 # via -r requirements/edx/testing.in edx-milestones==0.6.0 @@ -1024,6 +1026,7 @@ openedx-django-pyfs==3.7.0 # -r requirements/edx/base.txt # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 @@ -1672,6 +1675,7 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils + # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via -r requirements/edx/base.txt xblock-google-drive==0.7.0 @@ -1683,6 +1687,8 @@ xblock-utils==4.0.0 # -r requirements/edx/base.txt # edx-sga # xblock-poll +xblocks-contrib==0.1.0 + # via -r requirements/edx/base.txt xmlsec==1.3.14 # via # -r requirements/edx/base.txt diff --git a/xmodule/annotatable_block.py b/xmodule/annotatable_block.py index cec677f6c5d5..dbcf0123c59a 100644 --- a/xmodule/annotatable_block.py +++ b/xmodule/annotatable_block.py @@ -3,22 +3,24 @@ import logging import textwrap +from django.conf import settings from lxml import etree from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Scope, String +from xblocks_contrib.annotatable import AnnotatableBlock as _ExtractedAnnotatableBlock from openedx.core.djangolib.markup import HTML, Text from xmodule.editing_block import EditingMixin from xmodule.raw_block import RawMixin from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment -from xmodule.xml_block import XmlMixin from xmodule.x_module import ( ResourceTemplates, shim_xmodule_js, XModuleMixin, XModuleToXBlockMixin, ) +from xmodule.xml_block import XmlMixin log = logging.getLogger(__name__) @@ -28,7 +30,7 @@ @XBlock.needs('mako') -class AnnotatableBlock( +class _BuiltInAnnotatableBlock( RawMixin, XmlMixin, EditingMixin, @@ -40,6 +42,8 @@ class AnnotatableBlock( Annotatable XBlock. """ + is_extracted = False + data = String( help=_("XML data for the annotation"), scope=Scope.content, @@ -197,3 +201,10 @@ def studio_view(self, _context): add_webpack_js_to_fragment(fragment, 'AnnotatableBlockEditor') shim_xmodule_js(fragment, self.studio_js_module_name) return fragment + + +AnnotatableBlock = ( + _ExtractedAnnotatableBlock if settings.USE_EXTRACTED_ANNOTATABLE_BLOCK + else _BuiltInAnnotatableBlock +) +AnnotatableBlock.__name__ = "AnnotatableBlock" diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 24737b689845..fa0e87325bb7 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -25,7 +25,14 @@ from xblock.core import XBlock from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString, List from xblock.scorable import ScorableXBlockMixin, Score +from xblocks_contrib.problem import ProblemBlock as _ExtractedProblemBlock +from common.djangoapps.xblock_django.constants import ( + ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID, + ATTR_KEY_USER_IS_STAFF, + ATTR_KEY_USER_ID, +) +from openedx.core.djangolib.markup import HTML, Text from xmodule.capa import responsetypes from xmodule.capa.capa_problem import LoncapaProblem, LoncapaSystem from xmodule.capa.inputtypes import Status @@ -36,8 +43,8 @@ from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.graders import ShowCorrectness from xmodule.raw_block import RawMixin -from xmodule.util.sandboxing import SandboxService from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment +from xmodule.util.sandboxing import SandboxService from xmodule.x_module import ( ResourceTemplates, XModuleMixin, @@ -45,20 +52,12 @@ shim_xmodule_js ) from xmodule.xml_block import XmlMixin -from common.djangoapps.xblock_django.constants import ( - ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID, - ATTR_KEY_USER_IS_STAFF, - ATTR_KEY_USER_ID, -) -from openedx.core.djangolib.markup import HTML, Text from .capa.xqueue_interface import XQueueService - from .fields import Date, ListScoreField, ScoreField, Timedelta from .progress import Progress log = logging.getLogger("edx.courseware") - # Make '_' a no-op so we can scrape strings. Using lambda instead of # `django.utils.translation.gettext_noop` because Django cannot be imported in this file _ = lambda text: text @@ -134,7 +133,7 @@ def from_json(self, value): @XBlock.needs('sandbox') @XBlock.needs('replace_urls') @XBlock.wants('call_to_action') -class ProblemBlock( +class _BuiltInProblemBlock( ScorableXBlockMixin, RawMixin, XmlMixin, @@ -161,6 +160,8 @@ class ProblemBlock( """ INDEX_CONTENT_TYPE = 'CAPA' + is_extracted = False + resources_dir = None has_score = True @@ -2509,3 +2510,10 @@ def randomization_bin(seed, problem_id): r_hash.update(str(problem_id).encode()) # get the first few digits of the hash, convert to an int, then mod. return int(r_hash.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS + + +ProblemBlock = ( + _ExtractedProblemBlock if settings.USE_EXTRACTED_PROBLEM_BLOCK + else _BuiltInProblemBlock +) +ProblemBlock.__name__ = "ProblemBlock" diff --git a/xmodule/discussion_block.py b/xmodule/discussion_block.py index 89e573c07c83..79914b63d6b2 100644 --- a/xmodule/discussion_block.py +++ b/xmodule/discussion_block.py @@ -4,7 +4,7 @@ import logging import urllib - +from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.urls import reverse from django.utils.translation import get_language_bidi @@ -14,6 +14,7 @@ from xblock.fields import UNIQUE_ID, Scope, String from xblock.utils.resources import ResourceLoader from xblock.utils.studio_editable import StudioEditableXBlockMixin +from xblocks_contrib.discussion import DiscussionXBlock as _ExtractedDiscussionXBlock from lms.djangoapps.discussion.django_comment_client.permissions import has_permission from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider @@ -21,7 +22,6 @@ from openedx.core.lib.xblock_utils import get_css_dependencies, get_js_dependencies from xmodule.xml_block import XmlMixin - log = logging.getLogger(__name__) loader = ResourceLoader(__name__) # pylint: disable=invalid-name @@ -36,10 +36,12 @@ def _(text): @XBlock.needs('user') # pylint: disable=abstract-method @XBlock.needs('i18n') @XBlock.needs('mako') -class DiscussionXBlock(XBlock, StudioEditableXBlockMixin, XmlMixin): # lint-amnesty, pylint: disable=abstract-method +class _BuiltInDiscussionXBlock(XBlock, StudioEditableXBlockMixin, + XmlMixin): # lint-amnesty, pylint: disable=abstract-method """ Provides a discussion forum that is inline with other content in the courseware. """ + is_extracted = False completion_mode = XBlockCompletionMode.EXCLUDED discussion_id = String(scope=Scope.settings, default=UNIQUE_ID) @@ -275,3 +277,10 @@ def _apply_metadata_and_policy(cls, block, node, runtime): for field_name, value in metadata.items(): if field_name in block.fields: setattr(block, field_name, value) + + +DiscussionXBlock = ( + _ExtractedDiscussionXBlock if settings.USE_EXTRACTED_DISCUSSION_BLOCK + else _BuiltInDiscussionXBlock +) +DiscussionXBlock.__name__ = "DiscussionXBlock" diff --git a/xmodule/html_block.py b/xmodule/html_block.py index 62949647cee3..9840c3007f92 100644 --- a/xmodule/html_block.py +++ b/xmodule/html_block.py @@ -15,6 +15,7 @@ from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Boolean, List, Scope, String +from xblocks_contrib.html import HtmlBlock as _ExtractedHtmlBlock from common.djangoapps.xblock_django.constants import ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID from xmodule.contentstore.content import StaticContent @@ -22,8 +23,8 @@ from xmodule.edxnotes_utils import edxnotes from xmodule.html_checker import check_html from xmodule.stringify import stringify_children -from xmodule.util.misc import escape_html_characters from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment +from xmodule.util.misc import escape_html_characters from xmodule.x_module import ( ResourceTemplates, shim_xmodule_js, @@ -50,6 +51,7 @@ class HtmlBlockMixin( # lint-amnesty, pylint: disable=abstract-method The HTML XBlock mixin. This provides the base class for all Html-ish blocks (including the HTML XBlock). """ + display_name = String( display_name=_("Display Name"), help=_("The display name for this component."), @@ -353,11 +355,12 @@ def index_dictionary(self): @edxnotes -class HtmlBlock(HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method +class _BuiltInHtmlBlock(HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method """ This is the actual HTML XBlock. Nothing extra is required; this is just a wrapper to include edxnotes support. """ + is_extracted = False class AboutFields: # lint-amnesty, pylint: disable=missing-class-docstring @@ -489,3 +492,10 @@ def safe_parse_date(date): return datetime.strptime(date, '%B %d, %Y') except ValueError: # occurs for ill-formatted date values return datetime.today() + + +HtmlBlock = ( + _ExtractedHtmlBlock if settings.USE_EXTRACTED_HTML_BLOCK + else _BuiltInHtmlBlock +) +HtmlBlock.__name__ = "HtmlBlock" diff --git a/xmodule/lti_block.py b/xmodule/lti_block.py index e7c173075b4e..944a7ec88db0 100644 --- a/xmodule/lti_block.py +++ b/xmodule/lti_block.py @@ -59,9 +59,9 @@ import hashlib import logging import textwrap -from xml.sax.saxutils import escape from unittest import mock from urllib import parse +from xml.sax.saxutils import escape import nh3 import oauthlib.oauth1 @@ -69,30 +69,29 @@ from lxml import etree from oauthlib.oauth1.rfc5849 import signature from pytz import UTC -from webob import Response from web_fragments.fragment import Fragment +from webob import Response from xblock.core import List, Scope, String, XBlock from xblock.fields import Boolean, Float -from xmodule.mako_block import MakoTemplateBlockBase - -from openedx.core.djangolib.markup import HTML, Text -from xmodule.editing_block import EditingMixin +from xblocks_contrib.lti import LTIBlock as _ExtractedLTIBlock from common.djangoapps.xblock_django.constants import ( ATTR_KEY_ANONYMOUS_USER_ID, ATTR_KEY_USER_ROLE, ) +from openedx.core.djangolib.markup import HTML, Text +from xmodule.editing_block import EditingMixin from xmodule.lti_2_util import LTI20BlockMixin, LTIError +from xmodule.mako_block import MakoTemplateBlockBase from xmodule.raw_block import EmptyDataRawMixin from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment -from xmodule.xml_block import XmlMixin from xmodule.x_module import ( ResourceTemplates, shim_xmodule_js, XModuleMixin, XModuleToXBlockMixin, ) - +from xmodule.xml_block import XmlMixin log = logging.getLogger(__name__) @@ -274,7 +273,7 @@ class LTIFields: @XBlock.needs("mako") @XBlock.needs("user") @XBlock.needs("rebind_user") -class LTIBlock( +class _BuiltInLTIBlock( LTIFields, LTI20BlockMixin, EmptyDataRawMixin, @@ -366,6 +365,7 @@ class LTIBlock( Otherwise error message from LTI provider is generated. """ + is_extracted = False resources_dir = None uses_xmodule_styles_setup = True @@ -984,3 +984,10 @@ def is_past_due(self): else: close_date = due_date return close_date is not None and datetime.datetime.now(UTC) > close_date + + +LTIBlock = ( + _ExtractedLTIBlock if settings.USE_EXTRACTED_LTI_BLOCK + else _BuiltInLTIBlock +) +LTIBlock.__name__ = "LTIBlock" diff --git a/xmodule/poll_block.py b/xmodule/poll_block.py index a1c9686f42ac..b8c65f1cdba8 100644 --- a/xmodule/poll_block.py +++ b/xmodule/poll_block.py @@ -6,18 +6,19 @@ If student have answered - Question with statistics for each answers. """ - import html import json import logging -from collections import OrderedDict from copy import deepcopy -from web_fragments.fragment import Fragment - +from collections import OrderedDict +from django.conf import settings from lxml import etree +from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Boolean, Dict, List, Scope, String # lint-amnesty, pylint: disable=wrong-import-order +from xblocks_contrib.poll import PollBlock as _ExtractedPollBlock + from openedx.core.djangolib.markup import Text, HTML from xmodule.mako_block import MakoTemplateBlockBase from xmodule.stringify import stringify_children @@ -30,13 +31,12 @@ ) from xmodule.xml_block import XmlMixin - log = logging.getLogger(__name__) _ = lambda text: text @XBlock.needs('mako') -class PollBlock( +class _BuiltInPollBlock( MakoTemplateBlockBase, XmlMixin, XModuleToXBlockMixin, @@ -44,6 +44,9 @@ class PollBlock( XModuleMixin, ): # pylint: disable=abstract-method """Poll Block""" + + is_extracted = False + # Name of poll to use in links to this poll display_name = String( help=_("The display name for this component."), @@ -244,3 +247,10 @@ def add_child(xml_obj, answer): # lint-amnesty, pylint: disable=unused-argument add_child(xml_object, answer) return xml_object + + +PollBlock = ( + _ExtractedPollBlock if settings.USE_EXTRACTED_POLL_QUESTION_BLOCK + else _BuiltInPollBlock +) +PollBlock.__name__ = "PollBlock" diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index ea9d1a44280a..84d7edcf7263 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -29,6 +29,7 @@ from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.runtime import KvsFieldData +from xblocks_contrib.video import VideoBlock as _ExtractedVideoBlock from common.djangoapps.xblock_django.constants import ATTR_KEY_REQUEST_COUNTRY_CODE, ATTR_KEY_USER_ID from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag, CourseYoutubeBlockedFlag @@ -47,8 +48,8 @@ from xmodule.mako_block import MakoTemplateBlockBase from xmodule.modulestore.inheritance import InheritanceKeyValueStore, own_metadata from xmodule.raw_block import EmptyDataRawMixin +from xmodule.util.builtin_assets import add_css_to_fragment, add_webpack_js_to_fragment from xmodule.validation import StudioValidation, StudioValidationMessage -from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment from xmodule.video_block import manage_video_subtitles_save from xmodule.x_module import ( PUBLIC_VIEW, STUDENT_VIEW, @@ -56,7 +57,6 @@ XModuleMixin, XModuleToXBlockMixin, ) from xmodule.xml_block import XmlMixin, deserialize_field, is_pointer_tag, name_to_pathname - from .bumper_utils import bumperize from .sharing_sites import sharing_sites_info_for_video from .transcripts_utils import ( @@ -119,7 +119,7 @@ @XBlock.wants('settings', 'completion', 'i18n', 'request_cache') @XBlock.needs('mako', 'user') -class VideoBlock( +class _BuiltInVideoBlock( VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, VideoStudentViewHandlers, EmptyDataRawMixin, XmlMixin, EditingMixin, XModuleToXBlockMixin, ResourceTemplates, XModuleMixin, LicenseMixin): @@ -134,6 +134,7 @@ class VideoBlock( """ + is_extracted = False has_custom_completion = True completion_mode = XBlockCompletionMode.COMPLETABLE @@ -1260,3 +1261,10 @@ def _poster(self): edx_video_id=self.edx_video_id.strip() ) return None + + +VideoBlock = ( + _ExtractedVideoBlock if settings.USE_EXTRACTED_VIDEO_BLOCK + else _BuiltInVideoBlock +) +VideoBlock.__name__ = "VideoBlock" diff --git a/xmodule/word_cloud_block.py b/xmodule/word_cloud_block.py index d678f2a9a9f5..37e82400df78 100644 --- a/xmodule/word_cloud_block.py +++ b/xmodule/word_cloud_block.py @@ -6,23 +6,27 @@ If student have answered - words he entered and cloud. """ +from xblocks_contrib.word_cloud import WordCloudBlock as _ExtractedWordCloudBlock import json import logging +from django.conf import settings from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Boolean, Dict, Integer, List, Scope, String + from xmodule.editing_block import EditingMixin from xmodule.raw_block import EmptyDataRawMixin from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment -from xmodule.xml_block import XmlMixin from xmodule.x_module import ( ResourceTemplates, shim_xmodule_js, XModuleMixin, XModuleToXBlockMixin, ) +from xmodule.xml_block import XmlMixin + log = logging.getLogger(__name__) # Make '_' a no-op so we can scrape strings. Using lambda instead of @@ -41,7 +45,7 @@ def pretty_bool(value): @XBlock.needs('mako') -class WordCloudBlock( # pylint: disable=abstract-method +class _BuiltInWordCloudBlock( # pylint: disable=abstract-method EmptyDataRawMixin, XmlMixin, EditingMixin, @@ -53,6 +57,8 @@ class WordCloudBlock( # pylint: disable=abstract-method Word Cloud XBlock. """ + is_extracted = False + display_name = String( display_name=_("Display Name"), help=_("The display name for this component."), @@ -308,3 +314,10 @@ def index_dictionary(self): xblock_body["content_type"] = "Word Cloud" return xblock_body + + +WordCloudBlock = ( + _ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK + else _BuiltInWordCloudBlock +) +WordCloudBlock.__name__ = "WordCloudBlock" diff --git a/xmodule/xml_block.py b/xmodule/xml_block.py index 2753d455adc7..63dbedb17e1e 100644 --- a/xmodule/xml_block.py +++ b/xmodule/xml_block.py @@ -123,7 +123,11 @@ class XmlMixin: # places in the platform rely on it. 'course', 'org', 'url_name', 'filename', # Used for storing xml attributes between import and export, for roundtrips - 'xml_attributes') + 'xml_attributes', + # Used by _import_xml_node_to_parent in cms/djangoapps/contentstore/helpers.py to prevent + # XmlMixin from treating some XML nodes as "pointer nodes". + "x-is-pointer-node", + ) # This is a categories to fields map that contains the block category specific fields which should not be # cleaned and/or override while adding xml to node.