diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index 1afc51ed77af..0aa06d8b8dcc 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -51,6 +51,7 @@ class CourseHomeSerializer(serializers.Serializer): allow_empty=True ) archived_courses = CourseCommonSerializer(required=False, many=True) + can_access_advanced_settings = serializers.BooleanField() can_create_organizations = serializers.BooleanField() course_creator_status = serializers.CharField() courses = CourseCommonSerializer(required=False, many=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index d41ceb2647c5..d72042cff611 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -52,6 +52,7 @@ def get(self, request: Request): "allow_unicode_course_id": false, "allowed_organizations": [], "archived_courses": [], + "can_access_advanced_settings": true, "can_create_organizations": true, "course_creator_status": "granted", "courses": [], diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index 1b8bfaa84728..a8b4cf5e3933 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -44,6 +44,7 @@ def test_home_page_courses_response(self): "allow_unicode_course_id": False, "allowed_organizations": [], "archived_courses": [], + "can_access_advanced_settings": True, "can_create_organizations": True, "course_creator_status": "granted", "courses": [], diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py index 66b5f46128c8..8e220a334c06 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py @@ -277,18 +277,14 @@ def test_update_exam_settings_invalid_value(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual( - response.data, + self.assertIn( { - "detail": [ - { - "proctoring_provider": ( - "The selected proctoring provider, notvalidprovider, is not a valid provider. " - "Please select from one of ['test_proctoring_provider']." - ) - } - ] + "proctoring_provider": ( + "The selected proctoring provider, notvalidprovider, is not a valid provider. " + "Please select from one of ['test_proctoring_provider']." + ) }, + response.data['detail'], ) # course settings have been updated @@ -408,18 +404,14 @@ def test_400_for_disabled_lti(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual( - response.data, + self.assertIn( { - "detail": [ - { - "proctoring_provider": ( - "The selected proctoring provider, lti_external, is not a valid provider. " - "Please select from one of ['null']." - ) - } - ] + "proctoring_provider": ( + "The selected proctoring provider, lti_external, is not a valid provider. " + "Please select from one of ['null']." + ) }, + response.data['detail'], ) # course settings have been updated diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 450040c80374..9c478ddfe5d7 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -9,6 +9,7 @@ from django.conf import settings from django.test import TestCase from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator from path import Path as path @@ -19,7 +20,11 @@ from cms.djangoapps.contentstore import utils from cms.djangoapps.contentstore.tasks import ALL_ALLOWED_XBLOCKS, validate_course_olx from cms.djangoapps.contentstore.tests.utils import TEST_DATA_DIR, CourseTestCase +from cms.djangoapps.contentstore.utils import send_course_update_notification +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -927,3 +932,32 @@ def test_update_course_details_instructor_paced(self, mock_update): utils.update_course_details(mock_request, self.course.id, payload, None) mock_update.assert_called_once_with(self.course.id, payload, mock_request.user) + + +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class CourseUpdateNotificationTests(ModuleStoreTestCase): + """ + Unit tests for the course_update notification. + """ + + def setUp(self): + """ + Setup the test environment. + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(org='testorg', number='testcourse', run='testrun') + CourseNotificationPreference.objects.create(user_id=self.user.id, course_id=self.course.id) + + def test_course_update_notification_sent(self): + """ + Test that the course_update notification is sent. + """ + user = UserFactory() + CourseEnrollment.enroll(user=user, course_key=self.course.id) + assert Notification.objects.all().count() == 0 + content = "

content

" + send_course_update_notification(self.course.id, content, self.user) + assert Notification.objects.all().count() == 1 + notification = Notification.objects.first() + assert notification.content == "

content

" diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 631ceeb270b6..214193918eb4 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -11,16 +11,19 @@ from urllib.parse import quote_plus from uuid import uuid4 +from bs4 import BeautifulSoup from django.conf import settings from django.core.exceptions import ValidationError from django.urls import reverse from django.utils import translation +from django.utils.text import Truncator from django.utils.translation import gettext as _ from eventtracking import tracker from help_tokens.core import HelpUrlExpert from lti_consumer.models import CourseAllowPIISharingInLTIFlag from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryLocator + from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED @@ -1534,6 +1537,7 @@ def get_library_context(request, request_is_json=False): ) from cms.djangoapps.contentstore.views.library import ( LIBRARIES_ENABLED, + user_can_view_create_library_button, ) libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else [] @@ -1547,7 +1551,7 @@ def get_library_context(request, request_is_json=False): 'in_process_course_actions': [], 'courses': [], 'libraries_enabled': LIBRARIES_ENABLED, - 'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active, + 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active, 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -1712,6 +1716,7 @@ def get_home_context(request, no_course=False): 'allowed_organizations': get_allowed_organizations(user), 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), 'can_create_organizations': user_can_create_organizations(user), + 'can_access_advanced_settings': auth.has_studio_advanced_settings_access(user), } return home_context @@ -2239,11 +2244,34 @@ def track_course_update_event(course_key, user, course_update_content=None): tracker.emit(event_name, event_data) +def clean_html_body(html_body): + """ + Get html body, remove tags and limit to 500 characters + """ + html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser') + + tags_to_remove = [ + "a", "link", # Link Tags + "img", "picture", "source", # Image Tags + "video", "track", # Video Tags + "audio", # Audio Tags + "embed", "object", "iframe", # Embedded Content + "script" + ] + + # Remove the specified tags while keeping their content + for tag in tags_to_remove: + for match in html_body.find_all(tag): + match.unwrap() + + return str(html_body) + + def send_course_update_notification(course_key, content, user): """ Send course update notification """ - text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content)) + text_content = re.sub(r"(\s| |//)+", " ", clean_html_body(content)) course = modulestore().get_course(course_key) extra_context = { 'author_id': user.id, @@ -2252,7 +2280,7 @@ def send_course_update_notification(course_key, content, user): notification_data = CourseNotificationData( course_key=course_key, content_context={ - "course_update_content": text_content if len(text_content.strip()) < 10 else "Click here to view", + "course_update_content": text_content, **extra_context, }, notification_type="course_updates", diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 870c192653d2..8c314caa6697 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -69,31 +69,7 @@ def should_redirect_to_library_authoring_mfe(): ) -def user_can_view_create_library_button(user): - """ - Helper method for displaying the visibilty of the create_library_button. - """ - if not LIBRARIES_ENABLED: - return False - elif user.is_staff: - return True - elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): - is_course_creator = get_course_creator_status(user) == 'granted' - has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists() - has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists() - has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists() - return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role - else: - # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present. - disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None) - disable_course_creation = settings.FEATURES.get('DISABLE_COURSE_CREATION', False) - if disable_library_creation is not None: - return not disable_library_creation - else: - return not disable_course_creation - - -def user_can_create_library(user, org): +def _user_can_create_library_for_org(user, org=None): """ Helper method for returning the library creation status for a particular user, taking into account the value LIBRARIES_ENABLED. @@ -109,29 +85,29 @@ def user_can_create_library(user, org): Course Staff: Can make libraries in the organization which has courses of which they are staff. Course Admin: Can make libraries in the organization which has courses of which they are Admin. """ - if org is None: - return False if not LIBRARIES_ENABLED: return False elif user.is_staff: return True - if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + org_filter_params = {} + if org: + org_filter_params['org'] = org is_course_creator = get_course_creator_status(user) == 'granted' - has_org_staff_role = org in OrgStaffRole().get_orgs_for_user(user) + has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists() has_course_staff_role = ( UserBasedRole(user=user, role=CourseStaffRole.ROLE) .courses_with_role() - .filter(org=org) + .filter(**org_filter_params) .exists() ) has_course_admin_role = ( UserBasedRole(user=user, role=CourseInstructorRole.ROLE) .courses_with_role() - .filter(org=org) + .filter(**org_filter_params) .exists() ) return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role - else: # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present. disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None) @@ -142,6 +118,22 @@ def user_can_create_library(user, org): return not disable_course_creation +def user_can_view_create_library_button(user): + """ + Helper method for displaying the visibilty of the create_library_button. + """ + return _user_can_create_library_for_org(user) + + +def user_can_create_library(user, org): + """ + Helper method for to check if user can create library for given org. + """ + if org is None: + return False + return _user_can_create_library_for_org(user, org) + + @login_required @ensure_csrf_cookie @require_http_methods(('GET', 'POST')) diff --git a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py index a7ee7f0ab0cd..0f38722e1208 100644 --- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py +++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py @@ -162,6 +162,39 @@ def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler): else: assert 'To update these settings go to the Advanced Settings page.' in alert_text + @override_settings( + PROCTORING_BACKENDS={ + 'DEFAULT': 'test_proctoring_provider', + 'proctortrack': {}, + 'test_proctoring_provider': {}, + }, + FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED, + ) + @ddt.data( + "advanced_settings_handler", + "course_handler", + ) + def test_invalid_provider_alert(self, page_handler): + """ + An alert should appear if the course has a proctoring provider that is not valid. + """ + # create an error by setting an invalid proctoring provider + self.course.proctoring_provider = 'invalid_provider' + self.course.enable_proctored_exams = True + self.save_course() + + url = reverse_course_url(page_handler, self.course.id) + resp = self.client.get(url, HTTP_ACCEPT='text/html') + alert_text = self._get_exam_settings_alert_text(resp.content) + assert ( + 'This course has proctored exam settings that are incomplete or invalid.' + in alert_text + ) + assert ( + 'The proctoring provider configured for this course, \'invalid_provider\', is not valid.' + in alert_text + ) + @ddt.data( "advanced_settings_handler", "course_handler", diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index fd5219dfb472..5d4ac5a4a336 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -217,7 +217,10 @@ def update_from_json(cls, block, jsondict, user, filter_tabs=True): try: val = model['value'] if hasattr(block, key) and getattr(block, key) != val: - key_values[key] = block.fields[key].from_json(val) + if key == 'proctoring_provider': + key_values[key] = block.fields[key].from_json(val, validate_providers=True) + else: + key_values[key] = block.fields[key].from_json(val) except (TypeError, ValueError) as err: raise ValueError(_("Incorrect format for field '{name}'. {detailed_message}").format( # lint-amnesty, pylint: disable=raise-missing-from name=model['display_name'], detailed_message=str(err))) @@ -253,7 +256,10 @@ def validate_and_update_from_json(cls, block, jsondict, user, filter_tabs=True): try: val = model['value'] if hasattr(block, key) and getattr(block, key) != val: - key_values[key] = block.fields[key].from_json(val) + if key == 'proctoring_provider': + key_values[key] = block.fields[key].from_json(val, validate_providers=True) + else: + key_values[key] = block.fields[key].from_json(val) except (TypeError, ValueError, ValidationError) as err: did_validate = False errors.append({'key': key, 'message': str(err), 'model': model}) @@ -484,6 +490,24 @@ def validate_proctoring_settings(cls, block, settings_dict, user): enable_proctoring = block.enable_proctored_exams if enable_proctoring: + + if proctoring_provider_model: + proctoring_provider = proctoring_provider_model.get('value') + else: + proctoring_provider = block.proctoring_provider + + # If the proctoring provider stored in the course block no longer + # matches the available providers for this instance, show an error + if proctoring_provider not in available_providers: + message = ( + f'The proctoring provider configured for this course, \'{proctoring_provider}\', is not valid.' + ) + errors.append({ + 'key': 'proctoring_provider', + 'message': message, + 'model': proctoring_provider_model + }) + # Require a valid escalation email if Proctortrack is chosen as the proctoring provider escalation_email_model = settings_dict.get('proctoring_escalation_email') if escalation_email_model: @@ -491,11 +515,6 @@ def validate_proctoring_settings(cls, block, settings_dict, user): else: escalation_email = block.proctoring_escalation_email - if proctoring_provider_model: - proctoring_provider = proctoring_provider_model.get('value') - else: - proctoring_provider = block.proctoring_provider - missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.' if proctoring_provider_model and proctoring_provider == 'proctortrack': if not escalation_email: diff --git a/cms/envs/common.py b/cms/envs/common.py index 45a8e97f3e51..34dd8503f35e 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -949,7 +949,6 @@ 'openedx.core.djangoapps.cache_toolbox.middleware.CacheBackedAuthenticationMiddleware', 'common.djangoapps.student.middleware.UserStandingMiddleware', - 'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'common.djangoapps.track.middleware.TrackMiddleware', @@ -1449,9 +1448,8 @@ 'edx-ui-toolkit/js/utils/string-utils.js', 'edx-ui-toolkit/js/utils/html-utils.js', - # Load Bootstrap and supporting libraries - 'common/js/vendor/popper.js', - 'common/js/vendor/bootstrap.js', + # Here we were loading Bootstrap and supporting libraries, but it no longer seems to be needed for any Studio UI. + # 'common/js/vendor/bootstrap.bundle.js', # Finally load RequireJS 'common/js/vendor/require.js' diff --git a/cms/templates/content_libraries/xblock_iframe.html b/cms/templates/content_libraries/xblock_iframe.html index e8eb4c96ead0..b6e455f78515 100644 --- a/cms/templates/content_libraries/xblock_iframe.html +++ b/cms/templates/content_libraries/xblock_iframe.html @@ -6,7 +6,12 @@ - + {% if is_development %} + + + {% else %} + + {% endif %} diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 269961424d04..821c59dc0524 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -126,9 +126,13 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= if ecommerce_service.is_enabled(request.user): professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL) if purchase_workflow == "single" and professional_mode.sku: - redirect_url = ecommerce_service.get_checkout_page_url(professional_mode.sku) + redirect_url = ecommerce_service.get_checkout_page_url( + professional_mode.sku, course_run_keys=[course_id] + ) if purchase_workflow == "bulk" and professional_mode.bulk_sku: - redirect_url = ecommerce_service.get_checkout_page_url(professional_mode.bulk_sku) + redirect_url = ecommerce_service.get_checkout_page_url( + professional_mode.bulk_sku, course_run_keys=[course_id] + ) return redirect(redirect_url) course = modulestore().get_course(course_key) diff --git a/common/djangoapps/entitlements/rest_api/v1/permissions.py b/common/djangoapps/entitlements/rest_api/v1/permissions.py index 6a705d9feed5..db14f05049c3 100644 --- a/common/djangoapps/entitlements/rest_api/v1/permissions.py +++ b/common/djangoapps/entitlements/rest_api/v1/permissions.py @@ -4,7 +4,6 @@ """ -from django.conf import settings from rest_framework.permissions import SAFE_METHODS, BasePermission from lms.djangoapps.courseware.access import has_access @@ -22,12 +21,3 @@ def has_permission(self, request, view): return request.user.is_authenticated else: return request.user.is_staff or has_access(request.user, "support", "global") - - -class IsSubscriptionWorkerUser(BasePermission): - """ - Method that will require the request to be coming from the subscriptions service worker user. - """ - - def has_permission(self, request, view): - return request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME diff --git a/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py b/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py index 34abc39c0096..86d4ae6a87e1 100644 --- a/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py @@ -6,7 +6,6 @@ import uuid from datetime import datetime, timedelta from unittest.mock import patch -from uuid import uuid4 from django.conf import settings from django.urls import reverse @@ -1236,160 +1235,3 @@ def test_user_is_not_unenrolled_on_failed_refund( assert CourseEnrollment.is_enrolled(self.user, self.course.id) assert course_entitlement.enrollment_course_run is not None assert course_entitlement.expired_at is None - - -@skip_unless_lms -class RevokeSubscriptionsVerifiedAccessViewTest(ModuleStoreTestCase): - """ - Tests for the RevokeVerifiedAccessView - """ - REVOKE_VERIFIED_ACCESS_PATH = 'entitlements_api:v1:revoke_subscriptions_verified_access' - - def setUp(self): - super().setUp() - self.user = UserFactory(username="subscriptions_worker", is_staff=True) - self.client.login(username=self.user.username, password=TEST_PASSWORD) - self.course = CourseFactory() - self.course_mode1 = CourseModeFactory( - course_id=self.course.id, # pylint: disable=no-member - mode_slug=CourseMode.VERIFIED, - expiration_datetime=now() + timedelta(days=1) - ) - self.course_mode2 = CourseModeFactory( - course_id=self.course.id, # pylint: disable=no-member - mode_slug=CourseMode.AUDIT, - expiration_datetime=now() + timedelta(days=1) - ) - - @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') - def test_revoke_access_success(self, mock_get_courses_completion_status): - mock_get_courses_completion_status.return_value = ([], False) - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - assert course_entitlement.enrollment_course_run is not None - - response = self.client.post( - url, - data={ - "entitlement_uuids": [str(course_entitlement.uuid)], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 204 - - course_entitlement.refresh_from_db() - enrollment.refresh_from_db() - assert course_entitlement.expired_at is not None - assert course_entitlement.enrollment_course_run is None - assert enrollment.mode == CourseMode.AUDIT - - @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') - def test_already_completed_course(self, mock_get_courses_completion_status): - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - mock_get_courses_completion_status.return_value = ([str(enrollment.course_id)], False) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - assert course_entitlement.enrollment_course_run is not None - - response = self.client.post( - url, - data={ - "entitlement_uuids": [str(course_entitlement.uuid)], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 204 - - course_entitlement.refresh_from_db() - assert course_entitlement.expired_at is None - assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED - - @patch('common.djangoapps.entitlements.rest_api.v1.views.log.info') - def test_revoke_access_invalid_uuid(self, mock_log): - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - entitlement_uuids = [str(uuid4())] - response = self.client.post( - url, - data={ - "entitlement_uuids": entitlement_uuids, - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - - mock_log.assert_called_once_with("B2C_SUBSCRIPTIONS: Entitlements not found for the provided" - " entitlements data: %s and user: %s", - entitlement_uuids, - self.user.id) - assert response.status_code == 204 - - def test_revoke_access_unauthorized_user(self): - user = UserFactory(is_staff=True, username='not_subscriptions_worker') - self.client.login(username=user.username, password=TEST_PASSWORD) - - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - assert course_entitlement.enrollment_course_run is not None - - response = self.client.post( - url, - data={ - "entitlement_uuids": [], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 403 - - course_entitlement.refresh_from_db() - assert course_entitlement.expired_at is None - assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED - - @patch('common.djangoapps.entitlements.tasks.retry_revoke_subscriptions_verified_access.apply_async') - @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') - def test_course_completion_exception_triggers_task(self, mock_get_courses_completion_status, mock_task): - mock_get_courses_completion_status.return_value = ([], True) - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - response = self.client.post( - url, - data={ - "entitlement_uuids": [str(course_entitlement.uuid)], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 204 - mock_task.assert_called_once_with(args=([str(course_entitlement.uuid)], - [str(enrollment.course_id)], - self.user.username)) diff --git a/common/djangoapps/entitlements/rest_api/v1/throttles.py b/common/djangoapps/entitlements/rest_api/v1/throttles.py deleted file mode 100644 index 3a010c76afe7..000000000000 --- a/common/djangoapps/entitlements/rest_api/v1/throttles.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -Throttle classes for the entitlements API. -""" - -from django.conf import settings -from rest_framework.throttling import UserRateThrottle - - -class ServiceUserThrottle(UserRateThrottle): - """A throttle allowing service users to override rate limiting""" - - def allow_request(self, request, view): - """Returns True if the request is coming from one of the service users - and defaults to UserRateThrottle's configured setting otherwise. - """ - service_users = [ - settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME - ] - if request.user.username in service_users: - return True - return super().allow_request(request, view) diff --git a/common/djangoapps/entitlements/rest_api/v1/urls.py b/common/djangoapps/entitlements/rest_api/v1/urls.py index e1d98a2485c3..e04341b5ef50 100644 --- a/common/djangoapps/entitlements/rest_api/v1/urls.py +++ b/common/djangoapps/entitlements/rest_api/v1/urls.py @@ -6,7 +6,7 @@ from django.urls import path, re_path from rest_framework.routers import DefaultRouter -from .views import EntitlementEnrollmentViewSet, EntitlementViewSet, SubscriptionsRevokeVerifiedAccessView +from .views import EntitlementEnrollmentViewSet, EntitlementViewSet router = DefaultRouter() router.register(r'entitlements', EntitlementViewSet, basename='entitlements') @@ -24,9 +24,4 @@ ENROLLMENTS_VIEW, name='enrollments' ), - path( - 'subscriptions/entitlements/revoke', - SubscriptionsRevokeVerifiedAccessView.as_view(), - name='revoke_subscriptions_verified_access' - ) ] diff --git a/common/djangoapps/entitlements/rest_api/v1/views.py b/common/djangoapps/entitlements/rest_api/v1/views.py index 3306604d5d13..4f3dd54b52a7 100644 --- a/common/djangoapps/entitlements/rest_api/v1/views.py +++ b/common/djangoapps/entitlements/rest_api/v1/views.py @@ -15,7 +15,6 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status, viewsets from rest_framework.response import Response -from rest_framework.views import APIView from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long @@ -24,22 +23,13 @@ CourseEntitlementSupportDetail ) from common.djangoapps.entitlements.rest_api.v1.filters import CourseEntitlementFilter -from common.djangoapps.entitlements.rest_api.v1.permissions import ( - IsAdminOrSupportOrAuthenticatedReadOnly, - IsSubscriptionWorkerUser -) +from common.djangoapps.entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer -from common.djangoapps.entitlements.rest_api.v1.throttles import ServiceUserThrottle -from common.djangoapps.entitlements.tasks import retry_revoke_subscriptions_verified_access -from common.djangoapps.entitlements.utils import ( - is_course_run_entitlement_fulfillable, - revoke_entitlements_and_downgrade_courses_to_audit -) +from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable from common.djangoapps.student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf -from openedx.core.djangoapps.credentials.utils import get_courses_completion_status from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in User = get_user_model() @@ -132,7 +122,6 @@ class EntitlementViewSet(viewsets.ModelViewSet): filter_backends = (DjangoFilterBackend,) filterset_class = CourseEntitlementFilter pagination_class = EntitlementsPagination - throttle_classes = (ServiceUserThrottle,) def get_queryset(self): user = self.request.user @@ -530,68 +519,3 @@ def destroy(self, request, uuid): }) return Response(status=status.HTTP_204_NO_CONTENT) - - -class SubscriptionsRevokeVerifiedAccessView(APIView): - """ - Endpoint for expiring entitlements for a user and downgrading the enrollments - to Audit mode. This endpoint accepts a list of entitlement UUIDs and will expire - the entitlements along with downgrading the related enrollments to Audit mode. - Only those enrollments are downgraded to Audit for which user has not been awarded - a completion certificate yet. - """ - authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,) - permission_classes = (permissions.IsAuthenticated, IsSubscriptionWorkerUser,) - throttle_classes = (ServiceUserThrottle,) - - def _process_revoke_and_downgrade_to_audit(self, course_entitlements, user_id, revocable_entitlement_uuids): - """ - Gets course completion status for the provided course entitlements and triggers the - revoke and downgrade to audit process for the course entitlements which are not completed. - Triggers the retry task asynchronously if there is an exception while getting the - course completion status. - """ - entitled_course_ids = [] - user = User.objects.get(id=user_id) - username = user.username - for course_entitlement in course_entitlements: - if course_entitlement.enrollment_course_run is not None: - entitled_course_ids.append(str(course_entitlement.enrollment_course_run.course_id)) - - log.info('B2C_SUBSCRIPTIONS: Getting course completion status for user [%s] and entitled_course_ids %s', - username, - entitled_course_ids) - awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids) - - if is_exception: - # Trigger the retry task asynchronously - log.exception('B2C_SUBSCRIPTIONS: Exception occurred while getting course completion status for user %s ' - 'and entitled_course_ids %s', - username, - entitled_course_ids) - retry_revoke_subscriptions_verified_access.apply_async(args=(revocable_entitlement_uuids, - entitled_course_ids, - username)) - return - revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids, - revocable_entitlement_uuids) - - def post(self, request): - """ - Invokes the entitlements expiration process for the provided uuids and downgrades the - enrollments to Audit mode. - """ - revocable_entitlement_uuids = request.data.get('entitlement_uuids', []) - user_id = request.data.get('lms_user_id', None) - course_entitlements = (CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids). - select_related('user'). - select_related('enrollment_course_run')) - - if course_entitlements.exists(): - self._process_revoke_and_downgrade_to_audit(course_entitlements, user_id, revocable_entitlement_uuids) - return Response(status=status.HTTP_204_NO_CONTENT) - else: - log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements data: %s and user: %s', - revocable_entitlement_uuids, - user_id) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/common/djangoapps/entitlements/tasks.py b/common/djangoapps/entitlements/tasks.py index 981879e21793..9bd200bc9056 100644 --- a/common/djangoapps/entitlements/tasks.py +++ b/common/djangoapps/entitlements/tasks.py @@ -4,15 +4,12 @@ import logging from celery import shared_task -from celery.exceptions import MaxRetriesExceededError from celery.utils.log import get_task_logger from django.conf import settings # lint-amnesty, pylint: disable=unused-import from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail -from common.djangoapps.entitlements.utils import revoke_entitlements_and_downgrade_courses_to_audit -from openedx.core.djangoapps.credentials.utils import get_courses_completion_status LOGGER = get_task_logger(__name__) log = logging.getLogger(__name__) @@ -154,40 +151,3 @@ def expire_and_create_entitlements(self, entitlement_ids, support_username): '%d entries, task id :%s', len(entitlement_ids), self.request.id) - - -@shared_task(bind=True) -@set_code_owner_attribute -def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, username): - """ - Task to process course access revoke and move to audit. - This is called only if call to get_courses_completion_status fails due to any exception. - """ - LOGGER.info("B2C_SUBSCRIPTIONS: Running retry_revoke_subscriptions_verified_access for user [%s]," - " entitlement_uuids %s and entitled_course_ids %s", - username, - revocable_entitlement_uuids, - entitled_course_ids) - course_entitlements = CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids) - course_entitlements = course_entitlements.select_related('user').select_related('enrollment_course_run') - if course_entitlements.exists(): - awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids) - if is_exception: - try: - countdown = 2 ** self.request.retries - self.retry(countdown=countdown, max_retries=3) - except MaxRetriesExceededError: - LOGGER.exception( - 'B2C_SUBSCRIPTIONS: Failed to process retry_revoke_subscriptions_verified_access ' - 'for user [%s] and entitlement_uuids %s', - username, - revocable_entitlement_uuids - ) - return - revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids, - revocable_entitlement_uuids) - else: - LOGGER.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements uuids %s ' - 'for user [%s] duing the retry_revoke_subscriptions_verified_access task', - revocable_entitlement_uuids, - username) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 7b26cb041a0a..4bfc710fe901 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -583,7 +583,7 @@ def test_verification_signal(self): """ Verification signal is sent upon approval. """ - with mock.patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with mock.patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: # Begin the pipeline. pipeline.set_id_verification_status( auth_entry=pipeline.AUTH_ENTRY_LOGIN, diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index b4dcff0b4ab8..2e9d48109648 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/docs/decisions/0020-upstream-downstream.rst b/docs/decisions/0020-upstream-downstream.rst new file mode 100644 index 000000000000..8ceb9e775274 --- /dev/null +++ b/docs/decisions/0020-upstream-downstream.rst @@ -0,0 +1,402 @@ +4. Upstream and downstream content +################################## + +Status +****** + +Accepted. + +Implementation in progress as of 2024-09-03. + +Context +******* + +We are replacing the existing Legacy ("V1") Content Libraries system, based on +ModuleStore, with a Relaunched ("V2") Content Libraries system, based on +Learning Core. V1 and V2 libraries will coexist for at least one release to +allow for migration; eventually, V1 libraries will be removed entirely. + +Content from V1 libraries can only be included into courses using the +LibraryContentBlock (called "Randomized Content Module" in Studio), which works +like this: + +* Course authors add a LibraryContentBlock to a Unit and configure it with a + library key and a count of N library blocks to select (or `-1` for "all + blocks"). + +* For each block in the chosen library, its *content definition* is copied into + the course as a child of the LibraryContentBlock, whereas its *settings* are + copied into a special "default" settings dictionary in the course's structure + document--this distinction will matter later. The usage key of each copied + block is derived from a hash of the original library block's usage key plus + the LibraryContentBlock's own usage key--this will also matter + later. + +* The course author is free to override the content and settings of the + course-local copies of each library block. + +* When any update is made to the library, the course author is prompted to + update the LibraryContentBlock. This involves re-copying the library blocks' + content definitions and default settings, which clobbers any overrides they + have made to content, but preserves any overrides they have made to settings. + Furthermore, any blocks that were added to the library are newly copied into + the course, and any blocks that were removed from the library are deleted + from the course. For all blocks, usage keys are recalculated using the same + hash derivation described above; for existing blocks, it is important that + this recalculation yields the same usage key so that student state is not + lost. + +* Over in the LMS, when a learner loads LibraryContentBlock, they are shown a + list of N randomly-picked blocks from the library. Subsequent visits show + them the same list, *unless* children were added, children were removed, or N + changed. In those cases, the LibraryContentBlock tries to make the smallest + possible adjustment to their personal list of blocks while respecting N and + the updated list of children. + +This system has several issues: + +#. **Missing defaults after import:** When a course with a LibraryContentBlock + is imported into an Open edX instance *without* the referenced library, the + blocks' *content* will remain intact as will course-local *settings + overrides*. However, any *default settings* defined in the library will be + missing. This can result in content that is completely broken, especially + since critical fields like video URLs and LTI URLs are considered + "settings". For a detailed scenario, see `LibraryContentBlock Curveball 1`_. + +#. **Strange behavior when duplicating content:** Typically, when a + block is duplicated or copy-pasted, the new block's usage key and its + children's usage keys are randomly generated. However, recall that when a + LibraryContentBlock is updated, its children's usage keys are rederived + using a hash function. That would cause the children's usage keys to change, + thus destroying any student state. So, we must work around this with a hack: + upon duplicating or pasting a LibraryContentBlock, we immediately update the + LibraryContentBlock, thus discarding the problematic randomly-generated keys + in favor of hash-derived keys. This works, but: + + * it involves weird code hacks, + * it unexpectedly discards any content overrides the course author made to + the copied LibraryContentBlock's children, + * it unexpectedly uses the latest version of library content, regardless of + which version the copied LibraryContentBlock was using, and + * it fails if the library does not exist on the Open edX instance, which + can happen if the course was imported from another instance. + +#. **Conflation of reference and randomization:** The LibraryContentBlock does + two things: it connects courses to library content, and it shows users a + random subset of content. There is no reason that those two features need to + be coupled together. A course author may want to randomize course-defined + content, or they may want to randomize content from multiple different + libraries. Or, they may want to use content from libraries without + randomizing it at all. While it is feasible to support all these things in a + single XBlock, trying to do so led to a `very complicated XBlock concept`_ + which difficult to explain to product managers and other engineers. + +#. **Unpredictable preservation of overrides:** Recall that *content + definitions* and *settings* are handled differently. This distinction is + defined in the code: every authorable XBlock field is either defined with + `Scope.content` or `Scope.settings`. In theory, XBlock developers would use + the content scope for fields that are core to the meaning of piece of + content, and they would only use the settings scope for fields that would be + reasonable to configure in a local copy of the piece of content. In + practice, though, XBlock developers almost always use `Scope.settings`. The + result of this is that customizations to blocks *almost always* survive + through library updates, except when they don't. Course authors have no way + to know (or even guess) when their customizations they will and won't + survive updates. + +#. **General pain and suffering:** The relationship between courses and V1 + libraries is confusing to content authors, site admins, and developers + alike. The behaviors above toe the line between "quirks" and "known bugs", + and they are not all documented. Past attempts to improve the system have + `triggered series of bugs`_, some of which led to permanent loss of learner + state. In other cases, past Content Libraries improvement efforts have + slowed or completely stalled out in code review due to the overwhelming + amount of context and edge cases that must be understood to safely make any + changes. + +.. _LibraryContentBlock Curveball 1: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-1%3A-Import%2FExport +.. _LibraryContentBlock Curveball 2: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-2:-Duplication +.. _very complicated XBlock concept: https://github.com/openedx/edx-platform/blob/master/xmodule/docs/decisions/0003-library-content-block-schema.rst +.. _triggered series of bugs: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3858661405/Bugs+from+Content+Libraries+V1 + +We are keen to use the Library Relaunch project to address all of these +problems. So, V2 libraries will interop with courses using a completely +different data model. + + +Decision +******** + +We will create a framework where a *downstream* piece of content (e.g. a course +block) can be *linked* to an *upstream* piece of content (e.g., a library +block) with the following properties: + +* **Portable:** Links can refer to certain content on the current Open edX + instance, and in the future they may be able to refer to content on other + Open edX instances or sites. Links will never include information that is + internal to a particular Open edX instance, such as foreign keys. + +* **Flat:** The *link* is a not a wrapper (like the LibraryContentBlock), + but simply a piece of metadata directly on the downstream content which + points to the upstream content. We will no longer rely on precarious + hash-derived usage keys to establish connection to upstream blocks; + like any other block, an upstream-linked blocks can be granted whatever block + ID that the authoring environment assigns it, whether random or + human-readable. + +* **Forwards-compatible:** If downstream content is created in a course on + an Open edX site that supports upstream and downstreams (e.g., a Teak + instance), and then it is exported and imported into a site that doesn't + (e.g., a Quince instance), the downstream content will simply act like + regular course content. + +* **Independent:** Upstream content and downstream content exist separately + from one another: + + * Modifying upstream content does not affect any downstream content (unless a + sync happens, more on that later). + * Deleting upstream content does not impact its downstream content. By + corollary, pieces of downstream content can completely and correctly render + on Open edX instances that are missing their linked upstream content. + * (Preserving a positive feature of the V1 LibraryContentBlock) The link + persists through export-import and copy-paste, regardless of whether the + upstream content actually exists. A "broken" link to upstream content is + seamlessly "repaired" if the upstream content becomes available again. + +* **Customizable:** On an OLX level, authors can still override the value + of any field for a piece of downstream content. However, we will empower + Studio to be more prescriptive about what authors *can* override versus what + they *should* override: + + * We define a set of *customizable* fields, with platform-level defaults + like display_name and a max_attempts, plus the ability for external + XBlocks to opt their own fields into customizability. + * Studio may use this list to provide an interface for customizing + downstream blocks, separate from the usual "Edit" interface that would + permit them to make unsafe overrides. + * Furthermore, downstream content will record which fields the user has + customized... + + * even if the customization is to simply clear the value of the fields... + * and even if the customization is made redundant in a future version of + the upstream content. For example, if max_attempts is customized from 3 + to 5 in the downstream content, but the next version of the upstream + content also changes max_attempts to 5, the downstream would still + consider max_attempts to be customized. If the following version of the + upstream content again changed max_attempts to 6, the downstream would + retain max_attempts to be 5. + + * Finally, the downstream content will locally save the upstream value of + customizable fields, allowing the author to *revert* back to them + regardless of whether the upstream content is actually available. + +* **Synchronizable, without surprises:** Downstream content can be *synced* + with updates that have been made to its linked upstream. This means that the + latest available upstream content field values will entirely replace all of + the downstream field values, *except* those which were customized, as + described in the previous item. + +* **Concrete, but flexible:** The internal implementation of upstream-downstream + syncing will assume that: + + * upstream content belongs to a V2 content library, + * downstream content belongs to a course on the same instance, and + * the link is the stringified usage key of the upstream library content. + + This will allow us to keep the implementation straightforward. However, we + will *not* expose these assumptions in the Python APIs, the HTTP APIs, or in + the persisted fields, allowing us in the future to generalize to other + upstreams (such as externally-hosted libraries) and other downstreams (such + as a standalone enrollable sequence without a course). + + If any of these assumptions are violated, we will raise an exception or log a + warning, as appropriate. Particularly, if these assumptions are violated at + the OLX level via a course import, then we will probably show a warning at + import time and refuse to sync from the unsupported upstream; however, we + will *not* fail the entire import or mangle the value of upstream link, since + we want to remain forwards-compatible with potential future forms of syncing. + As a concrete example: if a course block has *another course block's usage + key* as an upstream, then we will faithfully keep that value through the + import and export process, but we will not prompt the user to sync updates + for that block. + +* **Decoupled:** Upstream-downstream linking is not tied up with any other + courseware feature; in particular, it is unrelated to content randomization. + Randomized library content will be supported, but it will be a *synthesis* of + two features: (1) a RandomizationBlock that randomly selects a subset of its + children, where (2) some or all of those children are linked to upstream + blocks. + +Consequences +************ + +To support the Libraries Relaunch in Sumac: + +* For every XBlock in CMS, we will use XBlock fields to persist the upstream + link, its versions, its customizable fields, and its set of downstream + overrides. + + * We will avoid exposing these fields to LMS code. + + * We will define an initial set of customizable fields for Problem, Text, and + Video blocks. + +* We will define method(s) for syncing update on the XBlock runtime so that + they are available in the SplitModuleStore's XBlock Runtime + (CachingDescriptorSystem). + + * Either in the initial implementation or in a later implementation, it may + make sense to declare abstract versions of the syncing method(s) higher up + in XBlock Runtime inheritance hierarchy. + +* We will expose a CMS HTTP API for syncing updates to blocks from their + upstreams. + + * We will avoid exposing this API from the LMS. + +For reference, here are some excerpts of a potential implementation. This may +change through development and code review. + +.. code-block:: python + + ########################################################################### + # cms/lib/xblock/upstream_sync.py + ########################################################################### + + class UpstreamSyncMixin(XBlockMixin): + """ + Allows an XBlock in the CMS to be associated & synced with an upstream. + Mixed into CMS's XBLOCK_MIXINS, but not LMS's. + """ + + # Metadata related to upstream synchronization + upstream = String( + help=(""" + The usage key of a block (generally within a content library) + which serves as a source of upstream updates for this block, + or None if there is no such upstream. Please note: It is valid + for this field to hold a usage key for an upstream block + that does not exist (or does not *yet* exist) on this instance, + particularly if this downstream block was imported from a + different instance. + """), + default=None, scope=Scope.settings, hidden=True, enforce_type=True + ) + upstream_version = Integer( + help=(""" + Record of the upstream block's version number at the time this + block was created from it. If upstream_version is smaller + than the upstream block's latest version, then the user will be + able to sync updates into this downstream block. + """), + default=None, scope=Scope.settings, hidden=True, enforce_type=True, + ) + downstream_customized = Set( + help=(""" + Names of the fields which have values set on the upstream + block yet have been explicitly overridden on this downstream + block. Unless explicitly cleared by the user, these + customizations will persist even when updates are synced from + the upstream. + """), + default=[], scope=Scope.settings, hidden=True, enforce_type=True, + ) + + # Store upstream defaults for customizable fields. + upstream_display_name = String(...) + upstream_max_attempts = List(...) + ... # We will probably want to pre-define several more of these. + + def get_upstream_field_names(cls) -> dict[str, str]: + """ + Mapping from each customizable field to field which stores its upstream default. + XBlocks outside of edx-platform can override this in order to set + up their own customizable fields. + """ + return { + "display_name": "upstream_display_name", + "max_attempts": "upstream_max_attempts", + } + + def save(self, *args, **kwargs): + """ + Update `downstream_customized` when a customizable field is modified. + Uses `get_upstream_field_names` keys as the list of fields that are + customizable. + """ + ... + + @dataclass(frozen=True) + class UpstreamInfo: + """ + Metadata about a block's relationship with an upstream. + """ + usage_key: UsageKey + current_version: int + latest_version: int | None + sync_url: str + error: str | None + + @property + def sync_available(self) -> bool: + """ + Should the user be prompted to sync this block with upstream? + """ + return ( + self.latest_version + and self.current_version < self.latest_version + and not self.error + ) + + + ########################################################################### + # xmodule/modulestore/split_mongo/caching_descriptor_system.py + ########################################################################### + + class CachingDescriptorSystem(...): + + def validate_upstream_key(self, usage_key: UsageKey | str) -> UsageKey: + """ + Raise an error if the provided key is not a valid upstream reference. + Instead of explicitly checking whether a key is a LibraryLocatorV2, + callers should validate using this function, and use an `except` clause + to handle the case where the key is not a valid upstream. + Raises: InvalidKeyError, UnsupportedUpstreamKeyType + """ + ... + + def sync_from_upstream(self, *, downstream_key: UsageKey, apply_updates: bool) -> None: + """ + Python API for loading updates from upstream block. + Can choose whether or not to actually apply those updates... + apply_updates=False: Think "get fetch". + Use case: course import. + apply_updates=True: Think "git pull". + Use case: sync_updates handler. + Raises: InvalidKeyError, UnsupportedUpstreamKeyType, XBlockNotFoundError + """ + ... + + def get_upstream_info(self, downstream_key: UsageKey) -> UpstreamInfo | None: + """ + Python API for upstream metadata, or None. + Raises: InvalidKeyError, XBlockNotFoundError + """ + ... + +Finally, here is what the OLX for a library-sourced Problem XBlock in a course +might look like: + +.. code-block:: xml + + + + diff --git a/docs/hooks/events.rst b/docs/hooks/events.rst index 7f9584c9e8e1..bccb98e56a42 100644 --- a/docs/hooks/events.rst +++ b/docs/hooks/events.rst @@ -233,17 +233,29 @@ Content Authoring Events - 2023-07-20 * - `LIBRARY_BLOCK_CREATED `_ - - org.openedx.content_authoring.content_library.created.v1 + - org.openedx.content_authoring.library_block.created.v1 - 2023-07-20 * - `LIBRARY_BLOCK_UPDATED `_ - - org.openedx.content_authoring.content_library.updated.v1 + - org.openedx.content_authoring.library_block.updated.v1 - 2023-07-20 * - `LIBRARY_BLOCK_DELETED `_ - - org.openedx.content_authoring.content_library.deleted.v1 + - org.openedx.content_authoring.library_block.deleted.v1 - 2023-07-20 - * - `CONTENT_OBJECT_TAGS_CHANGED `_ - - org.openedx.content_authoring.content.object.tags.changed.v1 - - 2024-03-31 + * - `LIBRARY_COLLECTION_CREATED `_ + - org.openedx.content_authoring.content_library.collection.created.v1 + - 2024-08-23 + + * - `LIBRARY_COLLECTION_UPDATED `_ + - org.openedx.content_authoring.content_library.collection.updated.v1 + - 2024-08-23 + + * - `LIBRARY_COLLECTION_DELETED `_ + - org.openedx.content_authoring.content_library.collection.deleted.v1 + - 2024-08-23 + + * - `CONTENT_OBJECT_ASSOCIATIONS_CHANGED `_ + - org.openedx.content_authoring.content.object.associations.changed.v1 + - 2024-09-06 diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml index 38c52e737e19..5e9afcc6d370 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -3461,29 +3461,6 @@ paths: in: path required: true type: string - /demographics/v1/demographics/status/: - get: - operationId: demographics_v1_demographics_status_list - summary: GET /api/user/v1/accounts/demographics/status - description: This is a Web API to determine the status of demographics related - features - parameters: [] - responses: - '200': - description: '' - tags: - - demographics - patch: - operationId: demographics_v1_demographics_status_partial_update - summary: PATCH /api/user/v1/accounts/demographics/status - description: This is a Web API to update fields that are dependent on user interaction. - parameters: [] - responses: - '200': - description: '' - tags: - - demographics - parameters: [] /discounts/course/{course_key_string}: get: operationId: discounts_course_read @@ -5300,19 +5277,6 @@ paths: required: true type: string format: uuid - /entitlements/v1/subscriptions/entitlements/revoke: - post: - operationId: entitlements_v1_subscriptions_entitlements_revoke_create - description: |- - Invokes the entitlements expiration process for the provided uuids and downgrades the - enrollments to Audit mode. - parameters: [] - responses: - '201': - description: '' - tags: - - entitlements - parameters: [] /experiments/v0/custom/REV-934/: get: operationId: experiments_v0_custom_REV-934_list @@ -6649,6 +6613,11 @@ paths: course, chapter, sequential, vertical, html, problem, video, and discussion. display_name: (str) The display name of the block. + course_progress: (dict) Contains information about how many assignments are in the course + and how many assignments the student has completed. + Included here: + * total_assignments_count: (int) Total course's assignments count. + * assignments_completed: (int) Assignments witch the student has completed. **Returns** @@ -6696,6 +6665,26 @@ paths: in: path required: true type: string + /mobile/{api_version}/course_info/{course_id}/enrollment_details: + get: + operationId: mobile_course_info_enrollment_details_list + summary: Handle the GET request + description: Returns user enrollment and course details. + parameters: [] + responses: + '200': + description: '' + tags: + - mobile + parameters: + - name: api_version + in: path + required: true + type: string + - name: course_id + in: path + required: true + type: string /mobile/{api_version}/course_info/{course_id}/handouts: get: operationId: mobile_course_info_handouts_list @@ -6861,6 +6850,10 @@ paths: An additional attribute "expiration" has been added to the response, which lists the date when access to the course will expire or null if it doesn't expire. + In v4 we added to the response primary object. Primary object contains the latest user's enrollment + or course where user has the latest progress. Primary object has been cut from user's + enrolments array and inserted into separated section with key `primary`. + **Example Request** GET /api/mobile/v1/users/{username}/course_enrollments/ @@ -6910,14 +6903,14 @@ paths: * mode: The type of certificate registration for this course (honor or certified). * url: URL to the downloadable version of the certificate, if exists. + * course_progress: Contains information about how many assignments are in the course + and how many assignments the student has completed. + * total_assignments_count: Total course's assignments count. + * assignments_completed: Assignments witch the student has completed. parameters: [] responses: '200': description: '' - schema: - type: array - items: - $ref: '#/definitions/CourseEnrollment' tags: - mobile parameters: @@ -7031,22 +7024,6 @@ paths: tags: - notifications parameters: [] - /notifications/channel/configurations/{course_key_string}: - patch: - operationId: notifications_channel_configurations_partial_update - description: Update an existing user notification preference for an entire channel - with the data in the request body. - parameters: [] - responses: - '200': - description: '' - tags: - - notifications - parameters: - - name: course_key_string - in: path - required: true - type: string /notifications/configurations/{course_key_string}: get: operationId: notifications_configurations_read @@ -7222,6 +7199,38 @@ paths: in: path required: true type: string + /notifications/preferences/update/{username}/{patch}/: + get: + operationId: notifications_preferences_update_read + description: |- + View to update user preferences from encrypted username and patch. + username and patch must be string + parameters: [] + responses: + '200': + description: '' + tags: + - notifications + post: + operationId: notifications_preferences_update_create + description: |- + View to update user preferences from encrypted username and patch. + username and patch must be string + parameters: [] + responses: + '201': + description: '' + tags: + - notifications + parameters: + - name: username + in: path + required: true + type: string + - name: patch + in: path + required: true + type: string /notifications/read/: patch: operationId: notifications_read_partial_update @@ -11731,39 +11740,6 @@ definitions: title: Course enrollments type: string readOnly: true - CourseEnrollment: - type: object - properties: - audit_access_expires: - title: Audit access expires - type: string - readOnly: true - created: - title: Created - type: string - format: date-time - readOnly: true - x-nullable: true - mode: - title: Mode - type: string - maxLength: 100 - minLength: 1 - is_active: - title: Is active - type: boolean - course: - title: Course - type: string - readOnly: true - certificate: - title: Certificate - type: string - readOnly: true - course_modes: - title: Course modes - type: string - readOnly: true Notification: required: - app_name diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index d45d0ae017bd..9f6540651eeb 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -41,12 +41,20 @@ def ace_email_sent_handler(sender, **kwargs): except user_model.DoesNotExist: user_id = None course_email = message.context.get('course_email', None) - course_id = course_email.course_id if course_email else None + course_id = message.context.get('course_id') + if not course_id: + course_id = course_email.course_id if course_email else None + try: + channel = sender.__class__.__name__ + except AttributeError: + channel = 'Other' tracker.emit( - 'edx.bulk_email.sent', + 'edx.ace.message_sent', { 'message_type': message.name, + 'channel': channel, 'course_id': course_id, 'user_id': user_id, + 'user_email': message.recipient.email_address, } ) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 60d45c15642d..2b96af786a97 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -472,7 +472,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas 'edx.bulk_email.created', { 'course_id': str(course_email.course_id), - 'to_list': to_list, + 'to_list': [user_obj.get('email', '') for user_obj in to_list], 'total_recipients': total_recipients, } ) @@ -533,7 +533,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas email_context['email'] = email email_context['name'] = profile_name email_context['user_id'] = user_id - email_context['course_id'] = course_email.course_id + email_context['course_id'] = str(course_email.course_id) email_context['unsubscribe_link'] = get_unsubscribed_link(current_recipient['username'], str(course_email.course_id)) diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py index 927699091558..f63010d13a90 100644 --- a/lms/djangoapps/bulk_email/views.py +++ b/lms/djangoapps/bulk_email/views.py @@ -61,12 +61,17 @@ def opt_out_email_updates(request, token, course_id): course_id, ) - tracker.emit( - 'edx.bulk_email.opt_out', - { - 'course_id': course_id, - 'user_id': user.id, - } - ) + event_name = 'edx.bulk_email.opt_out' + event_data = { + "username": user.username, + "user_email": user.email, + "user_id": user.id, + "course_id": course_id, + } + with tracker.get_tracker().context(event_name, event_data): + tracker.emit( + event_name, + event_data + ) return render_to_response('bulk_email/unsubscribe_success.html', context) diff --git a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl index beef611e4393..d7ca8fd9a400 100644 --- a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl +++ b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl @@ -31,7 +31,7 @@ workspace { } grades_app -> signal_handlers "Emits COURSE_GRADE_NOW_PASSED signal" - verify_student_app -> signal_handlers "Emits LEARNER_NOW_VERIFIED signal" + verify_student_app -> signal_handlers "Emits IDV_ATTEMPT_APPROVED signal" student_app -> signal_handlers "Emits ENROLLMENT_TRACK_UPDATED signal" allowlist -> signal_handlers "Emits APPEND_CERTIFICATE_ALLOWLIST signal" signal_handlers -> generation_handler "Invokes generate_allowlist_certificate()" diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index d8db7bbf9ce8..53055bf9c86e 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -32,9 +32,8 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_FAILED, COURSE_GRADE_NOW_PASSED, - LEARNER_NOW_VERIFIED ) -from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, IDV_ATTEMPT_APPROVED User = get_user_model() @@ -118,14 +117,17 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade') -@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed") -def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylint: disable=unused-argument +@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="learner_track_changed") +def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pylint: disable=unused-argument """ Listen for a signal indicating that the user's id verification status has changed. """ if not auto_certificate_generation_enabled(): return + event_data = kwargs.get('idv_attempt') + user = User.objects.get(id=event_data.user.id) + user_enrollments = CourseEnrollment.enrollments_for_user(user=user) expected_verification_status = IDVerificationService.user_status(user) expected_verification_status = expected_verification_status['status'] diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index d475cffbfb66..7b5552801349 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -13,22 +13,20 @@ from openedx_events.data import EventsMetadata from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from openedx_events.tests.utils import OpenEdxEventsTestMixin from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.certificates.api import has_self_generated_certificates_enabled from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - GeneratedCertificate -) +from lms.djangoapps.certificates.models import CertificateGenerationConfiguration, GeneratedCertificate from lms.djangoapps.certificates.signals import handle_exam_attempt_rejected_event from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.tests.utils import mock_passing_grade from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class SelfGeneratedCertsSignalTest(ModuleStoreTestCase): @@ -302,10 +300,17 @@ def test_failing_grade_allowlist(self): assert cert.status == CertificateStatuses.downloadable -class LearnerIdVerificationTest(ModuleStoreTestCase): +class LearnerIdVerificationTest(ModuleStoreTestCase, OpenEdxEventsTestMixin): """ Tests for certificate generation task firing on learner id verification """ + ENABLED_OPENEDX_EVENTS = ['org.openedx.learning.idv_attempt.approved.v1'] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.start_events_isolation() + def setUp(self): super().setUp() self.course_one = CourseFactory.create(self_paced=True) diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index a37cd2ab36b2..82ed8c483024 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -27,6 +27,7 @@ should_redirect_to_commerce_coordinator_checkout, should_redirect_to_commerce_coordinator_refunds, ) +from edx_django_utils.plugins import pluggable_override log = logging.getLogger(__name__) @@ -56,6 +57,7 @@ def ecommerce_url_root(self): """ Retrieve Ecommerce service public url root. """ return configuration_helpers.get_value('ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT) + @pluggable_override('OVERRIDE_GET_ABSOLUTE_ECOMMERCE_URL') def get_absolute_ecommerce_url(self, ecommerce_page_url): """ Return the absolute URL to the ecommerce page. @@ -110,7 +112,6 @@ def payment_page_url(self): def get_add_to_basket_url(self): """ Return the URL for the payment page based on the waffle switch. - Example: http://localhost/enabled_service_api_path """ @@ -118,12 +119,14 @@ def get_add_to_basket_url(self): return urljoin(settings.COMMERCE_COORDINATOR_URL_ROOT, settings.COORDINATOR_CHECKOUT_REDIRECT_PATH) return self.payment_page_url() + @pluggable_override('OVERRIDE_GET_CHECKOUT_PAGE_URL') def get_checkout_page_url(self, *skus, **kwargs): """ Construct the URL to the ecommerce checkout page and include products. Args: skus (list): List of SKUs associated with products to be added to basket program_uuid (string): The UUID of the program, if applicable + course_run_keys (list): The course run keys of the products to be added to basket. Returns: Absolute path to the ecommerce checkout page showing basket that contains specified products. @@ -153,10 +156,12 @@ def upgrade_url(self, user, course_key): """ Returns the URL for the user to upgrade, or None if not applicable. """ + course_run_key = str(course_key) + verified_mode = CourseMode.verified_mode_for_course(course_key) if verified_mode: if self.is_enabled(user): - return self.get_checkout_page_url(verified_mode.sku) + return self.get_checkout_page_url(verified_mode.sku, course_run_keys=[course_run_key]) else: return reverse('dashboard') return None diff --git a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py index be344dcb0d9b..0f8227e3201a 100644 --- a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py @@ -75,6 +75,7 @@ def send_ace_message(goal): 'email': user.email, 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'course_name': course_name, + 'course_id': str(goal.course_key), 'days_per_week': goal.days_per_week, 'course_url': course_home_url, 'goals_unsubscribe_url': goals_unsubscribe_url, diff --git a/lms/djangoapps/course_home_api/course_metadata/serializers.py b/lms/djangoapps/course_home_api/course_metadata/serializers.py index 7683a9089453..29b92fc7b004 100644 --- a/lms/djangoapps/course_home_api/course_metadata/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/serializers.py @@ -43,6 +43,7 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer): """ celebrations = serializers.DictField() course_access = serializers.DictField() + studio_access = serializers.BooleanField() course_id = serializers.CharField() is_enrolled = serializers.BooleanField() is_self_paced = serializers.BooleanField() diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py index 248f90389d40..02c30ff62e91 100644 --- a/lms/djangoapps/course_home_api/course_metadata/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/views.py @@ -20,7 +20,7 @@ from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.course_home_api.course_metadata.serializers import CourseHomeMetadataSerializer -from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.access import has_access, has_cms_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import check_course_access from lms.djangoapps.courseware.masquerade import setup_masquerade @@ -124,6 +124,7 @@ def get(self, request, *args, **kwargs): data = { 'course_id': course.id, 'username': username, + 'studio_access': has_cms_access(request.user, course_key), 'is_staff': has_access(request.user, 'staff', course_key).has_access, 'original_user_is_staff': original_user_is_staff, 'number': course.display_number_with_default, diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 74f1d74f837f..436cb3514a54 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -53,7 +53,8 @@ GlobalStaff, OrgInstructorRole, OrgStaffRole, - SupportStaffRole + SupportStaffRole, + CourseLimitedStaffRole, ) from common.djangoapps.util import milestones_helpers as milestones_helpers # lint-amnesty, pylint: disable=useless-import-alias from common.djangoapps.util.milestones_helpers import ( @@ -97,6 +98,31 @@ def has_ccx_coach_role(user, course_key): return False +def has_cms_access(user, course_key): + """ + Check if user has access to the CMS. When requesting from the LMS, a user with the + limited staff access role needs access to the CMS APIs, but not the CMS site. This + function accounts for this edge case when determining if a user has access to the CMS + site. + + Arguments: + user (User): the user whose course access we are checking. + course_key: Key to course. + + Returns: + bool: whether user has access to the CMS site. + """ + has_course_author_access = auth.has_course_author_access(user, course_key) + is_limited_staff = auth.user_has_role( + user, CourseLimitedStaffRole(course_key) + ) and not GlobalStaff().has_user(user) + + if is_limited_staff and has_course_author_access: + return False + + return has_course_author_access + + @function_trace('has_access') def has_access(user, action, obj, course_key=None): """ diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index d53d620d3e34..bd0c1854ab76 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -156,7 +156,10 @@ def test_pre_requisite_course(self): assert resp.status_code == 200 pre_requisite_courses = get_prerequisite_courses_display(course) pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])]) - assert '{}'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long + assert ( + f'You must successfully complete ' + f'{pre_requisite_courses[0]["display"]} before you begin this course.' + ) in resp.content.decode(resp.charset).strip('\n') @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True}) def test_about_page_unfulfilled_prereqs(self): @@ -190,7 +193,10 @@ def test_about_page_unfulfilled_prereqs(self): assert resp.status_code == 200 pre_requisite_courses = get_prerequisite_courses_display(course) pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])]) - assert '{}'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long + assert ( + f'You must successfully complete ' + f'{pre_requisite_courses[0]["display"]} before you begin this course.' + ) in resp.content.decode(resp.charset).strip('\n') url = reverse('about_course', args=[str(pre_requisite_course.id)]) resp = self.client.get(url) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 95af0cf75f06..79a52db8a0b6 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -825,9 +825,13 @@ def course_about(request, course_id): # pylint: disable=too-many-statements single_paid_mode = modes.get(CourseMode.PROFESSIONAL) if single_paid_mode and single_paid_mode.sku: - ecommerce_checkout_link = ecomm_service.get_checkout_page_url(single_paid_mode.sku) + ecommerce_checkout_link = ecomm_service.get_checkout_page_url( + single_paid_mode.sku, course_run_keys=[course_id] + ) if single_paid_mode and single_paid_mode.bulk_sku: - ecommerce_bulk_checkout_link = ecomm_service.get_checkout_page_url(single_paid_mode.bulk_sku) + ecommerce_bulk_checkout_link = ecomm_service.get_checkout_page_url( + single_paid_mode.bulk_sku, course_run_keys=[course_id] + ) registration_price, course_price = get_course_prices(course) # lint-amnesty, pylint: disable=unused-variable diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index 96e392c35d2a..25abcf80d486 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -286,13 +286,19 @@ def send_response_endorsed_on_thread_notification(self): response on his thread has been endorsed """ if self.creator.id != int(self.thread.user_id): - self._send_notification([self.thread.user_id], "response_endorsed_on_thread") + context = { + "email_content": clean_thread_html_body(self.comment.body) + } + self._send_notification([self.thread.user_id], "response_endorsed_on_thread", extra_context=context) def send_response_endorsed_notification(self): """ Sends a notification to the author of the response """ - self._send_notification([self.creator.id], "response_endorsed") + context = { + "email_content": clean_thread_html_body(self.comment.body) + } + self._send_notification([self.creator.id], "response_endorsed", extra_context=context) def send_new_thread_created_notification(self): """ diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index a87fafd4ca54..cbf438988975 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -64,7 +64,7 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, creator = User.objects.get(id=response.user_id) endorser = User.objects.get(id=endorsed_by) course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, creator) + notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id) # skip sending notification to author of thread if they are the same as the author of the response if response.user_id != thread.user_id: # sends notification to author of thread diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index 8efd5cd49cbd..ddfc120a8e4b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -663,6 +663,7 @@ def test_response_endorsed_notifications(self): 'post_title': 'test thread', 'course_name': self.course.display_name, 'sender_id': int(self.user_2.id), + 'email_content': 'dummy' } self.assertDictEqual(notification_data.context, expected_context) self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) @@ -680,6 +681,7 @@ def test_response_endorsed_notifications(self): 'post_title': 'test thread', 'course_name': self.course.display_name, 'sender_id': int(response.user_id), + 'email_content': 'dummy' } self.assertDictEqual(notification_data.context, expected_context) self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) diff --git a/lms/djangoapps/grades/exceptions.py b/lms/djangoapps/grades/exceptions.py index d615f1f64d5c..db2793efaa15 100644 --- a/lms/djangoapps/grades/exceptions.py +++ b/lms/djangoapps/grades/exceptions.py @@ -3,9 +3,9 @@ """ -class DatabaseNotReadyError(IOError): +class ScoreNotFoundError(IOError): """ - Subclass of IOError to indicate the database has not yet committed - the data we're trying to find. + Subclass of IOError to indicate the staff has not yet graded the problem or + the database has not yet committed the data we're trying to find. """ pass # lint-amnesty, pylint: disable=unnecessary-pass diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index 3b504e61ebe8..9ec237274b4f 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -33,7 +33,7 @@ from .config.waffle import DISABLE_REGRADE_ON_POLICY_CHANGE from .constants import ScoreDatabaseTableEnum from .course_grade_factory import CourseGradeFactory -from .exceptions import DatabaseNotReadyError +from .exceptions import ScoreNotFoundError from .grade_utils import are_grades_frozen from .signals.signals import SUBSECTION_SCORE_CHANGED from .subsection_grade_factory import SubsectionGradeFactory @@ -45,7 +45,7 @@ KNOWN_RETRY_ERRORS = ( # Errors we expect occasionally, should be resolved on retry DatabaseError, ValidationError, - DatabaseNotReadyError, + ScoreNotFoundError, UsageKeyNotInBlockStructure, ) RECALCULATE_GRADE_DELAY_SECONDS = 2 # to prevent excessive _has_db_updated failures. See TNL-6424. @@ -239,7 +239,7 @@ def _recalculate_subsection_grade(self, **kwargs): has_database_updated = _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs) if not has_database_updated: - raise DatabaseNotReadyError + raise ScoreNotFoundError _update_subsection_grades( course_key, diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index b0e533ee6f7f..e8bcc81318da 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -47,6 +47,7 @@ UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ENROLLED, UNENROLLED_TO_UNENROLLED, + CourseAccessRole, CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, @@ -60,12 +61,14 @@ CourseFinanceAdminRole, CourseInstructorRole, ) -from common.djangoapps.student.tests.factories import BetaTesterFactory -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory -from common.djangoapps.student.tests.factories import GlobalStaffFactory -from common.djangoapps.student.tests.factories import InstructorFactory -from common.djangoapps.student.tests.factories import StaffFactory -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import ( + BetaTesterFactory, + CourseEnrollmentFactory, + GlobalStaffFactory, + InstructorFactory, + StaffFactory, + UserFactory +) from lms.djangoapps.bulk_email.models import BulkEmailFlag, CourseEmail, CourseEmailTemplate from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import ( @@ -94,6 +97,9 @@ from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api +from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter +from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.user_api.preferences.api import delete_user_preference @@ -3515,6 +3521,14 @@ def test_send_email_but_not_logged_in(self): self.client.logout() url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, self.full_test_message) + assert response.status_code == 401 + + def test_send_email_logged_in_but_no_perms(self): + self.client.logout() + user = UserFactory() + self.client.login(username=user.username, password=self.TEST_PASSWORD) + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, self.full_test_message) assert response.status_code == 403 def test_send_email_but_not_staff(self): @@ -3635,6 +3649,7 @@ def test_send_email_with_lapsed_date_expect_error(self): url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) with LogCapture() as log: + response = self.client.post(url, self.full_test_message) assert response.status_code == 400 @@ -4145,6 +4160,21 @@ def test_change_due_date(self): # This operation regenerates the cache, so we can use cached results from edx-when. assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_change_due_date_with_reason(self): + url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) + due_date = datetime.datetime(2013, 12, 30, tzinfo=UTC) + response = self.client.post(url, { + 'student': self.user1.username, + 'url': str(self.week1.location), + 'due_datetime': '12/30/2013 00:00', + 'reason': 'Testing reason.' # this is optional field. + }) + assert response.status_code == 200, response.content + + assert get_extended_due(self.course, self.week1, self.user1) == due_date + # This operation regenerates the cache, so we can use cached results from edx-when. + assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_change_to_invalid_due_date(self): url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -4666,3 +4696,127 @@ def test_get_certificate_for_user_no_certificate(self): f"The student {self.user} does not have certificate for the course {self.course.id.course}. Kindly " "verify student username/email and the selected course are correct and try again." ) + + +@patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) +class TestOauthInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test endpoints using Oauth2 authentication. + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create( + org='test_org', + course='test_course', + run='test_run', + entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') + ) + self.problem_location = msk_from_problem_urlname( + self.course.id, + 'robot-some-problem-urlname' + ) + self.problem_urlname = str(self.problem_location) + + self.other_user = UserFactory() + dot_application = ApplicationFactory(user=self.other_user, authorization_grant_type='password') + access_token = AccessTokenFactory(user=self.other_user, application=dot_application) + oauth_adapter = DOTAdapter() + token_dict = { + 'access_token': access_token, + 'scope': 'email profile', + } + jwt_token = jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=True) + + self.headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + jwt_token + } + + # endpoints contains all urls with body and role. + self.endpoints = [ + ('list_course_role_members', {'rolename': 'staff'}, 'instructor'), + ('register_and_enroll_students', {}, 'staff'), + ('get_student_progress_url', {'course_id': str(self.course.id), + 'unique_student_identifier': self.other_user.email + }, 'staff' + ), + ('list_entrance_exam_instructor_tasks', {'unique_student_identifier': self.other_user.email}, 'staff'), + ('list_email_content', {}, 'staff'), + ('show_student_extensions', {'student': self.other_user.email}, 'staff'), + ('list_email_content', {}, 'staff'), + ('list_report_downloads', { + "send-to": ["myself"], + "subject": "This is subject", + "message": "message" + }, 'data_researcher'), + ('list_instructor_tasks', + { + 'problem_location_str': self.problem_urlname, + 'unique_student_identifier': self.other_user.email + }, + 'data_researcher'), + ('list_instructor_tasks', {}, 'data_researcher') + ] + + self.fake_jwt = ('wyJUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFuZ2UtbWUiLCJleHAiOjE3MjU4OTA2NzIsImdyY' + 'W50X3R5cGUiOiJwYXNzd29yZCIsImlhdCI6MTcyNTg4NzA3MiwiaXNzIjoiaHR0cDovLzEyNy4wLjAuMTo4MDAwL29h' + 'XNlcl9pZCI6MX0' + '.ec8neWp1YAuF40ye4oeK40obaapUvjfNPUQCycrsajwvcu58KcuLc96sf0JKmMMMn7DH9N98hg8W38iwbhKif1kLsCKr' + 'tStl1u2XGvFkyMov8TtespbHit5LYRZpJwrhC1h50ru2buYj3isWrAElGPIDyAj0FAvSJnvJhWSMDtIwB2gxZI1DqOm' + 'M6mzT7JbOU4QH2PNZrb2EZ11F6k9I-HrHnLQymr4s0vyjMlcBWllW3y19futNCgsFFRMXI4Z9zIbspsy5bq_Skub' + 'dBpnl0P9x8vUJCAbFnJABAVPtF7F7nNsROQMKsZtQxaUUwdcYZi5qKL2GcgGfO0eTL4IbJA') + + def assert_all_end_points(self, endpoint, body, role, add_role, use_jwt=True): + """ + Util method for verifying different end-points. + """ + if add_role: + role, _ = CourseAccessRole.objects.get_or_create( + course_id=self.course.id, + user=self.other_user, + role=role, + org=self.course.id.org + ) + + if use_jwt: + headers = self.headers + else: + headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + self.fake_jwt # this is fake jwt. + } + + url = reverse(endpoint, kwargs={'course_id': str(self.course.id)}) + response = self.client.post( + url, + data=body, + **headers + ) + return response + + def run_endpoint_tests(self, expected_status, add_role, use_jwt): + """ + Util method for running different end-points. + """ + for endpoint, body, role in self.endpoints: + with self.subTest(endpoint=endpoint, role=role, body=body): + response = self.assert_all_end_points(endpoint, body, role, add_role, use_jwt) + # JWT authentication works but it has no permissions. + assert response.status_code == expected_status, f"Failed for endpoint: {endpoint}" + + def test_end_points_with_oauth_without_jwt(self): + """ + Verify the endpoint using invalid JWT returns 401. + """ + self.run_endpoint_tests(expected_status=401, add_role=False, use_jwt=False) + + def test_end_points_with_oauth_without_permissions(self): + """ + Verify the endpoint using JWT authentication. But has no permissions. + """ + self.run_endpoint_tests(expected_status=403, add_role=False, use_jwt=True) + + def test_end_points_with_oauth_with_permissions(self): + """ + Verify the endpoint using JWT authentication with permissions. + """ + self.run_endpoint_tests(expected_status=200, add_role=True, use_jwt=True) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index d1883eb1d852..8a956c477b77 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -37,6 +37,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name +from rest_framework.exceptions import MethodNotAllowed from rest_framework import serializers, status # lint-amnesty, pylint: disable=wrong-import-order from rest_framework.permissions import IsAdminUser, IsAuthenticated # lint-amnesty, pylint: disable=wrong-import-order from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order @@ -106,7 +107,8 @@ from lms.djangoapps.instructor_task.data import InstructorTaskTypes from lms.djangoapps.instructor_task.models import ReportStore from lms.djangoapps.instructor.views.serializer import ( - AccessSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, BlockDueDateSerializer + AccessSerializer, BlockDueDateSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, + SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer, BlockDueDateSerializer ) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted @@ -1510,28 +1512,38 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red return JsonResponse({"status": success_status}) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def get_students_who_may_enroll(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 GetStudentsWhoMayEnroll(DeveloperErrorViewMixin, APIView): """ Initiate generation of a CSV file containing information about - students who may enroll in a course. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - Responds with JSON - {"status": "... status message ..."} + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Initiate generation of a CSV file containing information about + students who may enroll in a course. - """ - course_key = CourseKey.from_string(course_id) - query_features = ['email'] - report_type = _('enrollment') - task_api.submit_calculate_may_enroll_csv(request, course_key, query_features) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + Responds with JSON + {"status": "... status message ..."} + """ + course_key = CourseKey.from_string(course_id) + query_features = ['email'] + report_type = _('enrollment') + try: + task_api.submit_calculate_may_enroll_csv(request, course_key, query_features) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + except Exception as e: + raise self.api_error(status.HTTP_400_BAD_REQUEST, str(e), 'Requested task is already running') - return JsonResponse({"status": success_status}) + return JsonResponse({"status": success_status}) + + def get(self, request, *args, **kwargs): + raise MethodNotAllowed('GET') def _cohorts_csv_validator(file_storage, file_to_validate): @@ -1663,18 +1675,31 @@ def get_proctored_exam_results(request, course_id): return JsonResponse({"status": success_status}) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -def get_anon_ids(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 GetAnonIds(APIView): """ - Respond with 2-column CSV output of user-id, anonymized-user-id + Respond with 2-column CSV output of user-id, anonymized-user-id. + This API processes the incoming request to generate a CSV file containing + two columns: `user-id` and `anonymized-user-id`. The CSV is returned as a + response to the client. """ - report_type = _('Anonymized User IDs') - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) - task_api.generate_anonymous_ids(request, course_id) - return JsonResponse({"status": success_status}) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Handle POST request to generate a CSV output. + + Returns: + Response: A CSV file with two columns: `user-id` and `anonymized-user-id`. + """ + report_type = _('Anonymized User IDs') + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + task_api.generate_anonymous_ids(request, course_id) + return JsonResponse({"status": success_status}) @require_POST @@ -1792,23 +1817,24 @@ def post(self, request, course_id): return Response(serializer.data) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GIVE_STUDENT_EXTENSION) -@require_post_params( - problem_to_reset="problem urlname to reset" -) -@common_exceptions_400 -def reset_student_attempts(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 ResetStudentAttempts(DeveloperErrorViewMixin, APIView): """ - Resets a students attempts counter or starts a task to reset all students attempts counters. Optionally deletes student state for a problem. Limited to staff access. Some sub-methods limited to instructor access. + """ + http_method_names = ['post'] + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = StudentAttemptsSerializer - Takes some of the following query parameters + @method_decorator(ensure_csrf_cookie) + @transaction.non_atomic_requests + def post(self, request, course_id): + """ + Takes some of the following query parameters - problem_to_reset is a urlname of a problem - unique_student_identifier is an email or username - all_students is a boolean @@ -1818,65 +1844,74 @@ def reset_student_attempts(request, course_id): - delete_module is a boolean requires instructor access mutually exclusive with all_students - """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'staff', course_id, depth=None - ) - all_students = _get_boolean_param(request, 'all_students') - - if all_students and not has_access(request.user, 'instructor', course): - return HttpResponseForbidden("Requires instructor access.") + """ + course_id = CourseKey.from_string(course_id) + serializer_data = self.serializer_class(data=request.data) - problem_to_reset = strip_if_string(request.POST.get('problem_to_reset')) - student_identifier = request.POST.get('unique_student_identifier', None) - student = None - if student_identifier is not None: - student = get_student_from_identifier(student_identifier) - delete_module = _get_boolean_param(request, 'delete_module') + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - # parameter combinations - if all_students and student: - return HttpResponseBadRequest( - "all_students and unique_student_identifier are mutually exclusive." - ) - if all_students and delete_module: - return HttpResponseBadRequest( - "all_students and delete_module are mutually exclusive." + course = get_course_with_access( + request.user, 'staff', course_id, depth=None ) - try: - module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id) - except InvalidKeyError: - return HttpResponseBadRequest() + all_students = serializer_data.validated_data.get('all_students') - response_payload = {} - response_payload['problem_to_reset'] = problem_to_reset + if all_students and not has_access(request.user, 'instructor', course): + return HttpResponseForbidden("Requires instructor access.") - if student: - try: - enrollment.reset_student_attempts( - course_id, - student, - module_state_key, - requesting_user=request.user, - delete_module=delete_module + problem_to_reset = strip_if_string(serializer_data.validated_data.get('problem_to_reset')) + student_identifier = request.POST.get('unique_student_identifier', None) + student = serializer_data.validated_data.get('unique_student_identifier') + delete_module = serializer_data.validated_data.get('delete_module') + + # parameter combinations + if all_students and student: + return HttpResponseBadRequest( + "all_students and unique_student_identifier are mutually exclusive." + ) + if all_students and delete_module: + return HttpResponseBadRequest( + "all_students and delete_module are mutually exclusive." ) - except StudentModule.DoesNotExist: - return HttpResponseBadRequest(_("Module does not exist.")) - except sub_api.SubmissionError: - # Trust the submissions API to log the error - error_msg = _("An error occurred while deleting the score.") - return HttpResponse(error_msg, status=500) - response_payload['student'] = student_identifier - elif all_students: - task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key) - response_payload['task'] = TASK_SUBMISSION_OK - response_payload['student'] = 'All Students' - else: - return HttpResponseBadRequest() - return JsonResponse(response_payload) + try: + module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id) + except InvalidKeyError: + return HttpResponseBadRequest() + + response_payload = {} + response_payload['problem_to_reset'] = problem_to_reset + + if student: + try: + enrollment.reset_student_attempts( + course_id, + student, + module_state_key, + requesting_user=request.user, + delete_module=delete_module + ) + except StudentModule.DoesNotExist: + return HttpResponseBadRequest(_("Module does not exist.")) + except sub_api.SubmissionError: + # Trust the submissions API to log the error + error_msg = _("An error occurred while deleting the score.") + return HttpResponse(error_msg, status=500) + response_payload['student'] = student_identifier + + elif all_students: + try: + task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key) + response_payload['task'] = TASK_SUBMISSION_OK + response_payload['student'] = 'All Students' + except Exception: # pylint: disable=broad-except + error_msg = _("An error occurred while attempting to reset for all students.") + return HttpResponse(error_msg, status=500) + else: + return HttpResponseBadRequest() + + return JsonResponse(response_payload) @transaction.non_atomic_requests @@ -1913,8 +1948,10 @@ def reset_student_attempts_for_entrance_exam(request, course_id): student_identifier = request.POST.get('unique_student_identifier', None) student = None + if student_identifier is not None: student = get_student_from_identifier(student_identifier) + all_students = _get_boolean_param(request, 'all_students') delete_module = _get_boolean_param(request, 'delete_module') @@ -2336,9 +2373,8 @@ def get(self, request, course_id): return _list_instructor_tasks(request=request, course_id=course_id) -@require_POST -@ensure_csrf_cookie -def list_instructor_tasks(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListInstructorTasks(APIView): """ List instructor tasks. @@ -2348,21 +2384,44 @@ def list_instructor_tasks(request, course_id): - `problem_location_str` and `unique_student_identifier` lists task history for problem AND student (intersection) """ - return _list_instructor_tasks(request=request, course_id=course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.SHOW_TASKS + serializer_class = ListInstructorTaskInputSerializer + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List instructor tasks. + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + return _list_instructor_tasks( + request=request, course_id=course_id, serialize_data=serializer.validated_data + ) @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_course_permission(permissions.SHOW_TASKS) -def _list_instructor_tasks(request, course_id): +def _list_instructor_tasks(request, course_id, serialize_data=None): """ List instructor tasks. Internal function with common code for both DRF and and tradition views. """ + # This method is also used by other APIs with the GET method. + # The query_params attribute is utilized for GET requests, + # where parameters are passed as query strings. + course_id = CourseKey.from_string(course_id) - params = getattr(request, 'query_params', request.POST) - problem_location_str = strip_if_string(params.get('problem_location_str', False)) - student = params.get('unique_student_identifier', None) + if serialize_data is not None: + problem_location_str = strip_if_string(serialize_data.get('problem_location_str', False)) + student = serialize_data.get('unique_student_identifier', None) + else: + params = getattr(request, 'query_params', request.POST) + problem_location_str = strip_if_string(params.get('problem_location_str', False)) + student = params.get('unique_student_identifier', None) + if student is not None: student = get_student_from_identifier(student) @@ -2516,16 +2575,22 @@ def get(self, request, course_id): return _list_report_downloads(request=request, course_id=course_id) -@require_POST -@ensure_csrf_cookie -def list_report_downloads(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListReportDownloads(APIView): + """ List grade CSV files that are available for download for this course. Takes the following query parameters: - (optional) report_name - name of the report """ - return _list_report_downloads(request=request, course_id=course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + + return _list_report_downloads(request=request, course_id=course_id) @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -2739,81 +2804,96 @@ def extract_user_info(user): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EMAIL) -@require_post_params(send_to="sending to whom", subject="subject line", message="message text") -@common_exceptions_400 -def send_email(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 SendEmail(DeveloperErrorViewMixin, APIView): """ Send an email to self, staff, cohorts, or everyone involved in a course. - Query Parameters: - - 'send_to' specifies what group the email should be sent to - Options are defined by the CourseEmail model in - lms/djangoapps/bulk_email/models.py - - 'subject' specifies email's subject - - 'message' specifies email's content """ - course_id = CourseKey.from_string(course_id) - course_overview = CourseOverview.get_from_id(course_id) + http_method_names = ['post'] + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EMAIL + serializer_class = SendEmailSerializer - if not is_bulk_email_feature_enabled(course_id): - log.warning(f"Email is not enabled for course {course_id}") - return HttpResponseForbidden("Email is not enabled for this course.") + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Query Parameters: + - 'send_to' specifies what group the email should be sent to + Options are defined by the CourseEmail model in + lms/djangoapps/bulk_email/models.py + - 'subject' specifies email's subject + - 'message' specifies email's content + """ + course_id = CourseKey.from_string(course_id) + course_overview = CourseOverview.get_from_id(course_id) - targets = json.loads(request.POST.get("send_to")) - subject = request.POST.get("subject") - message = request.POST.get("message") - # optional, this is a date and time in the form of an ISO8601 string - schedule = request.POST.get("schedule", "") + if not is_bulk_email_feature_enabled(course_id): + log.warning(f"Email is not enabled for course {course_id}") + return HttpResponseForbidden("Email is not enabled for this course.") - schedule_dt = None - if schedule: - try: - # convert the schedule from a string to a datetime, then check if its a valid future date and time, dateutil - # will throw a ValueError if the schedule is no good. - schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc) - if schedule_dt < datetime.datetime.now(pytz.utc): - raise ValueError("the requested schedule is in the past") - except ValueError as value_error: - error_message = ( - f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: " - f"{value_error}" - ) - log.error(error_message) - return HttpResponseBadRequest(error_message) + serializer_data = self.serializer_class(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - # Retrieve the customized email "from address" and email template from site configuration for the course/partner. If - # there is no site configuration enabled for the current site then we use system defaults for both. - from_addr = _get_branded_email_from_address(course_overview) - template_name = _get_branded_email_template(course_overview) + # Skipping serializer validation to avoid potential disruptions. + # The API handles numerous input variations, and changes here could introduce breaking issues. - # Create the CourseEmail object. This is saved immediately so that any transaction that has been pending up to this - # point will also be committed. - try: - email = create_course_email( - course_id, - request.user, - targets, - subject, - message, - template_name=template_name, - from_addr=from_addr, - ) - except ValueError as err: - return HttpResponseBadRequest(repr(err)) + targets = json.loads(request.POST.get("send_to")) - # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) - task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt) + subject = serializer_data.validated_data.get("subject") + message = serializer_data.validated_data.get("message") + # optional, this is a date and time in the form of an ISO8601 string + schedule = serializer_data.validated_data.get("schedule", "") - response_payload = { - 'course_id': str(course_id), - 'success': True, - } + schedule_dt = None + if schedule: + try: + # convert the schedule from a string to a datetime, then check if its a + # valid future date and time, dateutil + # will throw a ValueError if the schedule is no good. + schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc) + if schedule_dt < datetime.datetime.now(pytz.utc): + raise ValueError("the requested schedule is in the past") + except ValueError as value_error: + error_message = ( + f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: " + f"{value_error}" + ) + log.error(error_message) + return HttpResponseBadRequest(error_message) - return JsonResponse(response_payload) + # Retrieve the customized email "from address" and email template from site configuration for the c + # ourse/partner. + # If there is no site configuration enabled for the current site then we use system defaults for both. + from_addr = _get_branded_email_from_address(course_overview) + template_name = _get_branded_email_template(course_overview) + + # Create the CourseEmail object. This is saved immediately so that any transaction that has been + # pending up to this point will also be committed. + try: + email = create_course_email( + course_id, + request.user, + targets, + subject, + message, + template_name=template_name, + from_addr=from_addr, + ) + except ValueError as err: + return HttpResponseBadRequest(repr(err)) + + # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) + task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt) + + response_payload = { + 'course_id': str(course_id), + 'success': True, + } + + return JsonResponse(response_payload) @require_POST @@ -2909,28 +2989,50 @@ def _display_unit(unit): return str(unit.location) -@handle_dashboard_error -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GIVE_STUDENT_EXTENSION) -@require_post_params('student', 'url', 'due_datetime') -def change_due_date(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ChangeDueDate(APIView): """ Grants a due date extension to a student for a particular unit. """ - course = get_course_by_id(CourseKey.from_string(course_id)) - student = require_student_from_identifier(request.POST.get('student')) - unit = find_unit(course, request.POST.get('url')) - due_date = parse_datetime(request.POST.get('due_datetime')) - reason = strip_tags(request.POST.get('reason', '')) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = BlockDueDateSerializer + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Grants a due date extension to a student for a particular unit. + + params: + url (str): The URL related to the block that needs the due date update. + due_datetime (str): The new due date and time for the block. + student (str): The email or username of the student whose access is being modified. + """ + serializer_data = self.serializer_class(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - set_due_date_extension(course, unit, student, due_date, request.user, reason=reason) + student = serializer_data.validated_data.get('student') + if not student: + response_payload = { + 'error': f'Could not find student matching identifier: {request.data.get("student")}' + } + return JsonResponse(response_payload) + + course = get_course_by_id(CourseKey.from_string(course_id)) + + unit = find_unit(course, serializer_data.validated_data.get('url')) + due_date = parse_datetime(serializer_data.validated_data.get('due_datetime')) + reason = strip_tags(serializer_data.validated_data.get('reason', '')) + try: + set_due_date_extension(course, unit, student, due_date, request.user, reason=reason) + except Exception as error: # pylint: disable=broad-except + return JsonResponse({'error': str(error)}, status=400) - return JsonResponse(_( - 'Successfully changed due date for student {0} for {1} ' - 'to {2}').format(student.profile.name, _display_unit(unit), - due_date.strftime('%Y-%m-%d %H:%M'))) + return JsonResponse(_( + 'Successfully changed due date for student {0} for {1} ' + 'to {2}').format(student.profile.name, _display_unit(unit), + due_date.strftime('%Y-%m-%d %H:%M'))) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 841d03a736ec..9c0939a1c1b8 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -30,11 +30,11 @@ path('get_grading_config', api.get_grading_config, name='get_grading_config'), re_path(r'^get_students_features(?P/csv)?$', api.get_students_features, name='get_students_features'), path('get_issued_certificates/', api.get_issued_certificates, name='get_issued_certificates'), - path('get_students_who_may_enroll', api.get_students_who_may_enroll, name='get_students_who_may_enroll'), - path('get_anon_ids', api.get_anon_ids, name='get_anon_ids'), + path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), + path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"), path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'), - path('reset_student_attempts', api.reset_student_attempts, name='reset_student_attempts'), + path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'), path('rescore_problem', api.rescore_problem, name='rescore_problem'), path('override_problem_score', api.override_problem_score, name='override_problem_score'), path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam, @@ -44,14 +44,14 @@ name='list_entrance_exam_instructor_tasks'), path('mark_student_can_skip_entrance_exam', api.mark_student_can_skip_entrance_exam, name='mark_student_can_skip_entrance_exam'), - path('list_instructor_tasks', api.list_instructor_tasks, name='list_instructor_tasks'), + path('list_instructor_tasks', api.ListInstructorTasks.as_view(), name='list_instructor_tasks'), path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'), path('list_forum_members', api.list_forum_members, name='list_forum_members'), path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'), - path('send_email', api.send_email, name='send_email'), - path('change_due_date', api.change_due_date, name='change_due_date'), - path('reset_due_date', api.ResetDueDate.as_view(), name='reset_due_date'), + path('change_due_date', api.ChangeDueDate.as_view(), name='change_due_date'), + path('send_email', api.SendEmail.as_view(), name='send_email'), + path('reset_due_date', api.reset_due_date, name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), @@ -59,7 +59,7 @@ path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'), # Grade downloads... - path('list_report_downloads', api.list_report_downloads, name='list_report_downloads'), + path('list_report_downloads', api.ListReportDownloads.as_view(), name='list_report_downloads'), path('calculate_grades_csv', api.calculate_grades_csv, name='calculate_grades_csv'), path('problem_grade_report', api.problem_grade_report, name='problem_grade_report'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 54ced3d5720e..03df7500e55e 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -61,6 +61,43 @@ def validate_unique_student_identifier(self, value): return user +class ListInstructorTaskInputSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for handling the input data for the problem response report generation API. + +Attributes: + unique_student_identifier (str): The email or username of the student. + This field is optional, but if provided, the `problem_location_str` + must also be provided. + problem_location_str (str): The string representing the location of the problem within the course. + This field is optional, unless `unique_student_identifier` is provided. + """ + unique_student_identifier = serializers.CharField( + max_length=255, + help_text="Email or username of student", + required=False + ) + problem_location_str = serializers.CharField( + help_text="Problem location", + required=False + ) + + def validate(self, data): + """ + Validate the data to ensure that if unique_student_identifier is provided, + problem_location_str must also be provided. + """ + unique_student_identifier = data.get('unique_student_identifier') + problem_location_str = data.get('problem_location_str') + + if unique_student_identifier and not problem_location_str: + raise serializers.ValidationError( + "unique_student_identifier must accompany problem_location_str" + ) + + return data + + class ShowStudentExtensionSerializer(serializers.Serializer): """ Serializer for validating and processing the student identifier. @@ -79,6 +116,78 @@ def validate_student(self, value): return user +class StudentAttemptsSerializer(serializers.Serializer): + """ + Serializer for resetting a students attempts counter or starts a task to reset all students + attempts counters. + """ + problem_to_reset = serializers.CharField( + help_text="The identifier or description of the problem that needs to be reset." + ) + + # following are optional params. + unique_student_identifier = serializers.CharField( + help_text="Email or username of student.", required=False + ) + all_students = serializers.CharField(required=False) + delete_module = serializers.CharField(required=False) + + def validate_all_students(self, value): + """ + converts the all_student params value to bool. + """ + return self.verify_bool(value) + + def validate_delete_module(self, value): + """ + converts the all_student params value. + """ + return self.verify_bool(value) + + def validate_unique_student_identifier(self, value): + """ + Validate that the student corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user + + def verify_bool(self, value): + """ + Returns the value of the boolean parameter with the given + name in the POST request. Handles translation from string + values to boolean values. + """ + if value is not None: + return value in ['true', 'True', True] + + return False + + +class SendEmailSerializer(serializers.Serializer): + """ + Serializer for sending an email with optional scheduling. + + Fields: + send_to (str): The email address of the recipient. This field is required. + subject (str): The subject line of the email. This field is required. + message (str): The body of the email. This field is required. + schedule (str, optional): + An optional field to specify when the email should be sent. + If provided, this should be a string that can be parsed into a + datetime format or some other scheduling logic. + """ + send_to = serializers.CharField(write_only=True, required=True) + + # set max length as per model field. + subject = serializers.CharField(max_length=128, write_only=True, required=True) + message = serializers.CharField(required=True) + schedule = serializers.CharField(required=False) + + class BlockDueDateSerializer(serializers.Serializer): """ Serializer for handling block due date updates for a specific student. diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 8fa590c37f8c..1fb25aeb8c07 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -406,7 +406,7 @@ def test_query_counts(self): with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'): with check_mongo_calls(2): - with self.assertNumQueries(53): + with self.assertNumQueries(54): CourseGradeReport.generate(None, None, course.id, {}, 'graded') def test_inactive_enrollments(self): diff --git a/lms/djangoapps/learner_dashboard/config/waffle.py b/lms/djangoapps/learner_dashboard/config/waffle.py index 2195a2697269..cc63e8d5d13c 100644 --- a/lms/djangoapps/learner_dashboard/config/waffle.py +++ b/lms/djangoapps/learner_dashboard/config/waffle.py @@ -37,20 +37,3 @@ 'learner_dashboard.enable_masters_program_tab_view', __name__, ) - -# .. toggle_name: learner_dashboard.enable_b2c_subscriptions -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to enable new B2C Subscriptions Program data. -# This flag is used to decide whether we need to enable program subscription related properties in program listing -# and detail pages. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-04-13 -# .. toggle_target_removal_date: 2023-07-01 -# .. toggle_warning: When the flag is ON, the new B2C Subscriptions Program data will be enabled in program listing -# and detail pages. -# .. toggle_tickets: PON-79 -ENABLE_B2C_SUBSCRIPTIONS = WaffleFlag( - 'learner_dashboard.enable_b2c_subscriptions', - __name__, -) diff --git a/lms/djangoapps/learner_dashboard/programs.py b/lms/djangoapps/learner_dashboard/programs.py index d567a4b9a350..dc334c0ce34e 100644 --- a/lms/djangoapps/learner_dashboard/programs.py +++ b/lms/djangoapps/learner_dashboard/programs.py @@ -6,7 +6,6 @@ from abc import ABC, abstractmethod from urllib.parse import quote -from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.http import Http404 from django.template.loader import render_to_string @@ -18,7 +17,7 @@ from common.djangoapps.student.models import anonymous_id_for_user from common.djangoapps.student.roles import GlobalStaff -from lms.djangoapps.learner_dashboard.utils import b2c_subscriptions_enabled, program_tab_view_is_enabled +from lms.djangoapps.learner_dashboard.utils import program_tab_view_is_enabled from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.core.djangoapps.programs.models import ( @@ -32,9 +31,7 @@ get_industry_and_credit_pathways, get_program_and_course_data, get_program_marketing_url, - get_program_subscriptions_marketing_url, get_program_urls, - get_programs_subscription_data ) from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from openedx.core.djangolib.markup import HTML @@ -60,30 +57,12 @@ def render_to_fragment(self, request, **kwargs): raise Http404 meter = ProgramProgressMeter(request.site, user, mobile_only=mobile_only) - is_user_b2c_subscriptions_enabled = b2c_subscriptions_enabled(mobile_only) - programs_subscription_data = ( - get_programs_subscription_data(user) - if is_user_b2c_subscriptions_enabled - else [] - ) - subscription_upsell_data = ( - { - 'marketing_url': get_program_subscriptions_marketing_url(), - 'minimum_price': settings.SUBSCRIPTIONS_MINIMUM_PRICE, - 'trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH, - } - if is_user_b2c_subscriptions_enabled - else {} - ) context = { 'marketing_url': get_program_marketing_url(programs_config, mobile_only), 'programs': meter.engaged_programs, 'progress': meter.progress(), - 'programs_subscription_data': programs_subscription_data, - 'subscription_upsell_data': subscription_upsell_data, 'user_preferences': get_user_preferences(user), - 'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled, 'mobile_only': bool(mobile_only) } html = render_to_string('learner_dashboard/programs_fragment.html', context) @@ -137,12 +116,6 @@ def render_to_fragment(self, request, program_uuid, **kwargs): # lint-amnesty, program_discussion_lti = ProgramDiscussionLTI(program_uuid, request) program_live_lti = ProgramLiveLTI(program_uuid, request) - is_user_b2c_subscriptions_enabled = b2c_subscriptions_enabled(mobile_only) - program_subscription_data = ( - get_programs_subscription_data(user, program_uuid) - if is_user_b2c_subscriptions_enabled - else [] - ) def program_tab_view_enabled() -> bool: return program_tab_view_is_enabled() and ( @@ -156,14 +129,11 @@ def program_tab_view_enabled() -> bool: 'urls': urls, 'user_preferences': get_user_preferences(user), 'program_data': program_data, - 'program_subscription_data': program_subscription_data, 'course_data': course_data, 'certificate_data': certificate_data, 'industry_pathways': industry_pathways, 'credit_pathways': credit_pathways, 'program_tab_view_enabled': program_tab_view_enabled(), - 'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled, - 'subscriptions_trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH, 'discussion_fragment': { 'configured': program_discussion_lti.is_configured, 'iframe': program_discussion_lti.render_iframe() diff --git a/lms/djangoapps/learner_dashboard/utils.py b/lms/djangoapps/learner_dashboard/utils.py index a604ba73786a..5e9c172fcb78 100644 --- a/lms/djangoapps/learner_dashboard/utils.py +++ b/lms/djangoapps/learner_dashboard/utils.py @@ -7,7 +7,6 @@ from common.djangoapps.student.roles import GlobalStaff from lms.djangoapps.learner_dashboard.config.waffle import ( - ENABLE_B2C_SUBSCRIPTIONS, ENABLE_MASTERS_PROGRAM_TAB_VIEW, ENABLE_PROGRAM_TAB_VIEW ) @@ -50,19 +49,3 @@ def is_enrolled_or_staff(request, program_uuid): except ObjectDoesNotExist: return False return True - - -def b2c_subscriptions_is_enabled() -> bool: - """ - Check if B2C program subscriptions flag is enabled. - """ - return ENABLE_B2C_SUBSCRIPTIONS.is_enabled() - - -def b2c_subscriptions_enabled(is_mobile=False) -> bool: - """ - Check whether B2C Subscriptions pages should be shown to user. - """ - if not is_mobile and b2c_subscriptions_is_enabled(): - return True - return False diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index b3471715b9dc..3d156f3640ca 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -3,7 +3,7 @@ """ from datetime import date, timedelta -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin from django.conf import settings from django.urls import reverse @@ -132,7 +132,13 @@ def get_upgradeUrl(self, instance): ) if ecommerce_payment_page and verified_sku: - return f"{ecommerce_payment_page}?sku={verified_sku}" + query_params = { + 'sku': verified_sku, + 'course_run_key': str(instance.course_id) + } + encoded_params = urlencode(query_params) + upgrade_url = f"{ecommerce_payment_page}?{encoded_params}" + return upgrade_url def get_resumeUrl(self, instance): return self.context.get("resume_course_urls", {}).get(instance.course_id) diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py index c974fa0c8e5f..941dd60453d4 100644 --- a/lms/djangoapps/verify_student/api.py +++ b/lms/djangoapps/verify_student/api.py @@ -1,12 +1,31 @@ """ API module. """ +import logging + from django.conf import settings +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ +from datetime import datetime +from typing import Optional + from lms.djangoapps.verify_student.emails import send_verification_approved_email +from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus +from lms.djangoapps.verify_student.models import VerificationAttempt +from lms.djangoapps.verify_student.signals.signals import ( + emit_idv_attempt_approved_event, + emit_idv_attempt_created_event, + emit_idv_attempt_denied_event, + emit_idv_attempt_pending_event, +) +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from lms.djangoapps.verify_student.tasks import send_verification_status_email +log = logging.getLogger(__name__) + +User = get_user_model() + def send_approval_email(attempt): """ @@ -33,3 +52,116 @@ def send_approval_email(attempt): else: email_context = {'user': attempt.user, 'expiration_datetime': expiration_datetime.strftime("%m/%d/%Y")} send_verification_approved_email(context=email_context) + + +def create_verification_attempt(user: User, name: str, status: str, expiration_datetime: Optional[datetime] = None): + """ + Create a verification attempt. + + This method is intended to be used by IDV implementation plugins to create VerificationAttempt instances. + + Args: + user (User): the user (usually a learner) performing the verification attempt + name (string): the name being ID verified + status (string): the initial status of the verification attempt + expiration_datetime (datetime, optional): When the verification attempt expires. Defaults to None. + + Returns: + id (int): The id of the created VerificationAttempt instance + """ + verification_attempt = VerificationAttempt.objects.create( + user=user, + name=name, + status=status, + expiration_datetime=expiration_datetime, + ) + + emit_idv_attempt_created_event( + attempt_id=verification_attempt.id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + + return verification_attempt.id + + +def update_verification_attempt( + attempt_id: int, + name: Optional[str] = None, + status: Optional[str] = None, + expiration_datetime: Optional[datetime] = None, +): + """ + Update a verification attempt. + + This method is intended to be used by IDV implementation plugins to update VerificationAttempt instances. + + Arguments: + * attempt_id (int): the verification attempt id of the attempt to update + * name (string, optional): the new name being ID verified + * status (string, optional): the new status of the verification attempt + * expiration_datetime (datetime, optional): The new expiration date and time + + Returns: + * None + """ + try: + attempt = VerificationAttempt.objects.get(id=attempt_id) + except VerificationAttempt.DoesNotExist: + log.error( + f'VerificationAttempt with id {attempt_id} was not found ' + f'when updating the attempt to status={status}', + ) + raise + + if name is not None: + attempt.name = name + + if status is not None: + attempt.status = status + + status_list = list(VerificationAttemptStatus) + if status not in status_list: + log.error( + 'Attempted to call update_verification_attempt called with invalid status: %(status)s. ' + 'Status must be one of: %(status_list)s', + { + 'status': status, + 'status_list': VerificationAttempt.STATUS_CHOICES, + }, + ) + raise VerificationAttemptInvalidStatus + + # NOTE: Generally, we only set the expiration date from the time that an IDV attempt is marked approved, + # so we allow expiration_datetime to = None for other status updates (e.g. pending). + attempt.expiration_datetime = expiration_datetime + + attempt.save() + + user = attempt.user + if status == VerificationAttemptStatus.PENDING: + emit_idv_attempt_pending_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.APPROVED: + emit_idv_attempt_approved_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.DENIED: + emit_idv_attempt_denied_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) diff --git a/lms/djangoapps/verify_student/apps.py b/lms/djangoapps/verify_student/apps.py index f01bdef7e908..d553b9e0cf9a 100644 --- a/lms/djangoapps/verify_student/apps.py +++ b/lms/djangoapps/verify_student/apps.py @@ -17,5 +17,5 @@ def ready(self): """ Connect signal handlers. """ - from lms.djangoapps.verify_student import signals # pylint: disable=unused-import + from lms.djangoapps.verify_student.signals import signals # pylint: disable=unused-import from lms.djangoapps.verify_student import tasks # pylint: disable=unused-import diff --git a/lms/djangoapps/verify_student/exceptions.py b/lms/djangoapps/verify_student/exceptions.py index 59e7d5623f05..d13e52d3e737 100644 --- a/lms/djangoapps/verify_student/exceptions.py +++ b/lms/djangoapps/verify_student/exceptions.py @@ -5,3 +5,7 @@ class WindowExpiredException(Exception): pass + + +class VerificationAttemptInvalidStatus(Exception): + pass diff --git a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py index b87b2eee4559..3a08ede0aaf6 100644 --- a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py +++ b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py @@ -8,7 +8,6 @@ import time from pprint import pformat -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from lms.djangoapps.verify_student.api import send_approval_email diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py index 4a93aa19f169..891ff9fda5d8 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py @@ -54,7 +54,7 @@ def test_performance(self): #self.assertNumQueries(100) def test_signal_called(self): - with patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: call_command('backfill_sso_verifications_for_old_account_links', '--provider-slug', self.provider.provider_id) # lint-amnesty, pylint: disable=line-too-long assert mock_signal.call_count == 1 diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py b/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py index 1c3f22aa30cd..8fa84efe3a85 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py @@ -121,7 +121,7 @@ def _create_attempts(self, num_attempts): for _ in range(num_attempts): self.create_upload_and_submit_attempt_for_user() - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_resubmit_in_date_range(self, send_idv_update_mock): call_command('retry_failed_photo_verifications', status="submitted", diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py b/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py index 99fd4ecd3a5f..c9e98a94dec0 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py @@ -38,7 +38,7 @@ def _create_attempts(self, num_attempts): for _ in range(num_attempts): self.create_and_submit_attempt_for_user() - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_command(self, send_idv_update_mock): call_command('trigger_softwaresecurephotoverifications_post_save_signal', start_date_time='2021-10-31 06:00:00') diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 903d80bf9245..23729c99a0b9 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -42,8 +42,9 @@ rsa_decrypt, rsa_encrypt ) -from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED from openedx.core.storage import get_storage +from openedx_events.learning.signals import IDV_ATTEMPT_APPROVED +from openedx_events.learning.data import UserData, VerificationAttemptData from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss @@ -248,13 +249,23 @@ def send_approval_signal(self, approved_by='None'): user_id=self.user, reviewer=approved_by )) - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=SSOVerification, - user=self.user + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from SSOVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from SSOVerification' log.info(message.format(user=self.user.username)) @@ -451,13 +462,24 @@ def approve(self, user_id=None, service=""): days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] ) self.save() - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=PhotoVerification, - user=self.user + + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from PhotoVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from PhotoVerification' log.info(message.format(user=self.user.username)) @status_before_must_be("ready", "must_retry") @@ -1203,10 +1225,10 @@ class VerificationAttempt(TimeStampedModel): name = models.CharField(blank=True, max_length=255) STATUS_CHOICES = [ - VerificationAttemptStatus.created, - VerificationAttemptStatus.pending, - VerificationAttemptStatus.approved, - VerificationAttemptStatus.denied, + VerificationAttemptStatus.CREATED, + VerificationAttemptStatus.PENDING, + VerificationAttemptStatus.APPROVED, + VerificationAttemptStatus.DENIED, ] status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES]) @@ -1214,3 +1236,18 @@ class VerificationAttempt(TimeStampedModel): null=True, blank=True, ) + + @property + def updated_at(self): + """Backwards compatibility with existing IDVerification models""" + return self.modified + + @classmethod + def retire_user(cls, user_id): + """ + Retire user as part of GDPR pipeline + + :param user_id: int + """ + verification_attempts = cls.objects.filter(user_id=user_id) + verification_attempts.delete() diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index bdfa31fee6d6..f1c5543e8536 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -17,7 +17,7 @@ from lms.djangoapps.verify_student.utils import is_verification_expiring_soon from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt from .utils import most_recent_verification log = logging.getLogger(__name__) @@ -75,7 +75,8 @@ def verifications_for_user(cls, user): Return a list of all verifications associated with the given user. """ verifications = [] - for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), + for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created'), + SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), SSOVerification.objects.filter(user=user).order_by('-created_at'), ManualVerification.objects.filter(user=user).order_by('-created_at')): verifications.append(verification) @@ -92,6 +93,11 @@ def get_verified_user_ids(cls, users): 'created_at__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) } return chain( + VerificationAttempt.objects.filter(**{ + 'user__in': users, + 'status': 'approved', + 'created__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + }).values_list('user_id', flat=True), SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), SSOVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), ManualVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True) @@ -117,11 +123,14 @@ def get_expiration_datetime(cls, user, statuses): 'status__in': statuses, } + id_verifications = VerificationAttempt.objects.filter(**filter_kwargs) photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs) sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs) manual_id_verifications = ManualVerification.objects.filter(**filter_kwargs) - attempt = most_recent_verification((photo_id_verifications, sso_id_verifications, manual_id_verifications)) + attempt = most_recent_verification( + (photo_id_verifications, sso_id_verifications, manual_id_verifications, id_verifications) + ) return attempt and attempt.expiration_datetime @classmethod @@ -242,8 +251,18 @@ def get_verification_details_by_id(cls, attempt_id): """ Returns a verification attempt object by attempt_id If the verification object cannot be found, returns None + + This method does not take into account verifications stored in the + VerificationAttempt model used for pluggable IDV implementations. + + As part of the work to implement pluggable IDV, this method's use + will be deprecated: https://openedx.atlassian.net/browse/OSPR-1011 """ verification = None + + # This does not look at the VerificationAttempt model since the provided id would become + # ambiguous between tables. The verification models in this list all inherit from the same + # base class and share the same id space. verification_models = [ SoftwareSecurePhotoVerification, SSOVerification, diff --git a/lms/djangoapps/verify_student/signals/__init__.py b/lms/djangoapps/verify_student/signals/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/verify_student/signals.py b/lms/djangoapps/verify_student/signals/handlers.py similarity index 82% rename from lms/djangoapps/verify_student/signals.py rename to lms/djangoapps/verify_student/signals/handlers.py index d929af68dd06..8a1d7b542b00 100644 --- a/lms/djangoapps/verify_student/signals.py +++ b/lms/djangoapps/verify_student/signals/handlers.py @@ -5,23 +5,23 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models.signals import post_save -from django.dispatch import Signal from django.dispatch.dispatcher import receiver from xmodule.modulestore.django import SignalHandler, modulestore from common.djangoapps.student.models_api import get_name, get_pending_name_change -from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL +from lms.djangoapps.verify_student.apps import VerifyStudentConfig # pylint: disable=unused-import +from lms.djangoapps.verify_student.signals.signals import idv_update_signal +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC -from .models import SoftwareSecurePhotoVerification, VerificationDeadline +from lms.djangoapps.verify_student.models import ( + SoftwareSecurePhotoVerification, + VerificationDeadline, + VerificationAttempt +) log = logging.getLogger(__name__) -# Signal for emitting IDV submission and review updates -# providing_args = ["attempt_id", "user_id", "status", "full_name", "profile_name"] -idv_update_signal = Signal() - - @receiver(SignalHandler.course_published) def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument """ @@ -75,3 +75,9 @@ def send_idv_update(sender, instance, **kwargs): # pylint: disable=unused-argum photo_id_name=instance.name, full_name=full_name ) + + +@receiver(USER_RETIRE_LMS_MISC) +def _listen_for_lms_retire_verification_attempts(sender, **kwargs): # pylint: disable=unused-argument + user = kwargs.get('user') + VerificationAttempt.retire_user(user.id) diff --git a/lms/djangoapps/verify_student/signals/signals.py b/lms/djangoapps/verify_student/signals/signals.py new file mode 100644 index 000000000000..c03d5f263191 --- /dev/null +++ b/lms/djangoapps/verify_student/signals/signals.py @@ -0,0 +1,109 @@ +""" +Signal definitions and functions to send those signals for the verify_student application. +""" + +from django.dispatch import Signal + +from openedx_events.learning.data import UserData, UserPersonalData, VerificationAttemptData +from openedx_events.learning.signals import ( + IDV_ATTEMPT_CREATED, + IDV_ATTEMPT_PENDING, + IDV_ATTEMPT_APPROVED, + IDV_ATTEMPT_DENIED, +) + +# Signal for emitting IDV submission and review updates +# providing_args = ["attempt_id", "user_id", "status", "full_name", "profile_name"] +idv_update_signal = Signal() + + +def _create_user_data(user): + """ + Helper function to create a UserData object. + """ + user_data = UserData( + id=user.id, + is_active=user.is_active, + pii=UserPersonalData( + username=user.username, + email=user.email, + name=user.get_full_name() + ) + ) + + return user_data + + +def emit_idv_attempt_created_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_CREATED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_CREATED + IDV_ATTEMPT_CREATED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_pending_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_PENDING Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_PENDING + IDV_ATTEMPT_PENDING.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_approved_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_APPROVED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_APPROVED + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_denied_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_DENIED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_DENIED + IDV_ATTEMPT_DENIED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) diff --git a/lms/djangoapps/verify_student/statuses.py b/lms/djangoapps/verify_student/statuses.py index b55a9042e0f6..41ef381cfe06 100644 --- a/lms/djangoapps/verify_student/statuses.py +++ b/lms/djangoapps/verify_student/statuses.py @@ -1,21 +1,22 @@ """ Status enums for verify_student. """ +from enum import StrEnum, auto -class VerificationAttemptStatus: +class VerificationAttemptStatus(StrEnum): """This class describes valid statuses for a verification attempt to be in.""" # This is the initial state of a verification attempt, before a learner has started IDV. - created = "created" + CREATED = auto() # A verification attempt is pending when it has been started but has not yet been completed. - pending = "pending" + PENDING = auto() # A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual # review, etc). - approved = "approved" + APPROVED = auto() # A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review, # etc). - denied = "denied" + DENIED = auto() diff --git a/lms/djangoapps/verify_student/tests/factories.py b/lms/djangoapps/verify_student/tests/factories.py index da35e98cc53f..d7eaeaf30211 100644 --- a/lms/djangoapps/verify_student/tests/factories.py +++ b/lms/djangoapps/verify_student/tests/factories.py @@ -3,7 +3,7 @@ """ from factory.django import DjangoModelFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt class SoftwareSecurePhotoVerificationFactory(DjangoModelFactory): @@ -19,3 +19,8 @@ class Meta: class SSOVerificationFactory(DjangoModelFactory): class Meta(): model = SSOVerification + + +class VerificationAttemptFactory(DjangoModelFactory): + class Meta: + model = VerificationAttempt diff --git a/lms/djangoapps/verify_student/tests/test_api.py b/lms/djangoapps/verify_student/tests/test_api.py index acdebaa70c1c..2be7b6580905 100644 --- a/lms/djangoapps/verify_student/tests/test_api.py +++ b/lms/djangoapps/verify_student/tests/test_api.py @@ -3,14 +3,21 @@ """ from unittest.mock import patch +from datetime import datetime, timezone import ddt from django.conf import settings from django.core import mail from django.test import TestCase from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.api import send_approval_email -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.api import ( + create_verification_attempt, + send_approval_email, + update_verification_attempt, +) +from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationAttempt +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus @ddt.ddt @@ -18,6 +25,7 @@ class TestSendApprovalEmail(TestCase): """ Test cases for the send_approval_email API method. """ + def setUp(self): super().setUp() @@ -41,3 +49,183 @@ def test_send_approval(self, use_ace): with patch.dict(settings.VERIFY_STUDENT, {'USE_DJANGO_MAIL': use_ace}): send_approval_email(self.attempt) self._assert_verification_approved_email(self.attempt.expiration_datetime) + + +@ddt.ddt +class CreateVerificationAttempt(TestCase): + """ + Test cases for the create_verification_attempt API method. + """ + + def setUp(self): + super().setUp() + + self.user = UserFactory.create() + self.attempt = VerificationAttempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc) + ) + self.attempt.save() + + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_created_event') + def test_create_verification_attempt(self, mock_created_event): + expected_id = 2 + self.assertEqual( + create_verification_attempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc) + ), + expected_id + ) + verification_attempt = VerificationAttempt.objects.get(id=expected_id) + + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, 'Tester McTest') + self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED) + self.assertEqual(verification_attempt.expiration_datetime, datetime(2024, 12, 31, tzinfo=timezone.utc)) + mock_created_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=VerificationAttemptStatus.CREATED, + name='Tester McTest', + expiration_date=datetime(2024, 12, 31, tzinfo=timezone.utc), + ) + + def test_create_verification_attempt_no_expiration_datetime(self): + expected_id = 2 + self.assertEqual( + create_verification_attempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + ), + expected_id + ) + verification_attempt = VerificationAttempt.objects.get(id=expected_id) + + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, 'Tester McTest') + self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED) + self.assertEqual(verification_attempt.expiration_datetime, None) + + +@ddt.ddt +class UpdateVerificationAttempt(TestCase): + """ + Test cases for the update_verification_attempt API method. + """ + + def setUp(self): + super().setUp() + + self.user = UserFactory.create() + self.attempt = VerificationAttempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc) + ) + self.attempt.save() + + @ddt.data( + ('Tester McTest', VerificationAttemptStatus.PENDING, datetime(2024, 12, 31, tzinfo=timezone.utc)), + ('Tester McTest2', VerificationAttemptStatus.APPROVED, datetime(2025, 12, 31, tzinfo=timezone.utc)), + ('Tester McTest3', VerificationAttemptStatus.DENIED, datetime(2026, 12, 31, tzinfo=timezone.utc)), + ) + @ddt.unpack + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_pending_event') + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_approved_event') + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_denied_event') + def test_update_verification_attempt( + self, + name, + status, + expiration_datetime, + mock_denied_event, + mock_approved_event, + mock_pending_event, + ): + update_verification_attempt( + attempt_id=self.attempt.id, + name=name, + status=status, + expiration_datetime=expiration_datetime, + ) + + verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id) + + # Values should change as a result of this update. + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, name) + self.assertEqual(verification_attempt.status, status) + self.assertEqual(verification_attempt.expiration_datetime, expiration_datetime) + + if status == VerificationAttemptStatus.PENDING: + mock_pending_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.APPROVED: + mock_approved_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.DENIED: + mock_denied_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + + def test_update_verification_attempt_none_values(self): + update_verification_attempt( + attempt_id=self.attempt.id, + name=None, + status=None, + expiration_datetime=None, + ) + + verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id) + + # Values should not change as a result of the values passed in being None, except for expiration_datetime. + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, self.attempt.name) + self.assertEqual(verification_attempt.status, self.attempt.status) + self.assertEqual(verification_attempt.expiration_datetime, None) + + def test_update_verification_attempt_not_found(self): + self.assertRaises( + VerificationAttempt.DoesNotExist, + update_verification_attempt, + attempt_id=999999, + name=None, + status=VerificationAttemptStatus.APPROVED, + ) + + @ddt.data( + 'completed', + 'failed', + 'submitted', + 'expired', + ) + def test_update_verification_attempt_invalid(self, status): + self.assertRaises( + VerificationAttemptInvalidStatus, + update_verification_attempt, + attempt_id=self.attempt.id, + name=None, + status=status, + expiration_datetime=None, + ) diff --git a/lms/djangoapps/verify_student/tests/test_signals.py b/lms/djangoapps/verify_student/tests/test_handlers.py similarity index 72% rename from lms/djangoapps/verify_student/tests/test_signals.py rename to lms/djangoapps/verify_student/tests/test_handlers.py index fb32edeccde0..40d80712f19d 100644 --- a/lms/djangoapps/verify_student/tests/test_signals.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -10,17 +10,28 @@ from common.djangoapps.student.models_api import do_name_change_request from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline -from lms.djangoapps.verify_student.signals import _listen_for_course_publish, _listen_for_lms_retire -from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory +from lms.djangoapps.verify_student.models import ( + SoftwareSecurePhotoVerification, + VerificationDeadline, + VerificationAttempt +) +from lms.djangoapps.verify_student.signals.handlers import ( + _listen_for_course_publish, + _listen_for_lms_retire, + _listen_for_lms_retire_verification_attempts +) +from lms.djangoapps.verify_student.tests.factories import ( + SoftwareSecurePhotoVerificationFactory, + VerificationAttemptFactory +) from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -class VerificationDeadlineSignalTest(ModuleStoreTestCase): +class VerificationDeadlineHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline signal + Tests for the VerificationDeadline handler """ def setUp(self): @@ -30,13 +41,13 @@ def setUp(self): VerificationDeadline.objects.all().delete() def test_no_deadline(self): - """ Verify the signal sets deadline to course end when no deadline exists.""" + """ Verify the handler sets deadline to course end when no deadline exists.""" _listen_for_course_publish('store', self.course.id) assert VerificationDeadline.deadline_for_course(self.course.id) == self.course.end def test_deadline(self): - """ Verify deadline is set to course end date by signal when changed. """ + """ Verify deadline is set to course end date by handler when changed. """ deadline = now() - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline) @@ -44,7 +55,7 @@ def test_deadline(self): assert VerificationDeadline.deadline_for_course(self.course.id) == self.course.end def test_deadline_explicit(self): - """ Verify deadline is unchanged by signal when explicitly set. """ + """ Verify deadline is unchanged by handler when explicitly set. """ deadline = now() - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline, is_explicit=True) @@ -55,9 +66,9 @@ def test_deadline_explicit(self): assert actual_deadline == deadline -class RetirementSignalTest(ModuleStoreTestCase): +class RetirementHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline signal + Tests for the VerificationDeadline handler """ def _create_entry(self): @@ -108,8 +119,8 @@ def test_idempotent(self): class PostSavePhotoVerificationTest(ModuleStoreTestCase): """ - Tests for the post_save signal on the SoftwareSecurePhotoVerification model. - This receiver should emit another signal that contains limited data about + Tests for the post_save handler on the SoftwareSecurePhotoVerification model. + This receiver should emit another handler that contains limited data about the verification attempt that was updated. """ @@ -121,7 +132,7 @@ def setUp(self): self.photo_id_image_url = 'https://test.photo' self.photo_id_key = 'test+key' - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_post_save_signal(self, mock_signal): # create new softwaresecureverification attempt = SoftwareSecurePhotoVerification.objects.create( @@ -154,7 +165,7 @@ def test_post_save_signal(self, mock_signal): full_name=attempt.user.profile.name ) - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_post_save_signal_pending_name(self, mock_signal): pending_name_change = do_name_change_request(self.user, 'Pending Name', 'test')[0] @@ -174,3 +185,26 @@ def test_post_save_signal_pending_name(self, mock_signal): photo_id_name=attempt.name, full_name=pending_name_change.new_name ) + + +class RetirementHandlerVerificationAttemptsTest(ModuleStoreTestCase): + """ + Tests for the LMS User Retirement signal for Verification Attempts + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.other_user = UserFactory.create() + VerificationAttemptFactory.create(user=self.user) + VerificationAttemptFactory.create(user=self.other_user) + + def test_retirement_signal(self): + _listen_for_lms_retire_verification_attempts(sender=self.__class__, user=self.user) + self.assertEqual(len(VerificationAttempt.objects.filter(user=self.user)), 0) + self.assertEqual(len(VerificationAttempt.objects.filter(user=self.other_user)), 1) + + def test_retirement_signal_no_attempts(self): + no_attempt_user = UserFactory.create() + _listen_for_lms_retire_verification_attempts(sender=self.__class__, user=no_attempt_user) + self.assertEqual(len(VerificationAttempt.objects.all()), 2) diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 56f388b7c97e..5351e3ede699 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -2,8 +2,8 @@ Tests for the service classes in verify_student. """ -from datetime import datetime, timedelta, timezone import itertools +from datetime import datetime, timedelta, timezone from random import randint from unittest.mock import patch @@ -16,10 +16,16 @@ from pytz import utc from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import ( + ManualVerification, + SoftwareSecurePhotoVerification, + SSOVerification, + VerificationAttempt +) from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order FAKE_SETTINGS = { @@ -34,12 +40,15 @@ class TestIDVerificationService(ModuleStoreTestCase): Tests for IDVerificationService. """ - def test_user_is_verified(self): + @ddt.data( + SoftwareSecurePhotoVerification, VerificationAttempt + ) + def test_user_is_verified(self, verification_model): """ Test to make sure we correctly answer whether a user has been verified. """ user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) + attempt = verification_model(user=user) attempt.save() # If it's any of these, they're not verified... @@ -49,16 +58,24 @@ def test_user_is_verified(self): assert not IDVerificationService.user_is_verified(user), status attempt.status = "approved" + if verification_model == VerificationAttempt: + attempt.expiration_datetime = now() + timedelta(days=19) + else: + attempt.expiration_date = now() + timedelta(days=19) attempt.save() + assert IDVerificationService.user_is_verified(user), attempt.status - def test_user_has_valid_or_pending(self): + @ddt.data( + SoftwareSecurePhotoVerification, VerificationAttempt + ) + def test_user_has_valid_or_pending(self, verification_model): """ Determine whether we have to prompt this user to verify, or if they've already at least initiated a verification submission. """ user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) + attempt = verification_model(user=user) # If it's any of these statuses, they don't have anything outstanding for status in ["created", "ready", "denied"]: @@ -70,6 +87,10 @@ def test_user_has_valid_or_pending(self): # -- must_retry, and submitted both count until we hear otherwise for status in ["submitted", "must_retry", "approved"]: attempt.status = status + if verification_model == VerificationAttempt: + attempt.expiration_datetime = now() + timedelta(days=19) + else: + attempt.expiration_date = now() + timedelta(days=19) attempt.save() assert IDVerificationService.user_has_valid_or_pending(user), status @@ -102,18 +123,22 @@ def test_get_verified_user_ids(self): user_a = UserFactory.create() user_b = UserFactory.create() user_c = UserFactory.create() + user_d = UserFactory.create() user_unverified = UserFactory.create() user_denied = UserFactory.create() + user_denied_b = UserFactory.create() SoftwareSecurePhotoVerification.objects.create(user=user_a, status='approved') ManualVerification.objects.create(user=user_b, status='approved') SSOVerification.objects.create(user=user_c, status='approved') + VerificationAttempt.objects.create(user=user_d, status='approved') SSOVerification.objects.create(user=user_denied, status='denied') + VerificationAttempt.objects.create(user=user_denied_b, status='denied') verified_user_ids = set(IDVerificationService.get_verified_user_ids([ - user_a, user_b, user_c, user_unverified, user_denied + user_a, user_b, user_c, user_d, user_unverified, user_denied ])) - expected_user_ids = {user_a.id, user_b.id, user_c.id} + expected_user_ids = {user_a.id, user_b.id, user_c.id, user_d.id} assert expected_user_ids == verified_user_ids @@ -158,6 +183,23 @@ def test_get_expiration_datetime(self): expiration_datetime = IDVerificationService.get_expiration_datetime(user_a, ['approved']) assert expiration_datetime == newer_record.expiration_datetime + def test_get_expiration_datetime_mixed_models(self): + """ + Test that the latest expiration datetime is returned if there are both instances of + IDVerification models and VerificationAttempt models + """ + user = UserFactory.create() + + SoftwareSecurePhotoVerification.objects.create( + user=user, status='approved', expiration_date=datetime(2021, 11, 12, 0, 0, tzinfo=timezone.utc) + ) + newest = VerificationAttempt.objects.create( + user=user, status='approved', expiration_datetime=datetime(2022, 1, 12, 0, 0, tzinfo=timezone.utc) + ) + + expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved']) + assert expiration_datetime == newest.expiration_datetime + @ddt.data( {'status': 'denied', 'error_msg': '[{"generalReasons": ["Name mismatch"]}]'}, {'status': 'approved', 'error_msg': ''}, diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 8c1912d9c2f8..1b6a47bee879 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -503,7 +503,8 @@ def _redirect_if_necessary( if ecommerce_service.is_enabled(user): url = ecommerce_service.get_checkout_page_url( sku, - catalog=self.request.GET.get('catalog') + catalog=self.request.GET.get('catalog'), + course_run_keys=[str(course_key)] ) # Redirect if necessary, otherwise implicitly return None diff --git a/lms/envs/common.py b/lms/envs/common.py index 04a1753838ed..334669215397 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2295,7 +2295,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx.core.djangoapps.safe_sessions.middleware.EmailChangeMiddleware', 'common.djangoapps.student.middleware.UserStandingMiddleware', - 'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware', # Adds user tags to tracking events # Must go before TrackMiddleware, to get the context set up @@ -3687,6 +3686,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # because that decision might happen in a later config file. (The headers to # allow is an application logic, and not site policy.) CORS_ALLOW_HEADERS = corsheaders_default_headers + ( + 'cache-control', + 'expires', + 'pragma', 'use-jwt-cookie', ) @@ -4688,7 +4690,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'enterprise_channel_worker', 'enterprise_access_worker', 'enterprise_subsidy_worker', - 'subscriptions_worker' ] # Setting for Open API key and prompts used by edx-enterprise. @@ -5382,17 +5383,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring AVAILABLE_DISCUSSION_TOURS = [] -######################## Subscriptions API SETTINGS ######################## -SUBSCRIPTIONS_ROOT_URL = "" -SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" - -SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None -SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/" -SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None -SUBSCRIPTIONS_MINIMUM_PRICE = '$39' -SUBSCRIPTIONS_TRIAL_LENGTH = 7 -SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker' - ############## NOTIFICATIONS ############## NOTIFICATIONS_EXPIRY = 60 EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000 @@ -5475,6 +5465,10 @@ def _should_send_learning_badge_events(settings): 'learning-course-access-role-lifecycle': {'event_key_field': 'course_access_role_data.course_key', 'enabled': False}, }, + 'org.openedx.enterprise.learner_credit_course_enrollment.revoked.v1': { + 'learner-credit-course-enrollment-lifecycle': + {'event_key_field': 'learner_credit_course_enrollment.uuid', 'enabled': False}, + }, # CMS events. These have to be copied over here because cms.common adds some derived entries as well, # and the derivation fails if the keys are missing. If we ever fully decouple the lms and cms settings, # we can remove these. diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 611017962852..7a06f717996c 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -522,15 +522,9 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ] course_access_role_removed_event_setting['learning-course-access-role-lifecycle']['enabled'] = True -######################## Subscriptions API SETTINGS ######################## -SUBSCRIPTIONS_ROOT_URL = "http://host.docker.internal:18750" -SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" - -SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None -SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/" -SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None -SUBSCRIPTIONS_MINIMUM_PRICE = '$39' -SUBSCRIPTIONS_TRIAL_LENGTH = 7 +lc_enrollment_revoked_setting = \ + EVENT_BUS_PRODUCER_CONFIG['org.openedx.enterprise.learner_credit_course_enrollment.revoked.v1'] +lc_enrollment_revoked_setting['learner-credit-course-enrollment-lifecycle']['enabled'] = True # API access management API_ACCESS_MANAGER_EMAIL = 'api-access@example.com' diff --git a/lms/envs/test.py b/lms/envs/test.py index 3c4bb9564927..a9e8aaf9f2e2 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -650,15 +650,6 @@ SURVEY_REPORT_ENABLE = True ANONYMOUS_SURVEY_REPORT = False -######################## Subscriptions API SETTINGS ######################## -SUBSCRIPTIONS_ROOT_URL = "http://localhost:18750" -SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" - -SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None -SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/" -SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None -SUBSCRIPTIONS_MINIMUM_PRICE = '$39' -SUBSCRIPTIONS_TRIAL_LENGTH = 7 CSRF_TRUSTED_ORIGINS = ['.example.com'] CSRF_TRUSTED_ORIGINS_WITH_SCHEME = ['https://*.example.com'] diff --git a/lms/static/js/instructor_dashboard/instructor_dashboard.js b/lms/static/js/instructor_dashboard/instructor_dashboard.js index 02972a93b6c4..f87e9db8e814 100644 --- a/lms/static/js/instructor_dashboard/instructor_dashboard.js +++ b/lms/static/js/instructor_dashboard/instructor_dashboard.js @@ -50,6 +50,12 @@ such that the value can be defined later than this assignment (file load order). $activeSection = null; + var usesProctoringLegacyView = function () { + // If the element #proctoring-mfe-view is present, then uses the new MFE + // and the legacy views should not be initialized. + return !document.getElementById('proctoring-mfe-view'); + } + SafeWaiter = (function() { function safeWaiter() { this.after_handlers = []; @@ -200,7 +206,7 @@ such that the value can be defined later than this assignment (file load order). } ]; // eslint-disable-next-line no-void - if (edx.instructor_dashboard.proctoring !== void 0) { + if (usesProctoringLegacyView() && edx.instructor_dashboard.proctoring !== void 0) { sectionsToInitialize = sectionsToInitialize.concat([ { constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView, diff --git a/lms/static/js/learner_dashboard/models/program_subscription_model.js b/lms/static/js/learner_dashboard/models/program_subscription_model.js deleted file mode 100644 index 18f30031f7a5..000000000000 --- a/lms/static/js/learner_dashboard/models/program_subscription_model.js +++ /dev/null @@ -1,86 +0,0 @@ -import Backbone from 'backbone'; -import moment from 'moment'; - -import DateUtils from 'edx-ui-toolkit/js/utils/date-utils'; -import StringUtils from 'edx-ui-toolkit/js/utils/string-utils'; - - -/** - * Model for Program Subscription Data. - */ -class ProgramSubscriptionModel extends Backbone.Model { - constructor({ context }, ...args) { - const { - subscriptionData: [data = {}], - programData: { subscription_prices }, - urls = {}, - userPreferences = {}, - subscriptionsTrialLength: trialLength = 7, - } = context; - - const priceInUSD = subscription_prices?.find(({ currency }) => currency === 'USD'); - - const subscriptionState = data.subscription_state?.toLowerCase() ?? ''; - const subscriptionPrice = StringUtils.interpolate( - gettext('${price}/month {currency}'), - { - price: parseFloat(priceInUSD?.price), - currency: priceInUSD?.currency, - } - ); - - const subscriptionUrl = - subscriptionState === 'active' - ? urls.manage_subscription_url - : urls.buy_subscription_url; - - const hasActiveTrial = false; - - const remainingDays = 0; - - const [currentPeriodEnd] = ProgramSubscriptionModel.formatDate( - data.current_period_end, - userPreferences - ); - const [trialEndDate, trialEndTime] = ['', '']; - - super( - { - hasActiveTrial, - currentPeriodEnd, - remainingDays, - subscriptionPrice, - subscriptionState, - subscriptionUrl, - trialEndDate, - trialEndTime, - trialLength, - }, - ...args - ); - } - - static formatDate(date, userPreferences) { - if (!date) { - return ['', '']; - } - - const userTimezone = ( - userPreferences.time_zone || moment?.tz?.guess?.() || 'UTC' - ); - const userLanguage = userPreferences['pref-lang'] || 'en'; - const context = { - datetime: date, - timezone: userTimezone, - language: userLanguage, - format: DateUtils.dateFormatEnum.shortDate, - }; - - const localDate = DateUtils.localize(context); - const localTime = ''; - - return [localDate, localTime]; - } -} - -export default ProgramSubscriptionModel; diff --git a/lms/static/js/learner_dashboard/program_list_factory.js b/lms/static/js/learner_dashboard/program_list_factory.js index 54333066414a..b9ff1c40191a 100644 --- a/lms/static/js/learner_dashboard/program_list_factory.js +++ b/lms/static/js/learner_dashboard/program_list_factory.js @@ -11,58 +11,18 @@ import HeaderView from './views/program_list_header_view'; function ProgramListFactory(options) { const progressCollection = new ProgressCollection(); - const subscriptionCollection = new Backbone.Collection(); if (options.userProgress) { progressCollection.set(options.userProgress); options.progressCollection = progressCollection; // eslint-disable-line no-param-reassign } - if (options.programsSubscriptionData.length) { - subscriptionCollection.set(options.programsSubscriptionData); - options.subscriptionCollection = subscriptionCollection; // eslint-disable-line no-param-reassign - } - if (options.programsData.length) { if (!options.mobileOnly) { new HeaderView({ context: options, }).render(); } - - const activeSubscriptions = options.programsSubscriptionData - // eslint-disable-next-line camelcase - .filter(({ subscription_state }) => subscription_state === 'active') - .sort((a, b) => new Date(b.created) - new Date(a.created)); - - // Sort programs so programs with active subscriptions are at the top - if (activeSubscriptions.length) { - // eslint-disable-next-line no-param-reassign - options.programsData = options.programsData - .map((programsData) => ({ - ...programsData, - subscriptionIndex: activeSubscriptions.findIndex( - // eslint-disable-next-line camelcase - ({ resource_id }) => resource_id === programsData.uuid, - ), - })) - .sort(({ subscriptionIndex: indexA }, { subscriptionIndex: indexB }) => { - switch (true) { - case indexA === -1 && indexB === -1: - // Maintain the original order for non-subscription programs - return 0; - case indexA === -1: - // Move non-subscription program to the end - return 1; - case indexB === -1: - // Keep non-subscription program to the end - return -1; - default: - // Sort by subscriptionIndex in ascending order - return indexA - indexB; - } - }); - } } new CollectionListView({ diff --git a/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js b/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js index c9c1c4d97bf2..1cd490447b0d 100644 --- a/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/collection_list_view_spec.js @@ -1,7 +1,5 @@ /* globals setFixtures */ -import Backbone from 'backbone'; - import CollectionListView from '../views/collection_list_view'; import ProgramCardView from '../views/program_card_view'; import ProgramCollection from '../collections/program_collection'; @@ -11,7 +9,6 @@ describe('Collection List View', () => { let view = null; let programCollection; let progressCollection; - let subscriptionCollection; const context = { programsData: [ { @@ -101,21 +98,14 @@ describe('Collection List View', () => { not_started: 3, }, ], - programsSubscriptionData: [{ - resource_id: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8', - subscription_state: 'active', - }], - isUserB2CSubscriptionsEnabled: false, }; beforeEach(() => { setFixtures('
'); programCollection = new ProgramCollection(context.programsData); progressCollection = new ProgressCollection(); - subscriptionCollection = new Backbone.Collection(context.programsSubscriptionData); progressCollection.set(context.userProgress); context.progressCollection = progressCollection; - context.subscriptionCollection = subscriptionCollection; view = new CollectionListView({ el: '.program-cards-container', diff --git a/lms/static/js/learner_dashboard/spec/course_card_view_spec.js b/lms/static/js/learner_dashboard/spec/course_card_view_spec.js index 5a0f18162868..91439c4a87a2 100644 --- a/lms/static/js/learner_dashboard/spec/course_card_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/course_card_view_spec.js @@ -17,10 +17,8 @@ describe('Course Card View', () => { programData, collectionCourseStatus, courseData: {}, - subscriptionData: [], urls: {}, userPreferences: {}, - isSubscriptionEligible: false, }; if (typeof collectionCourseStatus === 'undefined') { diff --git a/lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js b/lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js deleted file mode 100644 index 501cb9000483..000000000000 --- a/lms/static/js/learner_dashboard/spec/program_alert_list_view_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -/* globals setFixtures */ - -import ProgramAlertListView from '../views/program_alert_list_view'; - -describe('Program Alert List View', () => { - let view = null; - const context = { - enrollmentAlerts: [{ title: 'Test Program' }], - trialEndingAlerts: [{ - title: 'Test Program', - hasActiveTrial: true, - currentPeriodEnd: 'May 8, 2023', - remainingDays: 2, - subscriptionPrice: '$100/month USD', - subscriptionState: 'active', - subscriptionUrl: null, - trialEndDate: 'Apr 20, 2023', - trialEndTime: '5:59 am', - trialLength: 7, - }], - pageType: 'programDetails', - }; - - beforeEach(() => { - setFixtures('
'); - view = new ProgramAlertListView({ - el: '.js-program-details-alerts', - context, - }); - view.render(); - }); - - afterEach(() => { - view.remove(); - }); - - it('should exist', () => { - expect(view).toBeDefined(); - }); - - it('should render no enrollement alert', () => { - expect(view.$('.alert:first .alert-heading').text().trim()).toEqual( - 'Enroll in a Test Program\'s course', - ); - expect(view.$('.alert:first .alert-message').text().trim()).toEqual( - 'You have an active subscription to the Test Program program but are not enrolled in any courses. Enroll in a remaining course and enjoy verified access.', - ); - }); - - it('should render subscription trial is expiring alert', () => { - expect(view.$('.alert:last .alert-heading').text().trim()).toEqual( - 'Subscription trial expires in 2 days', - ); - expect(view.$('.alert:last .alert-message').text().trim()).toEqual( - 'Your Test Program trial will expire in 2 days at 5:59 am on Apr 20, 2023 and the card on file will be charged $100/month USD.', - ); - }); -}); diff --git a/lms/static/js/learner_dashboard/spec/program_card_view_spec.js b/lms/static/js/learner_dashboard/spec/program_card_view_spec.js index 290db60a4d0a..bf8a718f0a67 100644 --- a/lms/static/js/learner_dashboard/spec/program_card_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_card_view_spec.js @@ -42,7 +42,6 @@ describe('Program card View', () => { name: 'Wageningen University & Research', }, ], - subscriptionIndex: 1, }; const userProgress = [ { @@ -58,11 +57,6 @@ describe('Program card View', () => { not_started: 3, }, ]; - // eslint-disable-next-line no-undef - const subscriptionCollection = new Backbone.Collection([{ - resource_id: 'a87e5eac-3c93-45a1-a8e1-4c79ca8401c8', - subscription_state: 'active', - }]); const progressCollection = new ProgressCollection(); const cardRenders = ($card) => { expect($card).toBeDefined(); @@ -80,8 +74,6 @@ describe('Program card View', () => { model: programModel, context: { progressCollection, - subscriptionCollection, - isUserB2CSubscriptionsEnabled: true, }, }); }); @@ -133,10 +125,6 @@ describe('Program card View', () => { view.remove(); view = new ProgramCardView({ model: programModel, - context: { - subscriptionCollection, - isUserB2CSubscriptionsEnabled: true, - }, }); cardRenders(view.$el); expect(view.$('.progress').length).toEqual(0); @@ -149,10 +137,6 @@ describe('Program card View', () => { programModel = new ProgramModel(programNoBanner); view = new ProgramCardView({ model: programModel, - context: { - subscriptionCollection, - isUserB2CSubscriptionsEnabled: true, - }, }); cardRenders(view.$el); expect(view.$el.find('.banner-image').attr('srcset')).toEqual(''); @@ -167,16 +151,8 @@ describe('Program card View', () => { programModel = new ProgramModel(programNoBanner); view = new ProgramCardView({ model: programModel, - context: { - subscriptionCollection, - isUserB2CSubscriptionsEnabled: true, - }, }); cardRenders(view.$el); expect(view.$el.find('.banner-image').attr('srcset')).toEqual(''); }); - - it('should render the subscription badge if subscription is active', () => { - expect(view.$('.subscription-badge .badge').html()?.trim()).toEqual('Subscribed'); - }); }); diff --git a/lms/static/js/learner_dashboard/spec/program_details_header_spec.js b/lms/static/js/learner_dashboard/spec/program_details_header_spec.js index d28d8f0bd3ee..862fb3f228d9 100644 --- a/lms/static/js/learner_dashboard/spec/program_details_header_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_details_header_spec.js @@ -45,16 +45,6 @@ describe('Program Details Header View', () => { }, ], }, - subscriptionData: [ - { - trial_end: '1970-01-01T03:25:45Z', - current_period_end: '1970-06-03T07:12:04Z', - price: '100.00', - currency: 'USD', - subscription_state: 'active', - }, - ], - isSubscriptionEligible: true, }; beforeEach(() => { @@ -81,8 +71,4 @@ describe('Program Details Header View', () => { expect(view.$('.org-logo').attr('alt')) .toEqual(`${context.programData.authoring_organizations[0].name}'s logo`); }); - - it('should render the subscription badge if subscription is active', () => { - expect(view.$('.meta-info .badge').html().trim()).toEqual('Subscribed'); - }); }); diff --git a/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js b/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js index 60c877da8ad6..e1db3ddd181e 100644 --- a/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_details_sidebar_view_spec.js @@ -1,9 +1,7 @@ /* globals setFixtures */ import Backbone from 'backbone'; -import moment from 'moment'; -import SubscriptionModel from '../models/program_subscription_model'; import ProgramSidebarView from '../views/program_details_sidebar_view'; describe('Program Progress View', () => { @@ -25,15 +23,13 @@ describe('Program Progress View', () => { "url": "/certificates/bed3980e67ca40f0b31e309d9dfe9e7e", "type": "course", "title": "Introduction to the Treatment of Urban Sewage" } ], - urls: {"program_listing_url": "/dashboard/programs/", "commerce_api_url": "/api/commerce/v0/baskets/", "track_selection_url": "/course_modes/choose/", "program_record_url": "/foo/bar", "buy_subscription_url": "/subscriptions", "orders_and_subscriptions_url": "/orders", "subscriptions_learner_help_center_url": "/learner"}, + urls: {"program_listing_url": "/dashboard/programs/", "commerce_api_url": "/api/commerce/v0/baskets/", "track_selection_url": "/course_modes/choose/"}, userPreferences: {"pref-lang": "en"} }; /* eslint-enable */ let programModel; let courseData; - let subscriptionData; let certificateCollection; - let isSubscriptionEligible; const testCircle = (progress) => { const $circle = view.$('.progress-circle'); @@ -53,55 +49,15 @@ describe('Program Progress View', () => { expect(parseInt($numbers.find('.total').html(), 10)).toEqual(total); }; - const testSubscriptionState = (state, heading, body) => { - isSubscriptionEligible = true; - subscriptionData.subscription_state = state; - // eslint-disable-next-line no-use-before-define - view = initView(); - // eslint-disable-next-line no-param-reassign - body += ' on the Orders and subscriptions page'; - - expect(view.$('.js-subscription-info')[0]).toBeInDOM(); - expect( - view.$('.js-subscription-info .divider-heading').text().trim(), - ).toEqual(heading); - expect( - view.$('.js-subscription-info .subscription-section p:nth-child(1)'), - ).toContainHtml(body); - expect( - view.$('.js-subscription-info .subscription-section p:nth-child(2)'), - ).toContainText( - /Need help\? Check out the.*Learner Help Center.*to troubleshoot issues or contact support/, - ); - expect( - view.$('.js-subscription-info .subscription-section p:nth-child(2) .subscription-link').attr('href'), - ).toEqual('/learner'); - }; - const initView = () => new ProgramSidebarView({ el: '.js-program-sidebar', model: programModel, courseModel: courseData, - subscriptionModel: new SubscriptionModel({ - context: { - programData: { - subscription_eligible: isSubscriptionEligible, - subscription_prices: [{ - price: '100.00', - currency: 'USD', - }], - }, - subscriptionData: [subscriptionData], - urls: data.urls, - userPreferences: data.userPreferences, - }, - }), certificateCollection, industryPathways: data.industryPathways, creditPathways: data.creditPathways, programTabViewEnabled: false, urls: data.urls, - isSubscriptionEligible, }); beforeEach(() => { @@ -109,14 +65,6 @@ describe('Program Progress View', () => { programModel = new Backbone.Model(data.programData); courseData = new Backbone.Model(data.courseData); certificateCollection = new Backbone.Collection(data.certificateData); - isSubscriptionEligible = false; - subscriptionData = { - trial_end: '1970-01-01T03:25:45Z', - current_period_end: '1970-06-03T07:12:04Z', - price: '100.00', - currency: 'USD', - subscription_state: 'pre', - }; }); afterEach(() => { @@ -203,69 +151,14 @@ describe('Program Progress View', () => { el: '.js-program-sidebar', model: programModel, courseModel: courseData, - subscriptionModel: new SubscriptionModel({ - context: { - programData: { - subscription_eligible: isSubscriptionEligible, - subscription_prices: [{ - price: '100.00', - currency: 'USD', - }], - }, - subscriptionData: [subscriptionData], - urls: data.urls, - userPreferences: data.userPreferences, - }, - }), certificateCollection, industryPathways: [], creditPathways: [], programTabViewEnabled: false, urls: data.urls, - isSubscriptionEligible, }); expect(emptyView.$('.program-credit-pathways .divider-heading')).toHaveLength(0); expect(emptyView.$('.program-industry-pathways .divider-heading')).toHaveLength(0); }); - - it('should not render subscription info if program is not subscription eligible', () => { - view = initView(); - expect(view.$('.js-subscription-info')[0]).not.toBeInDOM(); - }); - - it('should render subscription info if program is subscription eligible', () => { - testSubscriptionState( - 'pre', - 'Inactive subscription', - 'If you had a subscription previously, your payment history is still available', - ); - }); - - it('should render active trial subscription info if subscription is active with trial', () => { - subscriptionData.trial_end = moment().add(3, 'days').utc().format( - 'YYYY-MM-DDTHH:mm:ss[Z]', - ); - testSubscriptionState( - 'active', - 'Trial subscription', - 'View your receipts or modify your subscription', - ); - }); - - it('should render active subscription info if subscription active', () => { - testSubscriptionState( - 'active', - 'Active subscription', - 'View your receipts or modify your subscription', - ); - }); - - it('should render inactive subscription info if subscription inactive', () => { - testSubscriptionState( - 'inactive', - 'Inactive subscription', - 'Restart your subscription for $100/month USD. Your payment history is still available', - ); - }); }); diff --git a/lms/static/js/learner_dashboard/spec/program_details_view_spec.js b/lms/static/js/learner_dashboard/spec/program_details_view_spec.js index feaf72526192..a3be0f10815d 100644 --- a/lms/static/js/learner_dashboard/spec/program_details_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_details_view_spec.js @@ -7,11 +7,6 @@ describe('Program Details View', () => { let view = null; const options = { programData: { - subscription_eligible: false, - subscription_prices: [{ - price: '100.00', - currency: 'USD', - }], subtitle: '', overview: '', weeks_to_complete: null, @@ -468,24 +463,11 @@ describe('Program Details View', () => { }, ], }, - subscriptionData: [ - { - trial_end: '1970-01-01T03:25:45Z', - current_period_end: '1970-06-03T07:12:04Z', - price: '100.00', - currency: 'USD', - subscription_state: 'pre', - }, - ], urls: { program_listing_url: '/dashboard/programs/', commerce_api_url: '/api/commerce/v0/baskets/', track_selection_url: '/course_modes/choose/', program_record_url: 'http://credentials.example.com/records/programs/UUID', - buy_subscription_url: '/subscriptions', - manage_subscription_url: '/orders', - subscriptions_learner_help_center_url: '/learner', - orders_and_subscriptions_url: '/orders', }, userPreferences: { 'pref-lang': 'en', @@ -513,59 +495,9 @@ describe('Program Details View', () => { }, ], programTabViewEnabled: false, - isUserB2CSubscriptionsEnabled: false, }; const data = options.programData; - const testSubscriptionState = (state, heading, body, trial = false) => { - const subscriptionData = { - ...options.subscriptionData[0], - subscription_state: state, - }; - if (trial) { - subscriptionData.trial_end = moment().add(3, 'days').utc().format( - 'YYYY-MM-DDTHH:mm:ss[Z]', - ); - } - // eslint-disable-next-line no-use-before-define - view = initView({ - // eslint-disable-next-line no-undef - programData: $.extend({}, options.programData, { - subscription_eligible: true, - }), - isUserB2CSubscriptionsEnabled: true, - subscriptionData: [subscriptionData], - }); - view.render(); - expect(view.$('.upgrade-subscription')[0]).toBeInDOM(); - expect(view.$('.upgrade-subscription .upgrade-button')) - .toContainText(heading); - expect(view.$('.upgrade-subscription .subscription-info-brief')) - .toContainText(body); - }; - - const testSubscriptionSunsetting = (state, heading, body) => { - const subscriptionData = { - ...options.subscriptionData[0], - subscription_state: state, - }; - // eslint-disable-next-line no-use-before-define - view = initView({ - // eslint-disable-next-line no-undef - programData: $.extend({}, options.programData, { - subscription_eligible: false, - }), - isUserB2CSubscriptionsEnabled: true, - subscriptionData: [subscriptionData], - }); - view.render(); - expect(view.$('.upgrade-subscription')[0]).not.toBeInDOM(); - expect(view.$('.upgrade-subscription .upgrade-button')).not - .toContainText(heading); - expect(view.$('.upgrade-subscription .subscription-info-brief')).not - .toContainText(body); - }; - const initView = (updates) => { // eslint-disable-next-line no-undef const viewOptions = $.extend({}, options, updates); @@ -730,37 +662,4 @@ describe('Program Details View', () => { properties, ); }); - - it('should not render the get subscription link if program is not active', () => { - testSubscriptionSunsetting( - 'pre', - 'Start 7-day free trial', - '$100/month USD subscription after trial ends. Cancel anytime.', - ); - }); - - it('should not render appropriate subscription text when subscription is active with trial', () => { - testSubscriptionSunsetting( - 'active', - 'Manage my subscription', - 'Trial ends', - true, - ); - }); - - it('should not render appropriate subscription text when subscription is active', () => { - testSubscriptionSunsetting( - 'active', - 'Manage my subscription', - 'Your next billing date is', - ); - }); - - it('should not render appropriate subscription text when subscription is inactive', () => { - testSubscriptionSunsetting( - 'inactive', - 'Restart my subscription', - '$100/month USD subscription. Cancel anytime.', - ); - }); }); diff --git a/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js b/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js index 4a663fc1f825..5e1c09bfe463 100644 --- a/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_list_header_view_spec.js @@ -13,27 +13,14 @@ describe('Program List Header View', () => { { uuid: '5b234e3c-3a2e-472e-90db-6f51501dc86c', title: 'edX Demonstration Program', - subscription_eligible: null, - subscription_prices: [], detail_url: '/dashboard/programs/5b234e3c-3a2e-472e-90db-6f51501dc86c/', }, { uuid: 'b90d70d5-f981-4508-bdeb-5b792d930c03', title: 'Test Program', - subscription_eligible: true, - subscription_prices: [{ price: '500.00', currency: 'USD' }], detail_url: '/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/', }, ], - programsSubscriptionData: [ - { - id: 'eeb25640-9741-4c11-963c-8a27337f217c', - resource_id: 'b90d70d5-f981-4508-bdeb-5b792d930c03', - trial_end: '2022-04-20T05:59:42Z', - current_period_end: '2023-05-08T05:59:42Z', - subscription_state: 'active', - }, - ], userProgress: [ { uuid: '5b234e3c-3a2e-472e-90db-6f51501dc86c', @@ -50,13 +37,9 @@ describe('Program List Header View', () => { all_unenrolled: true, }, ], - isUserB2CSubscriptionsEnabled: true, }; beforeEach(() => { - context.subscriptionCollection = new Backbone.Collection( - context.programsSubscriptionData, - ); context.progressCollection = new ProgressCollection( context.userProgress, ); @@ -78,18 +61,4 @@ describe('Program List Header View', () => { it('should render the program heading', () => { expect(view.$('h2:first').text().trim()).toEqual('My programs'); }); - - it('should render a program alert', () => { - expect( - view.$('.js-program-list-alerts .alert .alert-heading').html().trim(), - ).toEqual('Enroll in a Test Program\'s course'); - expect( - view.$('.js-program-list-alerts .alert .alert-message'), - ).toContainHtml( - 'According to our records, you are not enrolled in any courses included in your Test Program program subscription. Enroll in a course from the Program Details page.', - ); - expect( - view.$('.js-program-list-alerts .alert .view-button').attr('href'), - ).toEqual('/dashboard/programs/b90d70d5-f981-4508-bdeb-5b792d930c03/'); - }); }); diff --git a/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js b/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js index 04c936908e3c..e96369abb63d 100644 --- a/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js +++ b/lms/static/js/learner_dashboard/spec/sidebar_view_spec.js @@ -6,12 +6,6 @@ describe('Sidebar View', () => { let view = null; const context = { marketingUrl: 'https://www.example.org/programs', - subscriptionUpsellData: { - marketing_url: 'https://www.example.org/program-subscriptions', - minimum_price: '$39', - trial_length: 7, - }, - isUserB2CSubscriptionsEnabled: true, }; beforeEach(() => { @@ -32,10 +26,6 @@ describe('Sidebar View', () => { expect(view).toBeDefined(); }); - it('should not render the subscription upsell section', () => { - expect(view.$('.js-subscription-upsell')[0]).not.toBeInDOM(); - }); - it('should load the exploration panel given a marketing URL', () => { expect(view.$('.program-advertise .advertise-message').html().trim()) .toEqual( @@ -49,10 +39,6 @@ describe('Sidebar View', () => { view.remove(); view = new SidebarView({ el: '.sidebar', - context: { - isUserB2CSubscriptionsEnabled: true, - subscriptionUpsellData: context.subscriptionUpsellData, - }, }); view.render(); const $ad = view.$el.find('.program-advertise'); diff --git a/lms/static/js/learner_dashboard/views/course_card_view.js b/lms/static/js/learner_dashboard/views/course_card_view.js index 72028d6d95f5..dce9c7a384e6 100644 --- a/lms/static/js/learner_dashboard/views/course_card_view.js +++ b/lms/static/js/learner_dashboard/views/course_card_view.js @@ -9,8 +9,6 @@ import ExpiredNotificationView from './expired_notification_view'; import CourseEnrollView from './course_enroll_view'; import EntitlementView from './course_entitlement_view'; -import SubscriptionModel from '../models/program_subscription_model'; - import pageTpl from '../../../templates/learner_dashboard/course_card.underscore'; class CourseCardView extends Backbone.View { @@ -27,9 +25,6 @@ class CourseCardView extends Backbone.View { this.enrollModel = new EnrollModel(); if (options.context) { this.urlModel = new Backbone.Model(options.context.urls); - this.subscriptionModel = new SubscriptionModel({ - context: options.context, - }); this.enrollModel.urlRoot = this.urlModel.get('commerce_api_url'); } this.context = options.context || {}; @@ -93,8 +88,6 @@ class CourseCardView extends Backbone.View { this.upgradeMessage = new UpgradeMessageView({ $el: $upgradeMessage, model: this.model, - subscriptionModel: this.subscriptionModel, - isSubscriptionEligible: this.context.isSubscriptionEligible, }); $certStatus.remove(); diff --git a/lms/static/js/learner_dashboard/views/program_alert_list_view.js b/lms/static/js/learner_dashboard/views/program_alert_list_view.js deleted file mode 100644 index 6c42d85444ea..000000000000 --- a/lms/static/js/learner_dashboard/views/program_alert_list_view.js +++ /dev/null @@ -1,89 +0,0 @@ -import Backbone from 'backbone'; - -import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; -import StringUtils from 'edx-ui-toolkit/js/utils/string-utils'; - -import warningIcon from '../../../images/warning-icon.svg'; -import programAlertTpl from '../../../templates/learner_dashboard/program_alert_list_view.underscore'; - -class ProgramAlertListView extends Backbone.View { - constructor(options) { - const defaults = { - el: '.js-program-details-alerts', - }; - // eslint-disable-next-line prefer-object-spread - super(Object.assign({}, defaults, options)); - } - - initialize({ context }) { - this.tpl = HtmlUtils.template(programAlertTpl); - this.enrollmentAlerts = context.enrollmentAlerts || []; - this.trialEndingAlerts = context.trialEndingAlerts || []; - this.pageType = context.pageType; - this.render(); - } - - render() { - const data = { - alertList: this.getAlertList(), - warningIcon, - }; - HtmlUtils.setHtml(this.$el, this.tpl(data)); - } - - getAlertList() { - const alertList = this.enrollmentAlerts.map( - ({ title: programName, url }) => ({ - url, - // eslint-disable-next-line no-undef - urlText: gettext('View program'), - title: StringUtils.interpolate( - // eslint-disable-next-line no-undef - gettext('Enroll in a {programName}\'s course'), - { programName }, - ), - message: this.pageType === 'programDetails' - ? StringUtils.interpolate( - // eslint-disable-next-line no-undef - gettext('You have an active subscription to the {programName} program but are not enrolled in any courses. Enroll in a remaining course and enjoy verified access.'), - { programName }, - ) - : HtmlUtils.interpolateHtml( - // eslint-disable-next-line no-undef - gettext('According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.'), - { - programName, - i_start: HtmlUtils.HTML(''), - i_end: HtmlUtils.HTML(''), - }, - ), - }), - ); - return alertList.concat(this.trialEndingAlerts.map( - ({ title: programName, remainingDays, ...data }) => ({ - title: StringUtils.interpolate( - remainingDays < 1 - // eslint-disable-next-line no-undef - ? gettext('Subscription trial expires in less than 24 hours') - // eslint-disable-next-line no-undef - : ngettext('Subscription trial expires in {remainingDays} day', 'Subscription trial expires in {remainingDays} days', remainingDays), - { remainingDays }, - ), - message: StringUtils.interpolate( - remainingDays < 1 - // eslint-disable-next-line no-undef - ? gettext('Your {programName} trial will expire at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}.') - // eslint-disable-next-line no-undef - : ngettext('Your {programName} trial will expire in {remainingDays} day at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}.', 'Your {programName} trial will expire in {remainingDays} days at {trialEndTime} on {trialEndDate} and the card on file will be charged {subscriptionPrice}.', remainingDays), - { - programName, - remainingDays, - ...data, - }, - ), - }), - )); - } -} - -export default ProgramAlertListView; diff --git a/lms/static/js/learner_dashboard/views/program_card_view.js b/lms/static/js/learner_dashboard/views/program_card_view.js index 1a5a05313521..f4715e25388f 100644 --- a/lms/static/js/learner_dashboard/views/program_card_view.js +++ b/lms/static/js/learner_dashboard/views/program_card_view.js @@ -30,10 +30,6 @@ class ProgramCardView extends Backbone.View { uuid: this.model.get('uuid'), }); } - this.isSubscribed = ( - context.isUserB2CSubscriptionsEnabled && - this.model.get('subscriptionIndex') > -1 - ) ?? false; this.render(); } @@ -45,7 +41,6 @@ class ProgramCardView extends Backbone.View { this.getProgramProgress(), { orgList: orgList.join(' '), - isSubscribed: this.isSubscribed, }, ); diff --git a/lms/static/js/learner_dashboard/views/program_details_sidebar_view.js b/lms/static/js/learner_dashboard/views/program_details_sidebar_view.js index fea4ebd809dc..fa8ccb629b44 100644 --- a/lms/static/js/learner_dashboard/views/program_details_sidebar_view.js +++ b/lms/static/js/learner_dashboard/views/program_details_sidebar_view.js @@ -30,9 +30,7 @@ class ProgramDetailsSidebarView extends Backbone.View { this.industryPathways = options.industryPathways; this.creditPathways = options.creditPathways; this.programModel = options.model; - this.subscriptionModel = options.subscriptionModel; this.programTabViewEnabled = options.programTabViewEnabled; - this.isSubscriptionEligible = options.isSubscriptionEligible; this.urls = options.urls; this.render(); } @@ -42,14 +40,12 @@ class ProgramDetailsSidebarView extends Backbone.View { const data = $.extend( {}, this.model.toJSON(), - this.subscriptionModel.toJSON(), { programCertificate: this.programCertificate ? this.programCertificate.toJSON() : {}, industryPathways: this.industryPathways, creditPathways: this.creditPathways, programTabViewEnabled: this.programTabViewEnabled, - isSubscriptionEligible: this.isSubscriptionEligible, arrowUprightIcon, ...this.urls, }, diff --git a/lms/static/js/learner_dashboard/views/program_details_view.js b/lms/static/js/learner_dashboard/views/program_details_view.js index 220840c182e4..006d30c59b05 100644 --- a/lms/static/js/learner_dashboard/views/program_details_view.js +++ b/lms/static/js/learner_dashboard/views/program_details_view.js @@ -10,10 +10,6 @@ import CourseCardView from './course_card_view'; // eslint-disable-next-line import/no-named-as-default, import/no-named-as-default-member import HeaderView from './program_header_view'; import SidebarView from './program_details_sidebar_view'; -import AlertListView from './program_alert_list_view'; - -// eslint-disable-next-line import/no-named-as-default, import/no-named-as-default-member -import SubscriptionModel from '../models/program_subscription_model'; import launchIcon from '../../../images/launch-icon.svg'; import restartIcon from '../../../images/restart-icon.svg'; @@ -27,7 +23,6 @@ class ProgramDetailsView extends Backbone.View { el: '.js-program-details-wrapper', events: { 'click .complete-program': 'trackPurchase', - 'click .js-subscription-cta': 'trackSubscriptionCTA', }, }; // eslint-disable-next-line prefer-object-spread @@ -46,9 +41,6 @@ class ProgramDetailsView extends Backbone.View { this.certificateCollection = new Backbone.Collection( this.options.certificateData, ); - this.subscriptionModel = new SubscriptionModel({ - context: this.options, - }); this.completedCourseCollection = new CourseCardCollection( this.courseData.get('completed') || [], this.options.userPreferences, @@ -61,11 +53,6 @@ class ProgramDetailsView extends Backbone.View { this.courseData.get('not_started') || [], this.options.userPreferences, ); - this.subscriptionEventParams = { - label: this.options.programData.title, - program_uuid: this.options.programData.uuid, - }; - this.options.isSubscriptionEligible = this.getIsSubscriptionEligible(); this.render(); @@ -76,7 +63,6 @@ class ProgramDetailsView extends Backbone.View { pageName: 'program_dashboard', linkCategory: 'green_upgrade', }); - this.trackSubscriptionEligibleProgramView(); } static getUrl(base, programData) { @@ -107,7 +93,6 @@ class ProgramDetailsView extends Backbone.View { creditPathways: this.options.creditPathways, discussionFragment: this.options.discussionFragment, live_fragment: this.options.live_fragment, - isSubscriptionEligible: this.options.isSubscriptionEligible, launchIcon, restartIcon, }; @@ -115,7 +100,6 @@ class ProgramDetailsView extends Backbone.View { data = $.extend( data, this.programModel.toJSON(), - this.subscriptionModel.toJSON(), ); HtmlUtils.setHtml(this.$el, this.tpl(data)); this.postRender(); @@ -126,20 +110,6 @@ class ProgramDetailsView extends Backbone.View { model: new Backbone.Model(this.options), }); - if (this.options.isSubscriptionEligible) { - const { enrollmentAlerts, trialEndingAlerts } = this.getAlerts(); - - if (enrollmentAlerts.length || trialEndingAlerts.length) { - this.alertListView = new AlertListView({ - context: { - enrollmentAlerts, - trialEndingAlerts, - pageType: 'programDetails', - }, - }); - } - } - if (this.remainingCourseCollection.length > 0) { new CollectionListView({ el: '.js-course-list-remaining', @@ -178,12 +148,10 @@ class ProgramDetailsView extends Backbone.View { el: '.js-program-sidebar', model: this.programModel, courseModel: this.courseData, - subscriptionModel: this.subscriptionModel, certificateCollection: this.certificateCollection, industryPathways: this.options.industryPathways, creditPathways: this.options.creditPathways, programTabViewEnabled: this.options.programTabViewEnabled, - isSubscriptionEligible: this.options.isSubscriptionEligible, urls: this.options.urls, }); let hasIframe = false; @@ -197,59 +165,6 @@ class ProgramDetailsView extends Backbone.View { }).bind(this); } - getIsSubscriptionEligible() { - const courseCollections = [ - this.completedCourseCollection, - this.inProgressCourseCollection, - ]; - const isSomeCoursePurchasable = courseCollections.some((collection) => ( - collection.some((course) => ( - course.get('upgrade_url') - && !(course.get('expired') === true) - )) - )); - const programPurchasedWithoutSubscription = ( - this.subscriptionModel.get('subscriptionState') !== 'active' - && this.subscriptionModel.get('subscriptionState') !== 'inactive' - && !isSomeCoursePurchasable - && this.remainingCourseCollection.length === 0 - ); - - const isSubscriptionActiveSunsetting = ( - this.subscriptionModel.get('subscriptionState') === 'active' - ) - - return ( - this.options.isUserB2CSubscriptionsEnabled - && isSubscriptionActiveSunsetting - && !programPurchasedWithoutSubscription - ); - } - - getAlerts() { - const alerts = { - enrollmentAlerts: [], - trialEndingAlerts: [], - }; - if (this.subscriptionModel.get('subscriptionState') === 'active') { - if (this.courseData.get('all_unenrolled')) { - alerts.enrollmentAlerts.push({ - title: this.programModel.get('title'), - }); - } - if ( - this.subscriptionModel.get('remainingDays') <= 7 - && this.subscriptionModel.get('hasActiveTrial') - ) { - alerts.trialEndingAlerts.push({ - title: this.programModel.get('title'), - ...this.subscriptionModel.toJSON(), - }); - } - } - return alerts; - } - trackPurchase() { const data = this.options.programData; window.analytics.track('edx.bi.user.dashboard.program.purchase', { @@ -258,37 +173,6 @@ class ProgramDetailsView extends Backbone.View { uuid: data.uuid, }); } - - trackSubscriptionCTA() { - const state = this.subscriptionModel.get('subscriptionState'); - - if (state === 'active') { - window.analytics.track( - 'edx.bi.user.subscription.program-detail-page.manage.clicked', - this.subscriptionEventParams, - ); - } else { - const isNewSubscription = state !== 'inactive'; - window.analytics.track( - 'edx.bi.user.subscription.program-detail-page.subscribe.clicked', - { - category: `${this.options.programData.variant} bundle`, - is_new_subscription: isNewSubscription, - is_trial_eligible: isNewSubscription, - ...this.subscriptionEventParams, - }, - ); - } - } - - trackSubscriptionEligibleProgramView() { - if (this.options.isSubscriptionEligible) { - window.analytics.track( - 'edx.bi.user.subscription.program-detail-page.viewed', - this.subscriptionEventParams, - ); - } - } } export default ProgramDetailsView; diff --git a/lms/static/js/learner_dashboard/views/program_header_view.js b/lms/static/js/learner_dashboard/views/program_header_view.js index 2fd8e9fe5190..acb3c876cad0 100644 --- a/lms/static/js/learner_dashboard/views/program_header_view.js +++ b/lms/static/js/learner_dashboard/views/program_header_view.js @@ -42,22 +42,11 @@ class ProgramHeaderView extends Backbone.View { return logo; } - getIsSubscribed() { - const isSubscriptionEligible = this.model.get('isSubscriptionEligible'); - const subscriptionData = this.model.get('subscriptionData')?.[0]; - - return ( - isSubscriptionEligible && - subscriptionData?.subscription_state === 'active' - ); - } - render() { // eslint-disable-next-line no-undef const data = $.extend(this.model.toJSON(), { breakpoints: this.breakpoints, logo: this.getLogo(), - isSubscribed: this.getIsSubscribed(), }); if (this.model.get('programData')) { diff --git a/lms/static/js/learner_dashboard/views/program_list_header_view.js b/lms/static/js/learner_dashboard/views/program_list_header_view.js index 6520caf08615..98e628cefae4 100644 --- a/lms/static/js/learner_dashboard/views/program_list_header_view.js +++ b/lms/static/js/learner_dashboard/views/program_list_header_view.js @@ -2,10 +2,6 @@ import Backbone from 'backbone'; import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; -import AlertListView from './program_alert_list_view'; - -import SubscriptionModel from '../models/program_subscription_model'; - import programListHeaderTpl from '../../../templates/learner_dashboard/program_list_header_view.underscore'; class ProgramListHeaderView extends Backbone.View { @@ -19,76 +15,11 @@ class ProgramListHeaderView extends Backbone.View { initialize({ context }) { this.context = context; this.tpl = HtmlUtils.template(programListHeaderTpl); - this.programAndSubscriptionData = context.programsData - .map((programData) => ({ - programData, - subscriptionData: context.subscriptionCollection - ?.findWhere({ - resource_id: programData.uuid, - subscription_state: 'active', - }) - ?.toJSON(), - })) - .filter(({ subscriptionData }) => !!subscriptionData); this.render(); } render() { HtmlUtils.setHtml(this.$el, this.tpl(this.context)); - this.postRender(); - } - - postRender() { - if (this.context.isUserB2CSubscriptionsEnabled) { - const enrollmentAlerts = this.getEnrollmentAlerts(); - const trialEndingAlerts = this.getTrialEndingAlerts(); - - if (enrollmentAlerts.length || trialEndingAlerts.length) { - this.alertListView = new AlertListView({ - el: '.js-program-list-alerts', - context: { - enrollmentAlerts, - trialEndingAlerts, - pageType: 'programList', - }, - }); - } - } - } - - getEnrollmentAlerts() { - return this.programAndSubscriptionData - .map(({ programData, subscriptionData }) => - this.context.progressCollection?.findWhere({ - uuid: programData.uuid, - all_unenrolled: true, - }) ? { - title: programData.title, - url: programData.detail_url, - } : null - ) - .filter(Boolean); - } - - getTrialEndingAlerts() { - return this.programAndSubscriptionData - .map(({ programData, subscriptionData }) => { - const subscriptionModel = new SubscriptionModel({ - context: { - programData, - subscriptionData: [subscriptionData], - userPreferences: this.context?.userPreferences, - }, - }); - return ( - subscriptionModel.get('remainingDays') <= 7 && - subscriptionModel.get('hasActiveTrial') && { - title: programData.title, - ...subscriptionModel.toJSON(), - } - ); - }) - .filter(Boolean); } } diff --git a/lms/static/js/learner_dashboard/views/sidebar_view.js b/lms/static/js/learner_dashboard/views/sidebar_view.js index 3359eac1b429..520efbe29f03 100644 --- a/lms/static/js/learner_dashboard/views/sidebar_view.js +++ b/lms/static/js/learner_dashboard/views/sidebar_view.js @@ -10,9 +10,6 @@ class SidebarView extends Backbone.View { constructor(options) { const defaults = { el: '.sidebar', - events: { - 'click .js-subscription-upsell-cta ': 'trackSubscriptionUpsellCTA', - }, }; // eslint-disable-next-line prefer-object-spread super(Object.assign({}, defaults, options)); @@ -33,12 +30,6 @@ class SidebarView extends Backbone.View { context: this.context, }); } - - trackSubscriptionUpsellCTA() { - window.analytics.track( - 'edx.bi.user.subscription.program-dashboard.upsell.clicked', - ); - } } export default SidebarView; diff --git a/lms/static/js/learner_dashboard/views/subscription_upsell_view.js b/lms/static/js/learner_dashboard/views/subscription_upsell_view.js deleted file mode 100644 index 3c085aaf7e7b..000000000000 --- a/lms/static/js/learner_dashboard/views/subscription_upsell_view.js +++ /dev/null @@ -1,30 +0,0 @@ -import Backbone from 'backbone'; - -import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; - -import subscriptionUpsellTpl from '../../../templates/learner_dashboard/subscription_upsell_view.underscore'; - -class SubscriptionUpsellView extends Backbone.View { - constructor(options) { - const defaults = { - el: '.js-subscription-upsell', - }; - // eslint-disable-next-line prefer-object-spread - super(Object.assign({}, defaults, options)); - } - - initialize(options) { - this.tpl = HtmlUtils.template(subscriptionUpsellTpl); - this.subscriptionUpsellModel = new Backbone.Model( - options.subscriptionUpsellData, - ); - this.render(); - } - - render() { - const data = this.subscriptionUpsellModel.toJSON(); - HtmlUtils.setHtml(this.$el, this.tpl(data)); - } -} - -export default SubscriptionUpsellView; diff --git a/lms/static/js/learner_dashboard/views/upgrade_message_view.js b/lms/static/js/learner_dashboard/views/upgrade_message_view.js index 07d1b9522e95..c8ad3632861f 100644 --- a/lms/static/js/learner_dashboard/views/upgrade_message_view.js +++ b/lms/static/js/learner_dashboard/views/upgrade_message_view.js @@ -3,18 +3,12 @@ import Backbone from 'backbone'; import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; import upgradeMessageTpl from '../../../templates/learner_dashboard/upgrade_message.underscore'; -import upgradeMessageSubscriptionTpl from '../../../templates/learner_dashboard/upgrade_message_subscription.underscore'; import trackECommerceEvents from '../../commerce/track_ecommerce_events'; class UpgradeMessageView extends Backbone.View { initialize(options) { - if (options.isSubscriptionEligible) { - this.messageTpl = HtmlUtils.template(upgradeMessageSubscriptionTpl); - } else { - this.messageTpl = HtmlUtils.template(upgradeMessageTpl); - } + this.messageTpl = HtmlUtils.template(upgradeMessageTpl); this.$el = options.$el; - this.subscriptionModel = options.subscriptionModel; this.render(); const courseUpsellButtons = this.$el.find('.program_dashboard_course_upsell_button'); @@ -30,7 +24,6 @@ class UpgradeMessageView extends Backbone.View { const data = $.extend( {}, this.model.toJSON(), - this.subscriptionModel.toJSON(), ); HtmlUtils.setHtml(this.$el, this.messageTpl(data)); } diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index 629b3065778e..a8a34ccec589 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -44,6 +44,11 @@ > div.table { display: table; width: 100%; + + @include media-breakpoint-down(sm) { + display: flex; + flex-direction: column; + } } .intro { @@ -51,6 +56,11 @@ @include clearfix(); + @include media-breakpoint-down(sm) { + width: auto; + order: 2; + } + display: table-cell; vertical-align: middle; padding: $baseline; @@ -127,6 +137,10 @@ a.add-to-cart { @include button(shiny, $button-color); + @include media-breakpoint-down(md) { + width: 100%; + } + box-sizing: border-box; border-radius: 3px; display: block; @@ -189,6 +203,11 @@ @include float(left); @include margin(1px, flex-gutter(8), 0, 0); @include transition(none); + @include media-breakpoint-down(md) { + width: 100%; + margin-right: 0; + margin-bottom: 10px; + } width: flex-grid(5, 8); } @@ -213,6 +232,11 @@ width: flex-grid(4); z-index: 2; + @include media-breakpoint-down(sm) { + width: auto; + order: 1; + } + .hero { border: 1px solid $border-color-3; height: 100%; diff --git a/lms/static/sass/views/_program-details.scss b/lms/static/sass/views/_program-details.scss index 9056f04a13d7..f5a6eb62b50b 100644 --- a/lms/static/sass/views/_program-details.scss +++ b/lms/static/sass/views/_program-details.scss @@ -90,21 +90,6 @@ $btn-color-primary: $primary-dark; } } -.program-details-alerts { - .page-banner { - margin: 0; - padding: 0 0 48px; - gap: 24px; - } -} - -.program-details-tab-alerts { - .page-banner { - margin: 0; - gap: 24px; - } -} - // CSS for April 2017 version of Program Details Page .program-details { .window-wrap { @@ -449,42 +434,6 @@ $btn-color-primary: $primary-dark; } } - .upgrade-subscription { - margin: 16px 0 10px; - row-gap: 16px; - column-gap: 24px; - } - - .subscription-icon-launch { - width: 22.5px; - height: 22.5px; - margin-inline-start: 8px; - } - - .subscription-icon-restart { - width: 22.5px; - height: 22.5px; - margin-inline-end: 8px; - } - - .subscription-icon-arrow-upright { - display: inline-flex; - align-items: center; - width: 15px; - height: 15px; - margin-inline-start: 8px; - } - - .subscription-info-brief { - font-size: 0.9375em; - color: $gray-500; - } - - .subscription-info-upsell { - margin-top: 0.25rem; - font-size: 0.8125em; - } - .program-course-card { width: 100%; padding: 15px 15px 15px 0px; @@ -681,24 +630,6 @@ $btn-color-primary: $primary-dark; .program-sidebar { padding: 40px 40px 40px 0px; - .program-record,.subscription-info { - text-align: left; - padding-bottom: 2em; - } - - .subscription-section { - display: flex; - flex-direction: column; - gap: 16px; - color: #414141; - - .subscription-link { - color: inherit; - text-decoration: none; - border-bottom: 1px solid currentColor; - } - } - .sidebar-section { font-size: 0.9375em; width: auto; diff --git a/lms/static/sass/views/_program-list.scss b/lms/static/sass/views/_program-list.scss index 23f9a78b7c0d..d05e2eb2859b 100644 --- a/lms/static/sass/views/_program-list.scss +++ b/lms/static/sass/views/_program-list.scss @@ -39,13 +39,6 @@ .program-cards-container { @include grid-container(); padding-top: 32px; - - .subscription-badge { - position: absolute; - top: 8px; - left: 8px; - z-index: 10; - } } .sidebar { diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 91d7a2a28e57..eec9caeadbec 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -62,11 +62,10 @@
-

- ${course.display_name_with_default} -

+

${course.display_org_with_default}

+

${course.display_name_with_default}


- ${course.display_org_with_default} +

${get_course_about_section(request, course, 'short_description')}

@@ -160,7 +159,11 @@

<%block name="course_about_important_dates">
    -
  1. ${_("Course Number")}

    ${course.display_number_with_default}
  2. +
  3. + +

    ${_("Course Number")}

    + ${course.display_number_with_default} +
  4. % if not course.start_date_is_still_default: <% course_start_date = course.advertised_start or course.start @@ -231,7 +234,11 @@

    % endif % if get_course_about_section(request, course, "prerequisites"): -
  5. ${_("Requirements")}

    ${get_course_about_section(request, course, "prerequisites")}
  6. +
  7. + +

    ${_("Requirements")}

    + ${get_course_about_section(request, course, "prerequisites")} +
  8. % endif

diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html index e8411c9e4217..deeda26c431d 100644 --- a/lms/templates/courseware/courseware-chromeless.html +++ b/lms/templates/courseware/courseware-chromeless.html @@ -144,10 +144,12 @@ // to stay in relatively the same position so viewing of the video // is not disrupted. if ($(this).attr('class') === 'transcript-start'||$(this).attr('class') === 'transcript-end') { + var target = $(targetId)[0]; event.preventDefault(); - $(targetId)[0].scrollIntoView({ + target.scrollIntoView({ block: 'nearest', }); + target.focus(); } else { var targetName = $(this).attr('href').slice(1); // Checks if the target uses an id or name. @@ -159,6 +161,7 @@ target.scrollIntoView({ block: 'start', }); + target.focus(); } } } diff --git a/lms/templates/instructor/instructor_dashboard_2/special_exams.html b/lms/templates/instructor/instructor_dashboard_2/special_exams.html index 194c0cdcb018..2658af0bc70e 100644 --- a/lms/templates/instructor/instructor_dashboard_2/special_exams.html +++ b/lms/templates/instructor/instructor_dashboard_2/special_exams.html @@ -7,7 +7,7 @@
% if section_data.get('mfe_view_url'):
- +
% else: % if section_data.get('escalation_email'): diff --git a/lms/templates/learner_dashboard/program_card.underscore b/lms/templates/learner_dashboard/program_card.underscore index c9364d6ca2c7..de98c952dd15 100644 --- a/lms/templates/learner_dashboard/program_card.underscore +++ b/lms/templates/learner_dashboard/program_card.underscore @@ -61,8 +61,3 @@
-<% if (isSubscribed) { %> -
- <%- gettext('Subscribed') %> -
-<% } %> diff --git a/lms/templates/learner_dashboard/program_details_fragment.html b/lms/templates/learner_dashboard/program_details_fragment.html index 7aff07a6a3ac..70571ca80ff8 100644 --- a/lms/templates/learner_dashboard/program_details_fragment.html +++ b/lms/templates/learner_dashboard/program_details_fragment.html @@ -14,7 +14,6 @@ <%static:webpack entry="ProgramDetailsFactory"> ProgramDetailsFactory({ programData: ${program_data | n, dump_js_escaped_json}, - subscriptionData: ${program_subscription_data | n, dump_js_escaped_json}, courseData: ${course_data | n, dump_js_escaped_json}, certificateData: ${certificate_data | n, dump_js_escaped_json}, urls: ${urls | n, dump_js_escaped_json}, @@ -22,8 +21,6 @@ industryPathways: ${industry_pathways | n, dump_js_escaped_json}, creditPathways: ${credit_pathways | n, dump_js_escaped_json}, programTabViewEnabled: ${program_tab_view_enabled | n, dump_js_escaped_json}, - isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json}, - subscriptionsTrialLength: ${subscriptions_trial_length | n, dump_js_escaped_json}, discussionFragment: ${discussion_fragment, | n, dump_js_escaped_json}, live_fragment: ${live_fragment, | n, dump_js_escaped_json} }); diff --git a/lms/templates/learner_dashboard/program_details_sidebar.underscore b/lms/templates/learner_dashboard/program_details_sidebar.underscore index cab7aad04b75..0e05ae9b9a08 100644 --- a/lms/templates/learner_dashboard/program_details_sidebar.underscore +++ b/lms/templates/learner_dashboard/program_details_sidebar.underscore @@ -8,50 +8,6 @@ <% } %> -<% if (isSubscriptionEligible) { %> - -<% } %>

<% } %> - <% if ( - !isSubscriptionEligible - && is_learner_eligible_for_one_click_purchase + <% if (is_learner_eligible_for_one_click_purchase && (typeof is_mobile_only === 'undefined' || is_mobile_only === false) ) { %> diff --git a/lms/templates/learner_dashboard/program_details_view.underscore b/lms/templates/learner_dashboard/program_details_view.underscore index 25f1cd5b883b..2b803d462aea 100644 --- a/lms/templates/learner_dashboard/program_details_view.underscore +++ b/lms/templates/learner_dashboard/program_details_view.underscore @@ -1,5 +1,4 @@
-
@@ -22,8 +21,7 @@
<% } %> <% if ( - !isSubscriptionEligible - && is_learner_eligible_for_one_click_purchase + is_learner_eligible_for_one_click_purchase && (typeof is_mobile_only === 'undefined' || is_mobile_only === false) ) { %>
diff --git a/lms/templates/learner_dashboard/program_header_view.underscore b/lms/templates/learner_dashboard/program_header_view.underscore index b406e510c40d..58b556faaa6a 100644 --- a/lms/templates/learner_dashboard/program_header_view.underscore +++ b/lms/templates/learner_dashboard/program_header_view.underscore @@ -1,10 +1,5 @@
- <% if (isSubscribed) { %> -
- <%- gettext('Subscribed') %> -
- <% } %> <% if (logo) { %> <% // xss-lint: disable=underscore-not-escaped %> <%= logo %> diff --git a/lms/templates/learner_dashboard/program_list_header_view.underscore b/lms/templates/learner_dashboard/program_list_header_view.underscore index b3a19f55d716..03f6f26f4d74 100644 --- a/lms/templates/learner_dashboard/program_list_header_view.underscore +++ b/lms/templates/learner_dashboard/program_list_header_view.underscore @@ -1,2 +1 @@

<%- gettext('My programs') %>

-
diff --git a/lms/templates/learner_dashboard/programs_fragment.html b/lms/templates/learner_dashboard/programs_fragment.html index d3e9f3d8eff7..1a12d24ac6a8 100644 --- a/lms/templates/learner_dashboard/programs_fragment.html +++ b/lms/templates/learner_dashboard/programs_fragment.html @@ -31,11 +31,8 @@ ProgramListFactory({ marketingUrl: '${marketing_url | n, js_escaped_string}', programsData: ${programs | n, dump_js_escaped_json}, - programsSubscriptionData: ${programs_subscription_data | n, dump_js_escaped_json}, - subscriptionUpsellData: ${subscription_upsell_data | n, dump_js_escaped_json}, userProgress: ${progress | n, dump_js_escaped_json}, userPreferences: ${user_preferences | n, dump_js_escaped_json}, - isUserB2CSubscriptionsEnabled: ${is_user_b2c_subscriptions_enabled | n, dump_js_escaped_json}, mobileOnly: ${mobile_only | n, dump_js_escaped_json} }); diff --git a/lms/templates/learner_dashboard/subscription_upsell_view.underscore b/lms/templates/learner_dashboard/subscription_upsell_view.underscore deleted file mode 100644 index cc01d47c8150..000000000000 --- a/lms/templates/learner_dashboard/subscription_upsell_view.underscore +++ /dev/null @@ -1,20 +0,0 @@ -<%- gettext('New') %> -

- <%= HtmlUtils.interpolateHtml( - gettext('Monthly program subscriptions {emDash} more flexible, more affordable'), - { emDash: HtmlUtils.HTML('—') } - ) %> -

- -
- - <%- gettext('Explore subscription options') %> - diff --git a/lms/templates/learner_dashboard/upgrade_message_subscription.underscore b/lms/templates/learner_dashboard/upgrade_message_subscription.underscore deleted file mode 100644 index 7a3471f9a3fe..000000000000 --- a/lms/templates/learner_dashboard/upgrade_message_subscription.underscore +++ /dev/null @@ -1,21 +0,0 @@ -
- <%- gettext('Certificate Status:') %> - <%- gettext('Needs verified certificate ') %> -
-<% if ( subscriptionState !== 'active' ) { %> -
- - <%- gettext('Upgrade with a subscription') %> - - - <%- StringUtils.interpolate( - ( - subscriptionState === 'inactive' - ? gettext('Pay {subscriptionPrice} for all courses in this program') - : gettext('Pay {subscriptionPrice} after {trialLength}-day free trial') - ), - { subscriptionPrice, trialLength }, - ) %> - -
-<% } %> diff --git a/lms/templates/xblock_v2/xblock_iframe.html b/lms/templates/xblock_v2/xblock_iframe.html new file mode 120000 index 000000000000..7264c253466d --- /dev/null +++ b/lms/templates/xblock_v2/xblock_iframe.html @@ -0,0 +1 @@ +../../../cms/templates/content_libraries/xblock_iframe.html \ No newline at end of file diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 4262b8a7b13f..71d09590d003 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -18,7 +18,8 @@ from meilisearch.errors import MeilisearchError from meilisearch.models.task import TaskInfo from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator +from openedx_learning.api import authoring as authoring_api from common.djangoapps.student.roles import GlobalStaff from rest_framework.request import Request from common.djangoapps.student.role_helpers import get_course_roles @@ -31,8 +32,11 @@ Fields, meili_id_from_opaque_key, searchable_doc_for_course_block, + searchable_doc_for_collection, searchable_doc_for_library_block, - searchable_doc_tags + searchable_doc_collections, + searchable_doc_tags, + searchable_doc_tags_for_collection, ) log = logging.getLogger(__name__) @@ -299,7 +303,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: num_contexts_done = 0 # How many courses/libraries we've indexed num_blocks_done = 0 # How many individual components/XBlocks we've indexed - status_cb(f"Found {num_courses} courses and {num_libraries} libraries.") + status_cb(f"Found {num_courses} courses, {num_libraries} libraries.") with _using_temp_index(status_cb) as temp_index_name: ############## Configure the index ############## @@ -320,9 +324,13 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: Fields.tags + "." + Fields.tags_level1, Fields.tags + "." + Fields.tags_level2, Fields.tags + "." + Fields.tags_level3, + Fields.collections, + Fields.collections + "." + Fields.collections_display_name, + Fields.collections + "." + Fields.collections_key, Fields.type, Fields.access_id, Fields.last_published, + Fields.content + "." + Fields.problem_types, ]) # Mark which attributes are used for keyword search, in order of importance: client.index(temp_index_name).update_searchable_attributes([ @@ -330,7 +338,9 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: Fields.display_name, Fields.block_id, Fields.content, + Fields.description, Fields.tags, + Fields.collections, # If we don't list the following sub-fields _explicitly_, they're only sometimes searchable - that is, they # are searchable only if at least one document in the index has a value. If we didn't list them here and, # say, there were no tags.level3 tags in the index, the client would get an error if trying to search for @@ -340,6 +350,8 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: Fields.tags + "." + Fields.tags_level1, Fields.tags + "." + Fields.tags_level2, Fields.tags + "." + Fields.tags_level3, + Fields.collections + "." + Fields.collections_display_name, + Fields.collections + "." + Fields.collections_key, ]) # Mark which attributes can be used for sorting search results: client.index(temp_index_name).update_sortable_attributes([ @@ -362,8 +374,8 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: ############## Libraries ############## status_cb("Indexing libraries...") - for lib_key in lib_keys: - status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing library {lib_key}") + + def index_library(lib_key: str) -> list: docs = [] for component in lib_api.get_library_components(lib_key): try: @@ -371,51 +383,95 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: doc = {} doc.update(searchable_doc_for_library_block(metadata)) doc.update(searchable_doc_tags(metadata.usage_key)) + doc.update(searchable_doc_collections(metadata.usage_key)) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing library component {component}: {err}") - finally: - num_blocks_done += 1 if docs: try: # Add all the docs in this library at once (usually faster than adding one at a time): _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) except (TypeError, KeyError, MeilisearchError) as err: status_cb(f"Error indexing library {lib_key}: {err}") + return docs + + ############## Collections ############## + def index_collection_batch(batch, num_done, library_key) -> int: + docs = [] + for collection in batch: + try: + doc = searchable_doc_for_collection(collection) + doc.update(searchable_doc_tags_for_collection(library_key, collection)) + docs.append(doc) + except Exception as err: # pylint: disable=broad-except + status_cb(f"Error indexing collection {collection}: {err}") + num_done += 1 + + if docs: + try: + # Add docs in batch of 100 at once (usually faster than adding one at a time): + _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + except (TypeError, KeyError, MeilisearchError) as err: + status_cb(f"Error indexing collection batch {p}: {err}") + return num_done + + for lib_key in lib_keys: + status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing blocks in library {lib_key}") + lib_docs = index_library(lib_key) + num_blocks_done += len(lib_docs) + + # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: + library = lib_api.get_library(lib_key) + collections = authoring_api.get_collections(library.learning_package.id, enabled=True) + num_collections = collections.count() + num_collections_done = 0 + status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") + paginator = Paginator(collections, 100) + for p in paginator.page_range: + num_collections_done = index_collection_batch( + paginator.page(p).object_list, + num_collections_done, + lib_key, + ) + status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") num_contexts_done += 1 ############## Courses ############## status_cb("Indexing courses...") # To reduce memory usage on large instances, split up the CourseOverviews into pages of 1,000 courses: + + def index_course(course: CourseOverview) -> list: + docs = [] + # Pre-fetch the course with all of its children: + course = store.get_course(course.id, depth=None) + + def add_with_children(block): + """ Recursively index the given XBlock/component """ + doc = searchable_doc_for_course_block(block) + doc.update(searchable_doc_tags(block.usage_key)) + docs.append(doc) # pylint: disable=cell-var-from-loop + _recurse_children(block, add_with_children) # pylint: disable=cell-var-from-loop + + # Index course children + _recurse_children(course, add_with_children) + + if docs: + # Add all the docs in this course at once (usually faster than adding one at a time): + _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + return docs + paginator = Paginator(CourseOverview.objects.only('id', 'display_name'), 1000) for p in paginator.page_range: for course in paginator.page(p).object_list: status_cb( f"{num_contexts_done + 1}/{num_contexts}. Now indexing course {course.display_name} ({course.id})" ) - docs = [] - - # Pre-fetch the course with all of its children: - course = store.get_course(course.id, depth=None) - - def add_with_children(block): - """ Recursively index the given XBlock/component """ - doc = searchable_doc_for_course_block(block) - doc.update(searchable_doc_tags(block.usage_key)) - docs.append(doc) # pylint: disable=cell-var-from-loop - _recurse_children(block, add_with_children) # pylint: disable=cell-var-from-loop - - # Index course children - _recurse_children(course, add_with_children) - - if docs: - # Add all the docs in this course at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + course_docs = index_course(course) num_contexts_done += 1 - num_blocks_done += len(docs) + num_blocks_done += len(course_docs) - status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses and libraries.") + status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses, collections and libraries.") def upsert_xblock_index_doc(usage_key: UsageKey, recursive: bool = True) -> None: @@ -505,6 +561,22 @@ def upsert_library_block_index_doc(usage_key: UsageKey) -> None: _update_index_docs(docs) +def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collection_key: str) -> None: + """ + Creates or updates the document for the given Library Collection in the search index + """ + content_library = lib_api.ContentLibrary.objects.get_by_key(library_key) + collection = authoring_api.get_collection( + learning_package_id=content_library.learning_package_id, + collection_key=collection_key, + ) + docs = [ + searchable_doc_for_collection(collection) + ] + + _update_index_docs(docs) + + def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None: """ Creates or updates the documents for the given Content Library in the search index @@ -527,6 +599,26 @@ def upsert_block_tags_index_docs(usage_key: UsageKey): _update_index_docs([doc]) +def upsert_block_collections_index_docs(usage_key: UsageKey): + """ + Updates the collections data in documents for the given Course/Library block + """ + doc = {Fields.id: meili_id_from_opaque_key(usage_key)} + doc.update(searchable_doc_collections(usage_key)) + _update_index_docs([doc]) + + +def upsert_collection_tags_index_docs(collection_usage_key: LibraryCollectionLocator): + """ + Updates the tags data in documents for the given library collection + """ + collection = lib_api.get_library_collection_from_usage_key(collection_usage_key) + + doc = {Fields.id: collection.id} + doc.update(searchable_doc_tags_for_collection(collection_usage_key.library_key, collection)) + _update_index_docs([doc]) + + def _get_user_orgs(request: Request) -> list[str]: """ Get the org.short_names for the organizations that the requesting user has OrgStaffRole or OrgInstructorRole. diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 032023f97c60..f9041468c296 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -7,12 +7,16 @@ from hashlib import blake2b from django.utils.text import slugify +from django.core.exceptions import ObjectDoesNotExist from opaque_keys.edx.keys import LearningContextKey, UsageKey +from openedx_learning.api import authoring as authoring_api +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx.core.djangoapps.content.search.models import SearchAccess from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.xblock import api as xblock_api +from openedx_learning.api.authoring_models import LearningPackage log = logging.getLogger(__name__) @@ -25,12 +29,18 @@ class Fields: id = "id" usage_key = "usage_key" type = "type" # DocType.course_block or DocType.library_block (see below) - block_id = "block_id" # The block_id part of the usage key. Sometimes human-readable, sometimes a random hex ID + # The block_id part of the usage key for course or library blocks. + # If it's a collection, the collection.key is stored here. + # Sometimes human-readable, sometimes a random hex ID + # Is only unique within the given context_key. + block_id = "block_id" display_name = "display_name" + description = "description" modified = "modified" created = "created" last_published = "last_published" block_type = "block_type" + problem_types = "problem_types" context_key = "context_key" org = "org" access_id = "access_id" # .models.SearchAccess.id @@ -49,11 +59,21 @@ class Fields: tags_level1 = "level1" tags_level2 = "level2" tags_level3 = "level3" + # Collections (dictionary) that this object belongs to. + # Similarly to tags above, we collect the collection.titles and collection.keys into hierarchical facets. + collections = "collections" + collections_display_name = "display_name" + collections_key = "key" + # The "content" field is a dictionary of arbitrary data, depending on the block_type. # It comes from each XBlock's index_dictionary() method (if present) plus some processing. # Text (html) blocks have an "html_content" key in here, capa has "capa_content" and "problem_types", and so on. content = "content" + # Collections use this field to communicate how many entities/components they contain. + # Structural XBlocks may use this one day to indicate how many child blocks they ocntain. + num_children = "num_children" + # Note: new fields or values can be added at any time, but if they need to be indexed for filtering or keyword # search, the index configuration will need to be changed, which is only done as part of the 'reindex_studio' # command (changing those settings on an large active index is not recommended). @@ -65,6 +85,7 @@ class DocType: """ course_block = "course_block" library_block = "library_block" + collection = "collection" def meili_id_from_opaque_key(usage_key: UsageKey) -> str: @@ -219,6 +240,51 @@ def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict: return {Fields.tags: result} +def _collections_for_content_object(object_id: UsageKey | LearningContextKey) -> dict: + """ + Given an XBlock, course, library, etc., get the collections for its index doc. + + e.g. for something in Collections "COL_A" and "COL_B", this would return: + { + "collections": { + "display_name": ["Collection A", "Collection B"], + "key": ["COL_A", "COL_B"], + } + } + + If the object is in no collections, returns: + { + "collections": {}, + } + + """ + # Gather the collections associated with this object + collections = None + try: + component = lib_api.get_component_from_usage_key(object_id) + collections = authoring_api.get_entity_collections( + component.learning_package_id, + component.key, + ) + except ObjectDoesNotExist: + log.warning(f"No component found for {object_id}") + + if not collections: + return {Fields.collections: {}} + + result = { + Fields.collections: { + Fields.collections_display_name: [], + Fields.collections_key: [], + } + } + for collection in collections: + result[Fields.collections][Fields.collections_display_name].append(collection.title) + result[Fields.collections][Fields.collections_key].append(collection.key) + + return result + + def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetadata) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine @@ -261,6 +327,41 @@ def searchable_doc_tags(usage_key: UsageKey) -> dict: return doc +def searchable_doc_collections(usage_key: UsageKey) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, with the collections data for the given content object. + """ + doc = { + Fields.id: meili_id_from_opaque_key(usage_key), + } + doc.update(_collections_for_content_object(usage_key)) + + return doc + + +def searchable_doc_tags_for_collection( + library_key: LibraryLocatorV2, + collection, +) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, with the tags data for the given library collection. + """ + doc = { + Fields.id: collection.id, + } + + collection_usage_key = lib_api.get_library_collection_usage_key( + library_key, + collection.key, + ) + + doc.update(_tags_for_content_object(collection_usage_key)) + + return doc + + def searchable_doc_for_course_block(block) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine @@ -275,3 +376,41 @@ def searchable_doc_for_course_block(block) -> dict: doc.update(_fields_from_block(block)) return doc + + +def searchable_doc_for_collection(collection) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, so that the given collection can be + found using faceted search. + """ + doc = { + Fields.id: collection.id, + Fields.block_id: collection.key, + Fields.type: DocType.collection, + Fields.display_name: collection.title, + Fields.description: collection.description, + Fields.created: collection.created.timestamp(), + Fields.modified: collection.modified.timestamp(), + # Add related learning_package.key as context_key by default. + # If related contentlibrary is found, it will override this value below. + # Mostly contentlibrary.library_key == learning_package.key + Fields.context_key: collection.learning_package.key, + Fields.num_children: collection.entities.count(), + } + # Just in case learning_package is not related to a library + try: + context_key = collection.learning_package.contentlibrary.library_key + org = str(context_key.org) + doc.update({ + Fields.context_key: str(context_key), + Fields.org: org, + Fields.usage_key: str(lib_api.get_library_collection_usage_key(context_key, collection.key)), + }) + except LearningPackage.contentlibrary.RelatedObjectDoesNotExist: + log.warning(f"Related library not found for {collection}") + doc[Fields.access_id] = _meili_access_id_from_context_key(doc[Fields.context_key]) + # Add the breadcrumbs. + doc[Fields.breadcrumbs] = [{"display_name": collection.learning_package.title}] + + return doc diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index ba0e8c1a1680..f50dead8474a 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -6,30 +6,46 @@ from django.db.models.signals import post_delete from django.dispatch import receiver -from openedx_events.content_authoring.data import ContentLibraryData, ContentObjectData, LibraryBlockData, XBlockData +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import LibraryCollectionLocator +from openedx_events.content_authoring.data import ( + ContentLibraryData, + ContentObjectChangedData, + LibraryBlockData, + LibraryCollectionData, + XBlockData, +) from openedx_events.content_authoring.signals import ( CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED, - CONTENT_OBJECT_TAGS_CHANGED, + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, ) -from openedx.core.djangoapps.content_tagging.utils import get_content_key_from_string from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.search.models import SearchAccess -from .api import only_if_meilisearch_enabled, upsert_block_tags_index_docs +from .api import ( + only_if_meilisearch_enabled, + upsert_block_collections_index_docs, + upsert_block_tags_index_docs, + upsert_collection_tags_index_docs, +) from .tasks import ( delete_library_block_index_doc, delete_xblock_index_doc, update_content_library_index_docs, + update_library_collection_index_doc, upsert_library_block_index_doc, - upsert_xblock_index_doc + upsert_xblock_index_doc, ) log = logging.getLogger(__name__) @@ -108,7 +124,9 @@ def library_block_updated_handler(**kwargs) -> None: log.error("Received null or incorrect data for event") return - upsert_library_block_index_doc.delay(str(library_block_data.usage_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) @receiver(LIBRARY_BLOCK_DELETED) @@ -122,7 +140,9 @@ def library_block_deleted(**kwargs) -> None: log.error("Received null or incorrect data for event") return - delete_library_block_index_doc.delay(str(library_block_data.usage_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + delete_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) @receiver(CONTENT_LIBRARY_UPDATED) @@ -136,25 +156,64 @@ def content_library_updated_handler(**kwargs) -> None: log.error("Received null or incorrect data for event") return - update_content_library_index_docs.delay(str(content_library_data.library_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches index. + # Currently, this is only required to make sure that removed/discarded components are removed + # from the search index and displayed to user properly. If it becomes a performance bottleneck + # for other update operations other than discard, we can update CONTENT_LIBRARY_UPDATED event + # to include a parameter which can help us decide if the task needs to run sync or async. + update_content_library_index_docs.apply(args=[str(content_library_data.library_key)]) -@receiver(CONTENT_OBJECT_TAGS_CHANGED) +@receiver(LIBRARY_COLLECTION_CREATED) +@receiver(LIBRARY_COLLECTION_UPDATED) @only_if_meilisearch_enabled -def content_object_tags_changed_handler(**kwargs) -> None: +def library_collection_updated_handler(**kwargs) -> None: """ - Update the tags data in the index for the Content Object + Create or update the index for the content library collection """ - content_object_tags = kwargs.get("content_object", None) - if not content_object_tags or not isinstance(content_object_tags, ContentObjectData): + library_collection = kwargs.get("library_collection", None) + if not library_collection or not isinstance(library_collection, LibraryCollectionData): # pragma: no cover log.error("Received null or incorrect data for event") return - try: - # Check if valid if course or library block - get_content_key_from_string(content_object_tags.object_id) - except ValueError: - log.error("Received invalid content object id") + # Update collection index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches index. + # See content_library_updated_handler for more details. + update_library_collection_index_doc.apply(args=[ + str(library_collection.library_key), + library_collection.collection_key, + ]) + + +@receiver(CONTENT_OBJECT_ASSOCIATIONS_CHANGED) +@only_if_meilisearch_enabled +def content_object_associations_changed_handler(**kwargs) -> None: + """ + Update the collections/tags data in the index for the Content Object + """ + content_object = kwargs.get("content_object", None) + if not content_object or not isinstance(content_object, ContentObjectChangedData): + log.error("Received null or incorrect data for event") return - upsert_block_tags_index_docs(content_object_tags.object_id) + try: + # Check if valid if course or library block + usage_key = UsageKey.from_string(str(content_object.object_id)) + except InvalidKeyError: + try: + # Check if valid if library collection + usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id)) + except InvalidKeyError: + log.error("Received invalid content object id") + return + + # This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever. + # So we allow a potential double "upsert" here. + if not content_object.changes or "tags" in content_object.changes: + if isinstance(usage_key, LibraryCollectionLocator): + upsert_collection_tags_index_docs(usage_key) + else: + upsert_block_tags_index_docs(usage_key) + if not content_object.changes or "collections" in content_object.changes: + upsert_block_collections_index_docs(usage_key) diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index dfd603776981..d9dad834db29 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -84,3 +84,16 @@ def update_content_library_index_docs(library_key_str: str) -> None: # Delete all documents in this library that were not published by above function # as this task is also triggered on discard event. api.delete_all_draft_docs_for_library(library_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def update_library_collection_index_doc(library_key_str: str, collection_key: str) -> None: + """ + Celery task to update the content index documents for a library collection + """ + library_key = LibraryLocatorV2.from_string(library_key_str) + + log.info("Updating content index documents for collection %s in library%s", collection_key, library_key) + + api.upsert_library_collection_index_doc(library_key, collection_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index e8616cee60a8..4aa41a156dab 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -6,12 +6,13 @@ import copy from datetime import datetime, timezone -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, Mock, call, patch from opaque_keys.edx.keys import UsageKey import ddt from django.test import override_settings from freezegun import freeze_time +from openedx_learning.api import authoring as authoring_api from organizations.tests.factories import OrganizationFactory from common.djangoapps.student.tests.factories import UserFactory @@ -174,6 +175,33 @@ def setUp(self): tagging_api.add_tag_to_taxonomy(self.taxonomyB, "three") tagging_api.add_tag_to_taxonomy(self.taxonomyB, "four") + # Create a collection: + self.learning_package = authoring_api.get_learning_package_by_key(self.library.key) + with freeze_time(created_date): + self.collection = authoring_api.create_collection( + learning_package_id=self.learning_package.id, + key="MYCOL", + title="my_collection", + created_by=None, + description="my collection description" + ) + self.collection_usage_key = "lib-collection:org1:lib:MYCOL" + self.collection_dict = { + "id": self.collection.id, + "block_id": self.collection.key, + "usage_key": self.collection_usage_key, + "type": "collection", + "display_name": "my_collection", + "description": "my collection description", + "num_children": 0, + "context_key": "lib:org1:lib", + "org": "org1", + "created": created_date.timestamp(), + "modified": created_date.timestamp(), + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + } + @override_settings(MEILISEARCH_ENABLED=False) def test_reindex_meilisearch_disabled(self, mock_meilisearch): with self.assertRaises(RuntimeError): @@ -191,18 +219,40 @@ def test_reindex_meilisearch(self, mock_meilisearch): doc_vertical["tags"] = {} doc_problem1 = copy.deepcopy(self.doc_problem1) doc_problem1["tags"] = {} + doc_problem1["collections"] = {} doc_problem2 = copy.deepcopy(self.doc_problem2) doc_problem2["tags"] = {} + doc_problem2["collections"] = {} + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["tags"] = {} api.rebuild_index() + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls( [ call([doc_sequential, doc_vertical]), call([doc_problem1, doc_problem2]), + call([doc_collection]), ], any_order=True, ) + @override_settings(MEILISEARCH_ENABLED=True) + @patch( + "openedx.core.djangoapps.content.search.api.searchable_doc_for_collection", + Mock(side_effect=Exception("Failed to generate document")), + ) + def test_reindex_meilisearch_collection_error(self, mock_meilisearch): + + mock_logger = Mock() + api.rebuild_index(mock_logger) + assert call( + [self.collection_dict] + ) not in mock_meilisearch.return_value.index.return_value.add_documents.mock_calls + mock_logger.assert_any_call( + f"Error indexing collection {self.collection}: Failed to generate document" + ) + @override_settings(MEILISEARCH_ENABLED=True) def test_reindex_meilisearch_library_block_error(self, mock_meilisearch): @@ -213,6 +263,7 @@ def test_reindex_meilisearch_library_block_error(self, mock_meilisearch): doc_vertical["tags"] = {} doc_problem2 = copy.deepcopy(self.doc_problem2) doc_problem2["tags"] = {} + doc_problem2["collections"] = {} orig_from_component = library_api.LibraryXBlockMetadata.from_component @@ -305,6 +356,7 @@ def test_index_xblock_tags(self, mock_meilisearch): } } + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( [ call([doc_sequential_with_tags1]), @@ -359,6 +411,7 @@ def test_index_library_block_tags(self, mock_meilisearch): } } + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( [ call([doc_problem_with_tags1]), @@ -367,6 +420,134 @@ def test_index_library_block_tags(self, mock_meilisearch): any_order=True, ) + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_library_block_and_collections(self, mock_meilisearch): + """ + Test indexing an Library Block and the Collections it's in. + """ + # Create collections (these internally call `upsert_library_collection_index_doc`) + created_date = datetime(2023, 5, 6, 7, 8, 9, tzinfo=timezone.utc) + with freeze_time(created_date): + collection1 = library_api.create_library_collection( + self.library.key, + collection_key="COL1", + title="Collection 1", + created_by=None, + description="First Collection", + ) + + collection2 = library_api.create_library_collection( + self.library.key, + collection_key="COL2", + title="Collection 2", + created_by=None, + description="Second Collection", + ) + + # Add Problem1 to both Collections (these internally call `upsert_block_collections_index_docs` and + # `upsert_library_collection_index_doc`) + # (adding in reverse order to test sorting of collection tag) + updated_date = datetime(2023, 6, 7, 8, 9, 10, tzinfo=timezone.utc) + with freeze_time(updated_date): + for collection in (collection2, collection1): + library_api.update_library_collection_components( + self.library.key, + collection_key=collection.key, + usage_keys=[ + self.problem1.usage_key, + ], + ) + + # Build expected docs at each stage + lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key) + doc_collection1_created = { + "id": collection1.id, + "block_id": collection1.key, + "usage_key": f"lib-collection:org1:lib:{collection1.key}", + "type": "collection", + "display_name": "Collection 1", + "description": "First Collection", + "num_children": 0, + "context_key": "lib:org1:lib", + "org": "org1", + "created": created_date.timestamp(), + "modified": created_date.timestamp(), + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + } + doc_collection2_created = { + "id": collection2.id, + "block_id": collection2.key, + "usage_key": f"lib-collection:org1:lib:{collection2.key}", + "type": "collection", + "display_name": "Collection 2", + "description": "Second Collection", + "num_children": 0, + "context_key": "lib:org1:lib", + "org": "org1", + "created": created_date.timestamp(), + "modified": created_date.timestamp(), + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + } + doc_collection2_updated = { + "id": collection2.id, + "block_id": collection2.key, + "usage_key": f"lib-collection:org1:lib:{collection2.key}", + "type": "collection", + "display_name": "Collection 2", + "description": "Second Collection", + "num_children": 1, + "context_key": "lib:org1:lib", + "org": "org1", + "created": created_date.timestamp(), + "modified": updated_date.timestamp(), + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + } + doc_collection1_updated = { + "id": collection1.id, + "block_id": collection1.key, + "usage_key": f"lib-collection:org1:lib:{collection1.key}", + "type": "collection", + "display_name": "Collection 1", + "description": "First Collection", + "num_children": 1, + "context_key": "lib:org1:lib", + "org": "org1", + "created": created_date.timestamp(), + "modified": updated_date.timestamp(), + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + } + doc_problem_with_collection1 = { + "id": self.doc_problem1["id"], + "collections": { + "display_name": ["Collection 2"], + "key": ["COL2"], + }, + } + doc_problem_with_collection2 = { + "id": self.doc_problem1["id"], + "collections": { + "display_name": ["Collection 1", "Collection 2"], + "key": ["COL1", "COL2"], + }, + } + + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 6 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection1_created]), + call([doc_collection2_created]), + call([doc_collection2_updated]), + call([doc_collection1_updated]), + call([doc_problem_with_collection1]), + call([doc_problem_with_collection2]), + ], + any_order=True, + ) + @override_settings(MEILISEARCH_ENABLED=True) def test_delete_index_library_block(self, mock_meilisearch): """ @@ -403,3 +584,34 @@ def test_delete_all_drafts(self, mock_meilisearch): mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( filter=delete_filter ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_tags_in_collections(self, mock_meilisearch): + # Tag collection + tagging_api.tag_object(self.collection_usage_key, self.taxonomyA, ["one", "two"]) + tagging_api.tag_object(self.collection_usage_key, self.taxonomyB, ["three", "four"]) + + # Build expected docs with tags at each stage + doc_collection_with_tags1 = { + "id": self.collection.id, + "tags": { + 'taxonomy': ['A'], + 'level0': ['A > one', 'A > two'] + } + } + doc_collection_with_tags2 = { + "id": self.collection.id, + "tags": { + 'taxonomy': ['A', 'B'], + 'level0': ['A > one', 'A > two', 'B > four', 'B > three'] + } + } + + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection_with_tags1]), + call([doc_collection_with_tags2]), + ], + any_order=True, + ) diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index e853fd425273..9d51bd127bb4 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -1,9 +1,14 @@ """ Tests for the Studio content search documents (what gets stored in the index) """ +from datetime import datetime, timezone from organizations.models import Organization +from freezegun import freeze_time +from openedx_learning.api import authoring as authoring_api + from openedx.core.djangoapps.content_tagging import api as tagging_api +from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -11,10 +16,21 @@ try: # This import errors in the lms because content.search is not an installed app there. - from ..documents import searchable_doc_for_course_block, searchable_doc_tags + from ..documents import ( + searchable_doc_for_course_block, + searchable_doc_tags, + searchable_doc_tags_for_collection, + searchable_doc_collections, + searchable_doc_for_collection, + searchable_doc_for_library_block, + ) from ..models import SearchAccess except RuntimeError: searchable_doc_for_course_block = lambda x: x + searchable_doc_tags = lambda x: x + searchable_doc_tags_for_collection = lambda x: x + searchable_doc_for_collection = lambda x: x + searchable_doc_for_library_block = lambda x: x SearchAccess = {} @@ -32,12 +48,13 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): def setUpClass(cls): super().setUpClass() cls.store = modulestore() + cls.org = Organization.objects.create(name="edX", short_name="edX") cls.toy_course = ToyCourseFactory.create() # See xmodule/modulestore/tests/sample_courses.py cls.toy_course_key = cls.toy_course.id # Get references to some blocks in the toy course cls.html_block_key = cls.toy_course_key.make_usage_key("html", "toyjumpto") - # Create a problem in library + # Create a problem in course cls.problem_block = BlockFactory.create( category="problem", parent_location=cls.toy_course_key.make_usage_key("vertical", "vertical_test"), @@ -45,8 +62,39 @@ def setUpClass(cls): data="What is a test?", ) + # Create a library and collection with a block + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + cls.library = library_api.create_library( + org=cls.org, + slug="2012_Fall", + title="some content_library", + description="some description", + ) + cls.collection = library_api.create_library_collection( + cls.library.key, + collection_key="TOY_COLLECTION", + title="Toy Collection", + created_by=None, + description="my toy collection description" + ) + cls.collection_usage_key = "lib-collection:edX:2012_Fall:TOY_COLLECTION" + cls.library_block = library_api.create_library_block( + cls.library.key, + "html", + "text2", + ) + + # Add the problem block to the collection + library_api.update_library_collection_components( + cls.library.key, + collection_key="TOY_COLLECTION", + usage_keys=[ + cls.library_block.usage_key, + ] + ) + # Create a couple taxonomies and some tags - cls.org = Organization.objects.create(name="edX", short_name="edX") cls.difficulty_tags = tagging_api.create_taxonomy(name="Difficulty", orgs=[cls.org], allow_multiple=False) tagging_api.add_tag_to_taxonomy(cls.difficulty_tags, tag="Easy") tagging_api.add_tag_to_taxonomy(cls.difficulty_tags, tag="Normal") @@ -63,6 +111,8 @@ def setUpClass(cls): tagging_api.tag_object(str(cls.problem_block.usage_key), cls.difficulty_tags, tags=["Easy"]) tagging_api.tag_object(str(cls.html_block_key), cls.subject_tags, tags=["Chinese", "Jump Links"]) tagging_api.tag_object(str(cls.html_block_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(str(cls.library_block.usage_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(cls.collection_usage_key, cls.difficulty_tags, tags=["Normal"]) @property def toy_course_access_id(self): @@ -74,6 +124,16 @@ def toy_course_access_id(self): """ return SearchAccess.objects.get(context_key=self.toy_course_key).id + @property + def library_access_id(self): + """ + Returns the SearchAccess.id created for the library. + + This SearchAccess object is created when documents are added to the search index, so this method must be called + after this step, or risk a DoesNotExist error. + """ + return SearchAccess.objects.get(context_key=self.library.key).id + def test_problem_block(self): """ Test how a problem block gets represented in the search index @@ -198,3 +258,96 @@ def test_video_block_untagged(self): "content": {}, # This video has no tags. } + + def test_html_library_block(self): + """ + Test how a library block gets represented in the search index + """ + doc = {} + doc.update(searchable_doc_for_library_block(self.library_block)) + doc.update(searchable_doc_tags(self.library_block.usage_key)) + doc.update(searchable_doc_collections(self.library_block.usage_key)) + assert doc == { + "id": "lbedx2012_fallhtmltext2-4bb47d67", + "type": "library_block", + "block_type": "html", + "usage_key": "lb:edX:2012_Fall:html:text2", + "block_id": "text2", + "context_key": "lib:edX:2012_Fall", + "org": "edX", + "access_id": self.library_access_id, + "display_name": "Text", + "breadcrumbs": [ + { + "display_name": "some content_library", + }, + ], + "last_published": None, + "created": 1680674828.0, + "modified": 1680674828.0, + "content": { + "html_content": "", + }, + "collections": { + "key": ["TOY_COLLECTION"], + "display_name": ["Toy Collection"], + }, + "tags": { + "taxonomy": ["Difficulty"], + "level0": ["Difficulty > Normal"], + }, + } + + def test_collection_with_library(self): + doc = searchable_doc_for_collection(self.collection) + doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection)) + + assert doc == { + "id": self.collection.id, + "block_id": self.collection.key, + "usage_key": self.collection_usage_key, + "type": "collection", + "org": "edX", + "display_name": "Toy Collection", + "description": "my toy collection description", + "num_children": 1, + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + 'tags': { + 'taxonomy': ['Difficulty'], + 'level0': ['Difficulty > Normal'] + } + } + + def test_collection_with_no_library(self): + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + learning_package = authoring_api.create_learning_package( + key="course-v1:edX+toy+2012_Fall", + title="some learning_package", + description="some description", + ) + collection = authoring_api.create_collection( + learning_package_id=learning_package.id, + key="MYCOL", + title="my_collection", + created_by=None, + description="my collection description" + ) + doc = searchable_doc_for_collection(collection) + assert doc == { + "id": collection.id, + "block_id": collection.key, + "type": "collection", + "display_name": "my_collection", + "description": "my collection description", + "num_children": 0, + "context_key": learning_package.key, + "access_id": self.toy_course_access_id, + "breadcrumbs": [{"display_name": "some learning_package"}], + "created": created_date.timestamp(), + "modified": created_date.timestamp(), + } diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 17bea80b3a96..3dc33aec9616 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -69,24 +69,33 @@ from django.utils.translation import gettext as _ from edx_rest_api_client.client import OAuthAPIClient from lxml import etree -from opaque_keys.edx.keys import UsageKey, UsageKeyV2 +from opaque_keys.edx.keys import BlockTypeKey, UsageKey, UsageKeyV2 from opaque_keys.edx.locator import ( LibraryLocatorV2, LibraryUsageLocatorV2, - LibraryLocator as LibraryLocatorV1 + LibraryLocator as LibraryLocatorV1, + LibraryCollectionLocator, ) from opaque_keys import InvalidKeyError -from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData +from openedx_events.content_authoring.data import ( + ContentLibraryData, + ContentObjectChangedData, + LibraryBlockData, + LibraryCollectionData, +) from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, ) from openedx_learning.api import authoring as authoring_api -from openedx_learning.api.authoring_models import Component, MediaType +from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage, PublishableEntity from organizations.models import Organization from xblock.core import XBlock from xblock.exceptions import XBlockNotFoundError @@ -111,6 +120,8 @@ ContentLibraryNotFound = ContentLibrary.DoesNotExist +ContentLibraryCollectionNotFound = Collection.DoesNotExist + class ContentLibraryBlockNotFound(XBlockNotFoundError): """ XBlock not found in the content library """ @@ -120,6 +131,10 @@ class LibraryAlreadyExists(KeyError): """ A library with the specified slug already exists """ +class LibraryCollectionAlreadyExists(IntegrityError): + """ A Collection with that key already exists in the library """ + + class LibraryBlockAlreadyExists(KeyError): """ An XBlock with that ID already exists in the library """ @@ -150,6 +165,7 @@ class ContentLibraryMetadata: Class that represents the metadata about a content library. """ key = attr.ib(type=LibraryLocatorV2) + learning_package = attr.ib(type=LearningPackage) title = attr.ib("") description = attr.ib("") num_blocks = attr.ib(0) @@ -203,8 +219,12 @@ class LibraryXBlockMetadata: modified = attr.ib(type=datetime) display_name = attr.ib("") last_published = attr.ib(default=None, type=datetime) + last_draft_created = attr.ib(default=None, type=datetime) + last_draft_created_by = attr.ib("") + published_by = attr.ib("") has_unpublished_changes = attr.ib(False) tags_count = attr.ib(0) + created = attr.ib(default=None, type=datetime) @classmethod def from_component(cls, library_key, component): @@ -213,6 +233,14 @@ def from_component(cls, library_key, component): """ last_publish_log = component.versioning.last_publish_log + published_by = None + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = component.versioning.draft + last_draft_created = draft.created if draft else None + last_draft_created_by = draft.publishable_entity_version.created_by if draft else None + return cls( usage_key=LibraryUsageLocatorV2( library_key, @@ -223,7 +251,10 @@ def from_component(cls, library_key, component): created=component.created, modified=component.versioning.draft.created, last_published=None if last_publish_log is None else last_publish_log.published_at, - has_unpublished_changes=component.versioning.has_unpublished_changes + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=component.versioning.has_unpublished_changes, ) @@ -323,13 +354,14 @@ def get_metadata(queryset, text_search=None): has_unpublished_changes=False, has_unpublished_deletes=False, license=lib.license, + learning_package=lib.learning_package, ) for lib in queryset ] return libraries -def require_permission_for_library_key(library_key, user, permission): +def require_permission_for_library_key(library_key, user, permission) -> ContentLibrary: """ Given any of the content library permission strings defined in openedx.core.djangoapps.content_libraries.permissions, @@ -339,10 +371,12 @@ def require_permission_for_library_key(library_key, user, permission): Raises django.core.exceptions.PermissionDenied if the user doesn't have permission. """ - library_obj = ContentLibrary.objects.get_by_key(library_key) + library_obj = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] if not user.has_perm(permission, obj=library_obj): raise PermissionDenied + return library_obj + def get_library(library_key): """ @@ -408,6 +442,7 @@ def get_library(library_key): license=ref.license, created=learning_package.created, updated=learning_package.updated, + learning_package=learning_package ) @@ -479,6 +514,7 @@ def create_library( allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, license=library_license, + learning_package=ref.learning_package ) @@ -1056,6 +1092,214 @@ def revert_changes(library_key): ) +def create_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + title: str, + *, + description: str = "", + created_by: int | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary, + and emits a LIBRARY_COLLECTION_CREATED event. + + If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.create_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + created_by=created_by, + ) + except IntegrityError as err: + raise LibraryCollectionAlreadyExists from err + + # Emit event for library collection created + LIBRARY_COLLECTION_CREATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + return collection + + +def update_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + *, + title: str | None = None, + description: str | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary, + and emits a LIBRARY_COLLECTION_CREATED event. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.update_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc + + # Emit event for library collection updated + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + return collection + + +def update_library_collection_components( + library_key: LibraryLocatorV2, + collection_key: str, + *, + usage_keys: list[UsageKeyV2], + created_by: int | None = None, + remove=False, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Associates the Collection with Components for the given UsageKeys. + + By default the Components are added to the Collection. + If remove=True, the Components are removed from the Collection. + + If you've already fetched the ContentLibrary, pass it in to avoid refetching. + + Raises: + * ContentLibraryCollectionNotFound if no Collection with the given pk is found in the given library. + * ContentLibraryBlockNotFound if any of the given usage_keys don't match Components in the given library. + + Returns the updated Collection. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + # Fetch the Component.key values for the provided UsageKeys. + component_keys = [] + for usage_key in usage_keys: + # Parse the block_family from the key to use as namespace. + block_type = BlockTypeKey.from_string(str(usage_key)) + + try: + component = authoring_api.get_component_by_key( + content_library.learning_package_id, + namespace=block_type.block_family, + type_name=usage_key.block_type, + local_key=usage_key.block_id, + ) + except Component.DoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + component_keys.append(component.key) + + # Note: Component.key matches its PublishableEntity.key + entities_qset = PublishableEntity.objects.filter( + key__in=component_keys, + ) + + if remove: + collection = authoring_api.remove_from_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + ) + else: + collection = authoring_api.add_to_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + created_by=created_by, + ) + + # Emit event for library collection updated + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + # Emit a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for each of the objects added/removed + for usage_key in usage_keys: + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(usage_key), + changes=["collections"], + ), + ) + + return collection + + +def get_library_collection_usage_key( + library_key: LibraryLocatorV2, + collection_key: str, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> LibraryCollectionLocator: + """ + Returns the LibraryCollectionLocator associated to a collection + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + return LibraryCollectionLocator(library_key, collection_key) + + +def get_library_collection_from_usage_key( + collection_usage_key: LibraryCollectionLocator, +) -> Collection: + """ + Return a Collection using the LibraryCollectionLocator + """ + + library_key = collection_usage_key.library_key + collection_key = collection_usage_key.collection_id + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + try: + return authoring_api.get_collection( + content_library.learning_package_id, + collection_key, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc + + # V1/V2 Compatibility Helpers # (Should be removed as part of # https://github.com/openedx/edx-platform/issues/32457) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 497eda81475b..e9e04646ace4 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -4,7 +4,12 @@ # pylint: disable=abstract-method from django.core.validators import validate_unicode_slug from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from opaque_keys.edx.keys import UsageKeyV2 +from opaque_keys import InvalidKeyError + +from openedx_learning.api.authoring_models import Collection from openedx.core.djangoapps.content_libraries.constants import ( LIBRARY_TYPES, COMPLEX, @@ -143,7 +148,12 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer): block_type = serializers.CharField(source="usage_key.block_type") display_name = serializers.CharField(read_only=True) + last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + published_by = serializers.CharField(read_only=True) + last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + last_draft_created_by = serializers.CharField(read_only=True) has_unpublished_changes = serializers.BooleanField(read_only=True) + created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) # When creating a new XBlock in a library, the slug becomes the ID part of # the definition key and usage key: @@ -245,3 +255,52 @@ class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer): """ course_key = CourseKeyField() + + +class ContentLibraryCollectionSerializer(serializers.ModelSerializer): + """ + Serializer for a Content Library Collection + """ + + class Meta: + model = Collection + fields = '__all__' + + +class ContentLibraryCollectionUpdateSerializer(serializers.Serializer): + """ + Serializer for updating a Collection in a Content Library + """ + + title = serializers.CharField() + description = serializers.CharField(allow_blank=True) + + +class UsageKeyV2Serializer(serializers.Serializer): + """ + Serializes a UsageKeyV2. + """ + def to_representation(self, value: UsageKeyV2) -> str: + """ + Returns the UsageKeyV2 value as a string. + """ + return str(value) + + def to_internal_value(self, value: str) -> UsageKeyV2: + """ + Returns a UsageKeyV2 from the string value. + + Raises ValidationError if invalid UsageKeyV2. + """ + try: + return UsageKeyV2.from_string(value) + except InvalidKeyError as err: + raise ValidationError from err + + +class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer): + """ + Serializer for adding/removing Components to/from a Collection. + """ + + usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 87ae180d291e..b02e71b002a3 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -13,8 +13,20 @@ UsageKey, ) from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_events.content_authoring.data import ( + ContentObjectChangedData, + LibraryCollectionData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, +) +from openedx_events.tests.utils import OpenEdxEventsTestMixin from .. import api +from ..models import ContentLibrary +from .base import ContentLibrariesRestApiTest class EdxModulestoreImportClientTest(TestCase): @@ -241,3 +253,222 @@ def test_import_block_when_url_is_from_studio( block_olx ) mock_publish_changes.assert_not_called() + + +class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): + """ + Tests for Content Library API collections methods. + + Same guidelines as ContentLibrariesTestCase. + """ + ENABLED_OPENEDX_EVENTS = [ + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.event_type, + LIBRARY_COLLECTION_CREATED.event_type, + LIBRARY_COLLECTION_UPDATED.event_type, + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + + TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on + OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, + so we're following a pattern here. But that pattern doesn't really make sense. + """ + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-col-1", "Test Library 1") + self._create_library("test-lib-col-2", "Test Library 2") + + # Fetch the created ContentLibrare objects so we can access their learning_package.id + self.lib1 = ContentLibrary.objects.get(slug="test-lib-col-1") + self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2") + + # Create Content Library Collections + self.col1 = api.create_library_collection( + self.lib1.library_key, + collection_key="COL1", + title="Collection 1", + description="Description for Collection 1", + created_by=self.user.id, + ) + self.col2 = api.create_library_collection( + self.lib2.library_key, + collection_key="COL2", + title="Collection 2", + description="Description for Collection 2", + created_by=self.user.id, + ) + + # Create some library blocks in lib1 + self.lib1_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.lib1_html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + + def test_create_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_CREATED.connect(event_receiver) + + collection = api.create_library_collection( + self.lib2.library_key, + collection_key="COL4", + title="Collection 4", + description="Description for Collection 4", + created_by=self.user.id, + ) + assert collection.key == "COL4" + assert collection.title == "Collection 4" + assert collection.description == "Description for Collection 4" + assert collection.created_by == self.user + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_CREATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib2.library_key, + collection_key="COL4", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_create_library_collection_invalid_library(self): + library_key = LibraryLocatorV2.from_string("lib:INVALID:test-lib-does-not-exist") + with self.assertRaises(api.ContentLibraryNotFound) as exc: + api.create_library_collection( + library_key, + collection_key="COL4", + title="Collection 3", + ) + + def test_update_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + self.col1 = api.update_library_collection( + self.lib1.library_key, + self.col1.key, + title="New title for Collection 1", + ) + assert self.col1.key == "COL1" + assert self.col1.title == "New title for Collection 1" + assert self.col1.description == "Description for Collection 1" + assert self.col1.created_by == self.user + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_update_library_collection_wrong_library(self): + with self.assertRaises(api.ContentLibraryCollectionNotFound) as exc: + api.update_library_collection( + self.lib1.library_key, + self.col2.key, + ) + + def test_update_library_collection_components(self): + assert not list(self.col1.entities.all()) + + self.col1 = api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + assert len(self.col1.entities.all()) == 2 + + self.col1 = api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_html_block["id"]), + ], + remove=True, + ) + assert len(self.col1.entities.all()) == 1 + + def test_update_library_collection_components_event(self): + """ + Check that a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event is raised for each added/removed component. + """ + event_receiver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + + assert event_receiver.call_count == 3 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=self.lib1_problem_block["id"], + changes=["collections"], + ), + }, + event_receiver.call_args_list[1].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=self.lib1_html_block["id"], + changes=["collections"], + ), + }, + event_receiver.call_args_list[2].kwargs, + ) + + def test_update_collection_components_from_wrong_library(self): + with self.assertRaises(api.ContentLibraryBlockNotFound) as exc: + api.update_library_collection_components( + self.lib2.library_key, + self.col2.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + assert self.lib1_problem_block["id"] in str(exc.exception) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 95b7309b3cd1..677178bb3b31 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -5,9 +5,11 @@ from unittest import skip import ddt +from datetime import datetime, timezone from uuid import uuid4 from django.contrib.auth.models import Group from django.test.client import Client +from freezegun import freeze_time from organizations.models import Organization from rest_framework.test import APITestCase @@ -270,12 +272,18 @@ def test_library_blocks(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'problem' XBlock to the library: - block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) + with freeze_time(create_date): + block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") self.assertDictContainsEntries(block_data, { "id": "lb:CL-TEST:téstlꜟط:problem:ࠒröblæm1", "display_name": "Blank Problem", "block_type": "problem", "has_unpublished_changes": True, + "last_published": None, + "published_by": None, + "last_draft_created": create_date.isoformat().replace('+00:00', 'Z'), + "last_draft_created_by": "Bob", }) block_id = block_data["id"] # Confirm that the result contains a definition key, but don't check its value, @@ -287,10 +295,14 @@ def test_library_blocks(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - self._commit_library_changes(lib_id) + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) + with freeze_time(publish_date): + self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False # And now the block information should also show that block has no unpublished changes: block_data["has_unpublished_changes"] = False + block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') + block_data["published_by"] = "Bob" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -311,13 +323,16 @@ def test_library_blocks(self): """.strip() - self._set_library_block_olx(block_id, new_olx) + update_date = datetime(2024, 8, 8, 8, 8, 8, tzinfo=timezone.utc) + with freeze_time(update_date): + self._set_library_block_olx(block_id, new_olx) # now reading it back, we should get that exact OLX (no change to whitespace etc.): assert self._get_library_block_olx(block_id) == new_olx # And the display name and "unpublished changes" status of the block should be updated: self.assertDictContainsEntries(self._get_library_block(block_id), { "display_name": "New Multi Choice Question", "has_unpublished_changes": True, + "last_draft_created": update_date.isoformat().replace('+00:00', 'Z') }) # Now view the XBlock's student_view (including draft changes): @@ -358,12 +373,18 @@ def test_library_blocks_studio_view(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'html' XBlock to the library: - block_data = self._add_block_to_library(lib_id, "html", "html1") + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) + with freeze_time(create_date): + block_data = self._add_block_to_library(lib_id, "html", "html1") self.assertDictContainsEntries(block_data, { "id": "lb:CL-TEST:testlib2:html:html1", "display_name": "Text", "block_type": "html", "has_unpublished_changes": True, + "last_published": None, + "published_by": None, + "last_draft_created": create_date.isoformat().replace('+00:00', 'Z'), + "last_draft_created_by": "Bob", }) block_id = block_data["id"] @@ -372,10 +393,14 @@ def test_library_blocks_studio_view(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - self._commit_library_changes(lib_id) + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) + with freeze_time(publish_date): + self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False # And now the block information should also show that block has no unpublished changes: block_data["has_unpublished_changes"] = False + block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') + block_data["published_by"] = "Bob" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -383,13 +408,17 @@ def test_library_blocks_studio_view(self): orig_olx = self._get_library_block_olx(block_id) assert '/', include([ # Get metadata about a specific XBlock in this library, or delete the block: diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index bde8142d3fcc..835a5de1f1f2 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -84,6 +84,7 @@ from pylti1p3.exception import LtiException, OIDCException import edx_api_doc_tools as apidocs +from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException @@ -136,12 +137,21 @@ def convert_exceptions(fn): def wrapped_fn(*args, **kwargs): try: return fn(*args, **kwargs) + except InvalidKeyError as exc: + log.exception(str(exc)) + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.ContentLibraryNotFound: log.exception("Content library not found") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.ContentLibraryBlockNotFound: log.exception("XBlock not found in content library") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryCollectionNotFound: + log.exception("Collection not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryCollectionAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from except api.LibraryBlockAlreadyExists as exc: log.exception(str(exc)) raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/views_collections.py new file mode 100644 index 000000000000..2f40a1788628 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/views_collections.py @@ -0,0 +1,198 @@ +""" +Collections API Views +""" + +from __future__ import annotations + +from django.db.models import QuerySet +from django.utils.text import slugify +from django.db import transaction + +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED + +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Collection + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.djangoapps.content_libraries.models import ContentLibrary +from openedx.core.djangoapps.content_libraries.views import convert_exceptions +from openedx.core.djangoapps.content_libraries.serializers import ( + ContentLibraryCollectionSerializer, + ContentLibraryCollectionComponentsUpdateSerializer, + ContentLibraryCollectionUpdateSerializer, +) + + +class LibraryCollectionsView(ModelViewSet): + """ + Views to get, create and update Library Collections. + """ + + serializer_class = ContentLibraryCollectionSerializer + lookup_field = 'key' + + def __init__(self, *args, **kwargs) -> None: + """ + Caches the ContentLibrary for the duration of the request. + """ + super().__init__(*args, **kwargs) + self._content_library: ContentLibrary | None = None + + def get_content_library(self) -> ContentLibrary: + """ + Returns the requested ContentLibrary object, if access allows. + """ + if self._content_library: + return self._content_library + + lib_key_str = self.kwargs["lib_key_str"] + library_key = LibraryLocatorV2.from_string(lib_key_str) + permission = ( + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY + if self.request.method in ['OPTIONS', 'GET'] + else permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + + self._content_library = api.require_permission_for_library_key( + library_key, + self.request.user, + permission, + ) + return self._content_library + + def get_queryset(self) -> QuerySet[Collection]: + """ + Returns a queryset for the requested Collections, if access allows. + + This method may raise exceptions; these are handled by the @convert_exceptions wrapper on the views. + """ + content_library = self.get_content_library() + assert content_library.learning_package_id + return authoring_api.get_collections(content_library.learning_package_id) + + def get_object(self) -> Collection: + """ + Returns the requested Collections, if access allows. + + This method may raise exceptions; these are handled by the @convert_exceptions wrapper on the views. + """ + collection = super().get_object() + content_library = self.get_content_library() + + # Ensure the ContentLibrary and Collection share the same learning package + if collection.learning_package_id != content_library.learning_package_id: + raise api.ContentLibraryCollectionNotFound + return collection + + @convert_exceptions + def retrieve(self, request, *args, **kwargs) -> Response: + """ + Retrieve the Content Library Collection + """ + # View declared so we can wrap it in @convert_exceptions + return super().retrieve(request, *args, **kwargs) + + @convert_exceptions + def list(self, request, *args, **kwargs) -> Response: + """ + List Collections that belong to Content Library + """ + # View declared so we can wrap it in @convert_exceptions + return super().list(request, *args, **kwargs) + + @convert_exceptions + def create(self, request, *args, **kwargs) -> Response: + """ + Create a Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + create_serializer = ContentLibraryCollectionUpdateSerializer(data=request.data) + create_serializer.is_valid(raise_exception=True) + + title = create_serializer.validated_data['title'] + key = slugify(title) + + attempt = 0 + collection = None + while not collection: + modified_key = key if attempt == 0 else key + '-' + str(attempt) + try: + # Add transaction here to avoid TransactionManagementError on retry + with transaction.atomic(): + collection = api.create_library_collection( + library_key=content_library.library_key, + content_library=content_library, + collection_key=modified_key, + title=title, + description=create_serializer.validated_data["description"], + created_by=request.user.id, + ) + except api.LibraryCollectionAlreadyExists: + attempt += 1 + + serializer = self.get_serializer(collection) + + return Response(serializer.data) + + @convert_exceptions + def partial_update(self, request, *args, **kwargs) -> Response: + """ + Update a Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + collection_key = kwargs["key"] + + update_serializer = ContentLibraryCollectionUpdateSerializer( + data=request.data, partial=True + ) + update_serializer.is_valid(raise_exception=True) + updated_collection = api.update_library_collection( + library_key=content_library.library_key, + collection_key=collection_key, + content_library=content_library, + **update_serializer.validated_data + ) + serializer = self.get_serializer(updated_collection) + + return Response(serializer.data) + + @convert_exceptions + def destroy(self, request, *args, **kwargs) -> Response: + """ + Deletes a Collection that belongs to a Content Library + + Note: (currently not allowed) + """ + # TODO: Implement the deletion logic and emit event signal + + return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) + + @convert_exceptions + @action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update') + def update_components(self, request, *args, **kwargs) -> Response: + """ + Adds (PATCH) or removes (DELETE) Components to/from a Collection. + + Collection and Components must all be part of the given library/learning package. + """ + content_library = self.get_content_library() + collection_key = kwargs["key"] + + serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + usage_keys = serializer.validated_data["usage_keys"] + api.update_library_collection_components( + library_key=content_library.library_key, + content_library=content_library, + collection_key=collection_key, + usage_keys=usage_keys, + created_by=self.request.user.id, + remove=(request.method == "DELETE"), + ) + + return Response({'count': len(usage_keys)}) diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index 47b157c1a34e..f015770e5db8 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -12,14 +12,17 @@ import openedx_tagging.core.tagging.api as oel_tagging from django.db.models import Exists, OuterRef, Q, QuerySet from django.utils.timezone import now -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, LibraryCollectionKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy from openedx_tagging.core.tagging.models.utils import TAGS_CSV_SEPARATOR from organizations.models import Organization from .helpers.objecttag_export_helpers import build_object_tree_with_objecttags, iterate_with_level -from openedx_events.content_authoring.data import ContentObjectData -from openedx_events.content_authoring.signals import CONTENT_OBJECT_TAGS_CHANGED +from openedx_events.content_authoring.data import ContentObjectData, ContentObjectChangedData +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + CONTENT_OBJECT_TAGS_CHANGED, +) from .models import TaxonomyOrg from .types import ContentKey, TagValuesByObjectIdDict, TagValuesByTaxonomyIdDict, TaxonomyDict @@ -227,7 +230,7 @@ def generate_csv_rows(object_id, buffer) -> Iterator[str]: """ content_key = get_content_key_from_string(object_id) - if isinstance(content_key, UsageKey): + if isinstance(content_key, (UsageKey, LibraryCollectionKey)): raise ValueError("The object_id must be a CourseKey or a LibraryLocatorV2.") all_object_tags, taxonomies = get_all_object_tags(content_key) @@ -301,6 +304,16 @@ def set_exported_object_tags( create_invalid=True, taxonomy_export_id=str(taxonomy_export_id), ) + + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + time=now(), + content_object=ContentObjectChangedData( + object_id=content_key_str, + changes=["tags"], + ) + ) + + # Emit a (deprecated) CONTENT_OBJECT_TAGS_CHANGED event too CONTENT_OBJECT_TAGS_CHANGED.send_event( time=now(), content_object=ContentObjectData(object_id=content_key_str) @@ -378,7 +391,7 @@ def tag_object( Replaces the existing ObjectTag entries for the given taxonomy + object_id with the given list of tags, if the taxonomy can be used by the given object_id. - This is a wrapper around oel_tagging.tag_object that adds emitting the `CONTENT_OBJECT_TAGS_CHANGED` event + This is a wrapper around oel_tagging.tag_object that adds emitting the `CONTENT_OBJECT_ASSOCIATIONS_CHANGED` event when tagging an object. tags: A list of the values of the tags from this taxonomy to apply. @@ -399,6 +412,15 @@ def tag_object( taxonomy=taxonomy, tags=tags, ) + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + time=now(), + content_object=ContentObjectChangedData( + object_id=object_id, + changes=["tags"], + ) + ) + + # Emit a (deprecated) CONTENT_OBJECT_TAGS_CHANGED event too CONTENT_OBJECT_TAGS_CHANGED.send_event( time=now(), content_object=ContentObjectData(object_id=object_id) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index f64fba5c3358..e386ee234226 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -13,7 +13,7 @@ import ddt from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile -from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator from openedx_tagging.core.tagging.models import Tag, Taxonomy from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy from openedx_tagging.core.tagging.rest_api.v1.serializers import TaxonomySerializer @@ -111,6 +111,9 @@ def _setUp_library(self): ) self.libraryA = str(self.content_libraryA.key) + def _setUp_collection(self): + self.collection_key = str(LibraryCollectionLocator(self.content_libraryA.key, 'test-collection')) + def _setUp_users(self): """ Create users for testing @@ -284,6 +287,7 @@ def setUp(self): self._setUp_library() self._setUp_users() self._setUp_taxonomies() + self._setUp_collection() # Clear the rules cache in between test runs to keep query counts consistent. rules_cache.clear() @@ -1653,6 +1657,87 @@ def test_tag_library_invalid(self, user_attr, taxonomy_attr): response = self._call_put_request(self.libraryA, taxonomy.pk, ["invalid"]) assert response.status_code == status.HTTP_400_BAD_REQUEST + @ddt.data( + # staffA and staff are staff in collection and can tag using enabled taxonomies + ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("user", "tA1", [], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), + ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ) + @ddt.unpack + def test_tag_collection(self, user_attr, taxonomy_attr, tag_values, expected_status): + """ + Tests that only staff and org level users can tag collections + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.collection_key, taxonomy.pk, tag_values) + + assert response.status_code == expected_status + if status.is_success(expected_status): + tags_by_taxonomy = response.data[str(self.collection_key)]["taxonomies"] + if tag_values: + response_taxonomy = tags_by_taxonomy[0] + assert response_taxonomy["name"] == taxonomy.name + response_tags = response_taxonomy["tags"] + assert [t["value"] for t in response_tags] == tag_values + else: + assert tags_by_taxonomy == [] # No tags are set from any taxonomy + + # Check that re-fetching the tags returns what we set + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.collection_key) + new_response = self.client.get(url, format="json") + assert status.is_success(new_response.status_code) + assert new_response.data == response.data + + @ddt.data( + "staffA", + "staff", + ) + def test_tag_collection_disabled_taxonomy(self, user_attr): + """ + Nobody can use disabled taxonomies to tag objects + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + disabled_taxonomy = self.tA2 + assert disabled_taxonomy.enabled is False + + response = self._call_put_request(self.collection_key, disabled_taxonomy.pk, ["Tag 1"]) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + ("staffA", "tA1"), + ("staff", "tA1"), + ("staffA", "multiple_taxonomy"), + ("staff", "multiple_taxonomy"), + ) + @ddt.unpack + def test_tag_collection_invalid(self, user_attr, taxonomy_attr): + """ + Tests that nobody can add invalid tags to a collection using a closed taxonomy + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.collection_key, taxonomy.pk, ["invalid"]) + assert response.status_code == status.HTTP_400_BAD_REQUEST + @ddt.data( ("superuser", status.HTTP_200_OK), ("staff", status.HTTP_403_FORBIDDEN), @@ -1768,10 +1853,14 @@ def test_get_tags(self): @ddt.data( ('staff', 'courseA', 8), ('staff', 'libraryA', 8), + ('staff', 'collection_key', 8), ("content_creatorA", 'courseA', 11, False), ("content_creatorA", 'libraryA', 11, False), + ("content_creatorA", 'collection_key', 11, False), ("library_staffA", 'libraryA', 11, False), # Library users can only view objecttags, not change them? + ("library_staffA", 'collection_key', 11, False), ("library_userA", 'libraryA', 11, False), + ("library_userA", 'collection_key', 11, False), ("instructorA", 'courseA', 11), ("course_instructorA", 'courseA', 11), ("course_staffA", 'courseA', 11), diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index 71b210b9e561..3fc99736bae9 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -13,8 +13,11 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from openedx_events.content_authoring.data import ContentObjectData -from openedx_events.content_authoring.signals import CONTENT_OBJECT_TAGS_CHANGED +from openedx_events.content_authoring.data import ContentObjectData, ContentObjectChangedData +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + CONTENT_OBJECT_TAGS_CHANGED, +) from ...auth import has_view_object_tags_access from ...api import ( @@ -149,14 +152,24 @@ class ObjectTagOrgView(ObjectTagView): def update(self, request, *args, **kwargs) -> Response: """ - Extend the update method to fire CONTENT_OBJECT_TAGS_CHANGED event + Extend the update method to fire CONTENT_OBJECT_ASSOCIATIONS_CHANGED event """ response = super().update(request, *args, **kwargs) if response.status_code == 200: object_id = kwargs.get('object_id') + + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=object_id, + changes=["tags"], + ) + ) + + # Emit a (deprecated) CONTENT_OBJECT_TAGS_CHANGED event too CONTENT_OBJECT_TAGS_CHANGED.send_event( content_object=ContentObjectData(object_id=object_id) ) + return response diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py index 1bc80b73727a..b693f7ee0f56 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_api.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py @@ -5,7 +5,7 @@ import ddt from django.test.testcases import TestCase from fs.osfs import OSFS -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import ObjectTag from organizations.models import Organization @@ -380,6 +380,23 @@ def test_copy_cross_org_tags(self): with self.assertNumQueries(31): # TODO why so high? self._test_copy_object_tags(src_key, dst_key, expected_tags) + def test_tag_collection(self): + collection_key = LibraryCollectionKey.from_string("lib-collection:orgA:libX:1") + + api.tag_object( + object_id=str(collection_key), + taxonomy=self.taxonomy_3, + tags=["Tag 3.1"], + ) + + with self.assertNumQueries(1): + object_tags, taxonomies = api.get_all_object_tags(collection_key) + + assert object_tags == {'lib-collection:orgA:libX:1': {3: ['Tag 3.1']}} + assert taxonomies == { + self.taxonomy_3.id: self.taxonomy_3, + } + class TestExportImportTags(TaggedCourseMixin): """ diff --git a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py index d3306844ac40..f196549dd1a9 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py @@ -4,14 +4,15 @@ import time from unittest.mock import patch +from openedx_tagging.core.tagging.models import ObjectTag +from organizations.models import Organization + from openedx.core.djangoapps.content_libraries import api as library_api from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from .. import api from ..helpers.objecttag_export_helpers import TaggedContent, build_object_tree_with_objecttags, iterate_with_level -from openedx_tagging.core.tagging.models import ObjectTag -from organizations.models import Organization class TestGetAllObjectTagsMixin: diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py index 64fa0d58f000..9ffb090d61e3 100644 --- a/openedx/core/djangoapps/content_tagging/types.py +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -5,11 +5,11 @@ from typing import Dict, List, Union -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy -ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey] +ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, LibraryCollectionKey] ContextKey = Union[LibraryLocatorV2, CourseKey] TagValuesByTaxonomyIdDict = Dict[int, List[str]] diff --git a/openedx/core/djangoapps/content_tagging/utils.py b/openedx/core/djangoapps/content_tagging/utils.py index 8cc9c9e7f7a9..39dd925c1acd 100644 --- a/openedx/core/djangoapps/content_tagging/utils.py +++ b/openedx/core/djangoapps/content_tagging/utils.py @@ -5,7 +5,7 @@ from edx_django_utils.cache import RequestCache from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization @@ -26,8 +26,14 @@ def get_content_key_from_string(key_str: str) -> ContentKey: except InvalidKeyError: try: return UsageKey.from_string(key_str) - except InvalidKeyError as usage_key_error: - raise ValueError("object_id must be a CourseKey, LibraryLocatorV2 or a UsageKey") from usage_key_error + except InvalidKeyError: + try: + return LibraryCollectionKey.from_string(key_str) + except InvalidKeyError as usage_key_error: + raise ValueError( + "object_id must be one of the following " + "keys: CourseKey, LibraryLocatorV2, UsageKey or LibCollectionKey" + ) from usage_key_error def get_context_key_from_key(content_key: ContentKey) -> ContextKey: @@ -38,6 +44,10 @@ def get_context_key_from_key(content_key: ContentKey) -> ContextKey: if isinstance(content_key, (CourseKey, LibraryLocatorV2)): return content_key + # If the content key is a LibraryCollectionKey, return the LibraryLocatorV2 + if isinstance(content_key, LibraryCollectionKey): + return content_key.library_key + # If the content key is a UsageKey, return the context key context_key = content_key.context_key diff --git a/openedx/core/djangoapps/contentserver/middleware.py b/openedx/core/djangoapps/contentserver/middleware.py deleted file mode 100644 index 6eb72da458a0..000000000000 --- a/openedx/core/djangoapps/contentserver/middleware.py +++ /dev/null @@ -1,368 +0,0 @@ -""" -Middleware to serve assets. -""" - - -import datetime -import logging - -from django.http import ( - HttpResponse, - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseNotFound, - HttpResponseNotModified, - HttpResponsePermanentRedirect -) -from django.utils.deprecation import MiddlewareMixin -from edx_django_utils.monitoring import set_custom_attribute -from edx_toggles.toggles import WaffleFlag -from opaque_keys import InvalidKeyError -from opaque_keys.edx.locator import AssetLocator - -from openedx.core.djangoapps.header_control import force_header_for_response -from common.djangoapps.student.models import CourseEnrollment -from xmodule.assetstore.assetmgr import AssetManager # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.contentstore.content import XASSET_LOCATION_TAG, StaticContent # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore import InvalidLocationError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order - -from .caching import get_cached_content, set_cached_content -from .models import CdnUserAgentsConfig, CourseAssetCacheTtlConfig - -log = logging.getLogger(__name__) - -# .. toggle_name: content_server.use_view -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Deployment flag for switching asset serving from a middleware -# to a view. Intended to be used once in each environment to test the cutover and -# ensure there are no errors or changes in behavior. Once this has been tested, -# the middleware can be fully converted to a view. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2024-05-02 -# .. toggle_target_removal_date: 2024-07-01 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/34702 -CONTENT_SERVER_USE_VIEW = WaffleFlag('content_server.use_view', module_name=__name__) - -# TODO: Soon as we have a reasonable way to serialize/deserialize AssetKeys, we need -# to change this file so instead of using course_id_partial, we're just using asset keys - -HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" - - -class StaticContentServerMiddleware(MiddlewareMixin): - """ - Shim to maintain old pattern of serving course assets from a middleware. See views.py. - """ - def process_request(self, request): - """Intercept asset request or allow view to handle it, depending on config.""" - if CONTENT_SERVER_USE_VIEW.is_enabled(): - return - else: - set_custom_attribute('content_server.handled_by.middleware', True) - return IMPL.process_request(request) - - -class StaticContentServer(): - """ - Serves course assets to end users. Colloquially referred to as "contentserver." - """ - def is_asset_request(self, request): - """Determines whether the given request is an asset request""" - # Don't change this without updating urls.py! See docstring of views.py. - return ( - request.path.startswith('/' + XASSET_LOCATION_TAG + '/') - or - request.path.startswith('/' + AssetLocator.CANONICAL_NAMESPACE) - or - StaticContent.is_versioned_asset_path(request.path) - ) - - # pylint: disable=too-many-statements - def process_request(self, request): - """Process the given request""" - asset_path = request.path - - if self.is_asset_request(request): # lint-amnesty, pylint: disable=too-many-nested-blocks - # Make sure we can convert this request into a location. - if AssetLocator.CANONICAL_NAMESPACE in asset_path: - asset_path = asset_path.replace('block/', 'block@', 1) - - # If this is a versioned request, pull out the digest and chop off the prefix. - requested_digest = None - if StaticContent.is_versioned_asset_path(asset_path): - requested_digest, asset_path = StaticContent.parse_versioned_asset_path(asset_path) - - # Make sure we have a valid location value for this asset. - try: - loc = StaticContent.get_location_from_path(asset_path) - except (InvalidLocationError, InvalidKeyError): - return HttpResponseBadRequest() - - # Attempt to load the asset to make sure it exists, and grab the asset digest - # if we're able to load it. - actual_digest = None - try: - content = self.load_asset_from_location(loc) - actual_digest = getattr(content, "content_digest", None) - except (ItemNotFoundError, NotFoundError): - return HttpResponseNotFound() - - # If this was a versioned asset, and the digest doesn't match, redirect - # them to the actual version. - if requested_digest is not None and actual_digest is not None and (actual_digest != requested_digest): - actual_asset_path = StaticContent.add_version_to_asset_path(asset_path, actual_digest) - return HttpResponsePermanentRedirect(actual_asset_path) - - # Set the basics for this request. Make sure that the course key for this - # asset has a run, which old-style courses do not. Otherwise, this will - # explode when the key is serialized to be sent to NR. - safe_course_key = loc.course_key - if safe_course_key.run is None: - safe_course_key = safe_course_key.replace(run='only') - - set_custom_attribute('course_id', safe_course_key) - set_custom_attribute('org', loc.org) - set_custom_attribute('contentserver.path', loc.path) - - # Figure out if this is a CDN using us as the origin. - is_from_cdn = StaticContentServer.is_cdn_request(request) - set_custom_attribute('contentserver.from_cdn', is_from_cdn) - - # Check if this content is locked or not. - locked = self.is_content_locked(content) - set_custom_attribute('contentserver.locked', locked) - - # Check that user has access to the content. - if not self.is_user_authorized(request, content, loc): - return HttpResponseForbidden('Unauthorized') - - # Figure out if the client sent us a conditional request, and let them know - # if this asset has changed since then. - last_modified_at_str = content.last_modified_at.strftime(HTTP_DATE_FORMAT) - if 'HTTP_IF_MODIFIED_SINCE' in request.META: - if_modified_since = request.META['HTTP_IF_MODIFIED_SINCE'] - if if_modified_since == last_modified_at_str: - return HttpResponseNotModified() - - # *** File streaming within a byte range *** - # If a Range is provided, parse Range attribute of the request - # Add Content-Range in the response if Range is structurally correct - # Request -> Range attribute structure: "Range: bytes=first-[last]" - # Response -> Content-Range attribute structure: "Content-Range: bytes first-last/totalLength" - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 - response = None - if request.META.get('HTTP_RANGE'): - # If we have a StaticContent, get a StaticContentStream. Can't manipulate the bytes otherwise. - if isinstance(content, StaticContent): - content = AssetManager.find(loc, as_stream=True) - - header_value = request.META['HTTP_RANGE'] - try: - unit, ranges = parse_range_header(header_value, content.length) - except ValueError as exception: - # If the header field is syntactically invalid it should be ignored. - log.exception( - "%s in Range header: %s for content: %s", - str(exception), header_value, str(loc) - ) - else: - if unit != 'bytes': - # Only accept ranges in bytes - log.warning("Unknown unit in Range header: %s for content: %s", header_value, str(loc)) - elif len(ranges) > 1: - # According to Http/1.1 spec content for multiple ranges should be sent as a multipart message. - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16 - # But we send back the full content. - log.warning( - "More than 1 ranges in Range header: %s for content: %s", header_value, str(loc) - ) - else: - first, last = ranges[0] - - if 0 <= first <= last < content.length: - # If the byte range is satisfiable - response = HttpResponse(content.stream_data_in_range(first, last)) - response['Content-Range'] = 'bytes {first}-{last}/{length}'.format( - first=first, last=last, length=content.length - ) - response['Content-Length'] = str(last - first + 1) - response.status_code = 206 # Partial Content - - set_custom_attribute('contentserver.ranged', True) - else: - log.warning( - "Cannot satisfy ranges in Range header: %s for content: %s", - header_value, str(loc) - ) - return HttpResponse(status=416) # Requested Range Not Satisfiable - - # If Range header is absent or syntactically invalid return a full content response. - if response is None: - response = HttpResponse(content.stream_data()) - response['Content-Length'] = content.length - - set_custom_attribute('contentserver.content_len', content.length) - set_custom_attribute('contentserver.content_type', content.content_type) - - # "Accept-Ranges: bytes" tells the user that only "bytes" ranges are allowed - response['Accept-Ranges'] = 'bytes' - response['Content-Type'] = content.content_type - response['X-Frame-Options'] = 'ALLOW' - - # Set any caching headers, and do any response cleanup needed. Based on how much - # middleware we have in place, there's no easy way to use the built-in Django - # utilities and properly sanitize and modify a response to ensure that it is as - # cacheable as possible, which is why we do it ourselves. - self.set_caching_headers(content, response) - - return response - - def set_caching_headers(self, content, response): - """ - Sets caching headers based on whether or not the asset is locked. - """ - - is_locked = getattr(content, "locked", False) - - # We want to signal to the end user's browser, and to any intermediate proxies/caches, - # whether or not this asset is cacheable. If we have a TTL configured, we inform the - # caller, for unlocked assets, how long they are allowed to cache it. Since locked - # assets should be restricted to enrolled students, we simply send headers that - # indicate there should be no caching whatsoever. - cache_ttl = CourseAssetCacheTtlConfig.get_cache_ttl() - if cache_ttl > 0 and not is_locked: - set_custom_attribute('contentserver.cacheable', True) - - response['Expires'] = StaticContentServer.get_expiration_value(datetime.datetime.utcnow(), cache_ttl) - response['Cache-Control'] = "public, max-age={ttl}, s-maxage={ttl}".format(ttl=cache_ttl) - elif is_locked: - set_custom_attribute('contentserver.cacheable', False) - - response['Cache-Control'] = "private, no-cache, no-store" - - response['Last-Modified'] = content.last_modified_at.strftime(HTTP_DATE_FORMAT) - - # Force the Vary header to only vary responses on Origin, so that XHR and browser requests get cached - # separately and don't screw over one another. i.e. a browser request that doesn't send Origin, and - # caches a version of the response without CORS headers, in turn breaking XHR requests. - force_header_for_response(response, 'Vary', 'Origin') - - @staticmethod - def is_cdn_request(request): - """ - Attempts to determine whether or not the given request is coming from a CDN. - - Currently, this is a static check because edx.org only uses CloudFront, but may - be expanded in the future. - """ - cdn_user_agents = CdnUserAgentsConfig.get_cdn_user_agents() - user_agent = request.META.get('HTTP_USER_AGENT', '') - if user_agent in cdn_user_agents: - # This is a CDN request. - return True - - return False - - @staticmethod - def get_expiration_value(now, cache_ttl): - """Generates an RFC1123 datetime string based on a future offset.""" - expire_dt = now + datetime.timedelta(seconds=cache_ttl) - return expire_dt.strftime(HTTP_DATE_FORMAT) - - def is_content_locked(self, content): - """ - Determines whether or not the given content is locked. - """ - return bool(getattr(content, "locked", False)) - - def is_user_authorized(self, request, content, location): - """ - Determines whether or not the user for this request is authorized to view the given asset. - """ - if not self.is_content_locked(content): - return True - - if not hasattr(request, "user") or not request.user.is_authenticated: - return False - - if not request.user.is_staff: - deprecated = getattr(location, 'deprecated', False) - if deprecated and not CourseEnrollment.is_enrolled_by_partial(request.user, location.course_key): - return False - if not deprecated and not CourseEnrollment.is_enrolled(request.user, location.course_key): - return False - - return True - - def load_asset_from_location(self, location): - """ - Loads an asset based on its location, either retrieving it from a cache - or loading it directly from the contentstore. - """ - - # See if we can load this item from cache. - content = get_cached_content(location) - if content is None: - # Not in cache, so just try and load it from the asset manager. - try: - content = AssetManager.find(location, as_stream=True) - except (ItemNotFoundError, NotFoundError): # lint-amnesty, pylint: disable=try-except-raise - raise - - # Now that we fetched it, let's go ahead and try to cache it. We cap this at 1MB - # because it's the default for memcached and also we don't want to do too much - # buffering in memory when we're serving an actual request. - if content.length is not None and content.length < 1048576: - content = content.copy_to_in_mem() - set_cached_content(content) - - return content - - -IMPL = StaticContentServer() - - -def parse_range_header(header_value, content_length): - """ - Returns the unit and a list of (start, end) tuples of ranges. - - Raises ValueError if header is syntactically invalid or does not contain a range. - - See spec for details: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 - """ - - unit = None - ranges = [] - - if '=' in header_value: - unit, byte_ranges_string = header_value.split('=') - - # Parse the byte ranges. - for byte_range_string in byte_ranges_string.split(','): - byte_range_string = byte_range_string.strip() - # Case 0: - if '-' not in byte_range_string: # Invalid syntax of header value. # lint-amnesty, pylint: disable=no-else-raise - raise ValueError('Invalid syntax.') - # Case 1: -500 - elif byte_range_string.startswith('-'): - first = max(0, (content_length + int(byte_range_string))) - last = content_length - 1 - # Case 2: 500- - elif byte_range_string.endswith('-'): - first = int(byte_range_string[0:-1]) - last = content_length - 1 - # Case 3: 500-999 - else: - first, last = byte_range_string.split('-') - first = int(first) - last = min(int(last), content_length - 1) - - ranges.append((first, last)) - - if len(ranges) == 0: - raise ValueError('Invalid syntax') - - return unit, ranges diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py index e9ee1f7ee5b2..e1c5e01c0a7f 100644 --- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py +++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py @@ -4,7 +4,6 @@ import copy - import datetime import logging import unittest @@ -17,18 +16,18 @@ from django.test.client import Client from django.test.utils import override_settings from opaque_keys import InvalidKeyError + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from xmodule.assetstore.assetmgr import AssetManager +from xmodule.contentstore.content import VERSIONED_ASSETS_PREFIX, StaticContent from xmodule.contentstore.django import contentstore -from xmodule.contentstore.content import StaticContent, VERSIONED_ASSETS_PREFIX from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase from xmodule.modulestore.xml_importer import import_course_from_xml -from xmodule.assetstore.assetmgr import AssetManager -from xmodule.modulestore.exceptions import ItemNotFoundError - -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory, AdminFactory -from ..middleware import parse_range_header, HTTP_DATE_FORMAT, StaticContentServer +from ..views import HTTP_DATE_FORMAT, StaticContentServer, parse_range_header log = logging.getLogger(__name__) diff --git a/openedx/core/djangoapps/contentserver/views.py b/openedx/core/djangoapps/contentserver/views.py index 84bd4c3b1046..7c40026415bb 100644 --- a/openedx/core/djangoapps/contentserver/views.py +++ b/openedx/core/djangoapps/contentserver/views.py @@ -9,28 +9,41 @@ `resolve` utility, but these URLs get a Resolver404 (because there's no registered urlpattern). -We'd like to turn this into a proper view: -https://github.com/openedx/edx-platform/issues/34702 - -The first step, seen here, is to have urlpatterns (redundant with the -middleware's `is_asset_request` method) and a view, but the view just calls into -the same code the middleware uses. The implementation of the middleware has been -moved into StaticContentServerImpl, leaving the middleware as just a shell -around the latter. - -A waffle flag chooses whether to allow the middleware to handle the request, or -whether to pass the request along to the view. Why? Because we might be relying -by accident on some weird behavior inherent to misusing a middleware this way, -and we need a way to quickly switch back if we encounter problems. - -If the view works, we can move all of StaticContentServerImpl directly into the -view and drop the middleware and the waffle flag. +We've turned it into a proper view, with a few warts remaining: + +- The view implementation is all bundled into a StaticContentServer class that + doesn't appear to have any state. The methods could likely just be extracted + as top-level functions. +- All three urlpatterns are registered to the same view, which then has to + re-parse the URL to determine which pattern is in effect. We should probably + have 3 views as entry points. """ -from django.http import HttpResponseNotFound +import datetime +import logging + +from django.http import ( + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + HttpResponseNotModified, + HttpResponsePermanentRedirect +) from django.views.decorators.http import require_safe from edx_django_utils.monitoring import set_custom_attribute +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import AssetLocator + +from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.header_control import force_header_for_response +from xmodule.assetstore.assetmgr import AssetManager +from xmodule.contentstore.content import XASSET_LOCATION_TAG, StaticContent +from xmodule.exceptions import NotFoundError +from xmodule.modulestore import InvalidLocationError +from xmodule.modulestore.exceptions import ItemNotFoundError -from .middleware import CONTENT_SERVER_USE_VIEW, IMPL +from .caching import get_cached_content, set_cached_content +from .models import CdnUserAgentsConfig, CourseAssetCacheTtlConfig @require_safe @@ -38,21 +51,315 @@ def course_assets_view(request): """ Serve course assets to end users. Colloquially referred to as "contentserver." """ - set_custom_attribute('content_server.handled_by.view', True) - - if not CONTENT_SERVER_USE_VIEW.is_enabled(): - # Should never happen; keep track of occurrences. - set_custom_attribute('content_server.view.called_when_disabled', True) - # But handle the request anyhow. - - # We'll delegate request handling to an instance of the middleware - # until we can verify that the behavior is identical when requests - # come all the way through to the view. - response = IMPL.process_request(request) - - if response is None: - # Shouldn't happen - set_custom_attribute('content_server.view.no_response_from_impl', True) - return HttpResponseNotFound() - else: - return response + return IMPL.process_request(request) + + +log = logging.getLogger(__name__) + +# TODO: Soon as we have a reasonable way to serialize/deserialize AssetKeys, we need +# to change this file so instead of using course_id_partial, we're just using asset keys + +HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" + + +class StaticContentServer(): + """ + Serves course assets to end users. Colloquially referred to as "contentserver." + """ + def is_asset_request(self, request): + """Determines whether the given request is an asset request""" + # Don't change this without updating urls.py! See docstring of views.py. + return ( + request.path.startswith('/' + XASSET_LOCATION_TAG + '/') + or + request.path.startswith('/' + AssetLocator.CANONICAL_NAMESPACE) + or + StaticContent.is_versioned_asset_path(request.path) + ) + + # pylint: disable=too-many-statements + def process_request(self, request): + """Process the given request""" + asset_path = request.path + + if self.is_asset_request(request): # lint-amnesty, pylint: disable=too-many-nested-blocks + # Make sure we can convert this request into a location. + if AssetLocator.CANONICAL_NAMESPACE in asset_path: + asset_path = asset_path.replace('block/', 'block@', 1) + + # If this is a versioned request, pull out the digest and chop off the prefix. + requested_digest = None + if StaticContent.is_versioned_asset_path(asset_path): + requested_digest, asset_path = StaticContent.parse_versioned_asset_path(asset_path) + + # Make sure we have a valid location value for this asset. + try: + loc = StaticContent.get_location_from_path(asset_path) + except (InvalidLocationError, InvalidKeyError): + return HttpResponseBadRequest() + + # Attempt to load the asset to make sure it exists, and grab the asset digest + # if we're able to load it. + actual_digest = None + try: + content = self.load_asset_from_location(loc) + actual_digest = getattr(content, "content_digest", None) + except (ItemNotFoundError, NotFoundError): + return HttpResponseNotFound() + + # If this was a versioned asset, and the digest doesn't match, redirect + # them to the actual version. + if requested_digest is not None and actual_digest is not None and (actual_digest != requested_digest): + actual_asset_path = StaticContent.add_version_to_asset_path(asset_path, actual_digest) + return HttpResponsePermanentRedirect(actual_asset_path) + + # Set the basics for this request. Make sure that the course key for this + # asset has a run, which old-style courses do not. Otherwise, this will + # explode when the key is serialized to be sent to NR. + safe_course_key = loc.course_key + if safe_course_key.run is None: + safe_course_key = safe_course_key.replace(run='only') + + set_custom_attribute('course_id', safe_course_key) + set_custom_attribute('org', loc.org) + set_custom_attribute('contentserver.path', loc.path) + + # Figure out if this is a CDN using us as the origin. + is_from_cdn = StaticContentServer.is_cdn_request(request) + set_custom_attribute('contentserver.from_cdn', is_from_cdn) + + # Check if this content is locked or not. + locked = self.is_content_locked(content) + set_custom_attribute('contentserver.locked', locked) + + # Check that user has access to the content. + if not self.is_user_authorized(request, content, loc): + return HttpResponseForbidden('Unauthorized') + + # Figure out if the client sent us a conditional request, and let them know + # if this asset has changed since then. + last_modified_at_str = content.last_modified_at.strftime(HTTP_DATE_FORMAT) + if 'HTTP_IF_MODIFIED_SINCE' in request.META: + if_modified_since = request.META['HTTP_IF_MODIFIED_SINCE'] + if if_modified_since == last_modified_at_str: + return HttpResponseNotModified() + + # *** File streaming within a byte range *** + # If a Range is provided, parse Range attribute of the request + # Add Content-Range in the response if Range is structurally correct + # Request -> Range attribute structure: "Range: bytes=first-[last]" + # Response -> Content-Range attribute structure: "Content-Range: bytes first-last/totalLength" + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 + response = None + if request.META.get('HTTP_RANGE'): + # If we have a StaticContent, get a StaticContentStream. Can't manipulate the bytes otherwise. + if isinstance(content, StaticContent): + content = AssetManager.find(loc, as_stream=True) + + header_value = request.META['HTTP_RANGE'] + try: + unit, ranges = parse_range_header(header_value, content.length) + except ValueError as exception: + # If the header field is syntactically invalid it should be ignored. + log.exception( + "%s in Range header: %s for content: %s", + str(exception), header_value, str(loc) + ) + else: + if unit != 'bytes': + # Only accept ranges in bytes + log.warning("Unknown unit in Range header: %s for content: %s", header_value, str(loc)) + elif len(ranges) > 1: + # According to Http/1.1 spec content for multiple ranges should be sent as a multipart message. + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16 + # But we send back the full content. + log.warning( + "More than 1 ranges in Range header: %s for content: %s", header_value, str(loc) + ) + else: + first, last = ranges[0] + + if 0 <= first <= last < content.length: + # If the byte range is satisfiable + response = HttpResponse(content.stream_data_in_range(first, last)) + response['Content-Range'] = 'bytes {first}-{last}/{length}'.format( + first=first, last=last, length=content.length + ) + response['Content-Length'] = str(last - first + 1) + response.status_code = 206 # Partial Content + + set_custom_attribute('contentserver.ranged', True) + else: + log.warning( + "Cannot satisfy ranges in Range header: %s for content: %s", + header_value, str(loc) + ) + return HttpResponse(status=416) # Requested Range Not Satisfiable + + # If Range header is absent or syntactically invalid return a full content response. + if response is None: + response = HttpResponse(content.stream_data()) + response['Content-Length'] = content.length + + set_custom_attribute('contentserver.content_len', content.length) + set_custom_attribute('contentserver.content_type', content.content_type) + + # "Accept-Ranges: bytes" tells the user that only "bytes" ranges are allowed + response['Accept-Ranges'] = 'bytes' + response['Content-Type'] = content.content_type + response['X-Frame-Options'] = 'ALLOW' + + # Set any caching headers, and do any response cleanup needed. Based on how much + # middleware we have in place, there's no easy way to use the built-in Django + # utilities and properly sanitize and modify a response to ensure that it is as + # cacheable as possible, which is why we do it ourselves. + self.set_caching_headers(content, response) + + return response + + def set_caching_headers(self, content, response): + """ + Sets caching headers based on whether or not the asset is locked. + """ + + is_locked = getattr(content, "locked", False) + + # We want to signal to the end user's browser, and to any intermediate proxies/caches, + # whether or not this asset is cacheable. If we have a TTL configured, we inform the + # caller, for unlocked assets, how long they are allowed to cache it. Since locked + # assets should be restricted to enrolled students, we simply send headers that + # indicate there should be no caching whatsoever. + cache_ttl = CourseAssetCacheTtlConfig.get_cache_ttl() + if cache_ttl > 0 and not is_locked: + set_custom_attribute('contentserver.cacheable', True) + + response['Expires'] = StaticContentServer.get_expiration_value(datetime.datetime.utcnow(), cache_ttl) + response['Cache-Control'] = "public, max-age={ttl}, s-maxage={ttl}".format(ttl=cache_ttl) + elif is_locked: + set_custom_attribute('contentserver.cacheable', False) + + response['Cache-Control'] = "private, no-cache, no-store" + + response['Last-Modified'] = content.last_modified_at.strftime(HTTP_DATE_FORMAT) + + # Force the Vary header to only vary responses on Origin, so that XHR and browser requests get cached + # separately and don't screw over one another. i.e. a browser request that doesn't send Origin, and + # caches a version of the response without CORS headers, in turn breaking XHR requests. + force_header_for_response(response, 'Vary', 'Origin') + + @staticmethod + def is_cdn_request(request): + """ + Attempts to determine whether or not the given request is coming from a CDN. + + Currently, this is a static check because edx.org only uses CloudFront, but may + be expanded in the future. + """ + cdn_user_agents = CdnUserAgentsConfig.get_cdn_user_agents() + user_agent = request.META.get('HTTP_USER_AGENT', '') + if user_agent in cdn_user_agents: + # This is a CDN request. + return True + + return False + + @staticmethod + def get_expiration_value(now, cache_ttl): + """Generates an RFC1123 datetime string based on a future offset.""" + expire_dt = now + datetime.timedelta(seconds=cache_ttl) + return expire_dt.strftime(HTTP_DATE_FORMAT) + + def is_content_locked(self, content): + """ + Determines whether or not the given content is locked. + """ + return bool(getattr(content, "locked", False)) + + def is_user_authorized(self, request, content, location): + """ + Determines whether or not the user for this request is authorized to view the given asset. + """ + if not self.is_content_locked(content): + return True + + if not hasattr(request, "user") or not request.user.is_authenticated: + return False + + if not request.user.is_staff: + deprecated = getattr(location, 'deprecated', False) + if deprecated and not CourseEnrollment.is_enrolled_by_partial(request.user, location.course_key): + return False + if not deprecated and not CourseEnrollment.is_enrolled(request.user, location.course_key): + return False + + return True + + def load_asset_from_location(self, location): + """ + Loads an asset based on its location, either retrieving it from a cache + or loading it directly from the contentstore. + """ + + # See if we can load this item from cache. + content = get_cached_content(location) + if content is None: + # Not in cache, so just try and load it from the asset manager. + try: + content = AssetManager.find(location, as_stream=True) + except (ItemNotFoundError, NotFoundError): # lint-amnesty, pylint: disable=try-except-raise + raise + + # Now that we fetched it, let's go ahead and try to cache it. We cap this at 1MB + # because it's the default for memcached and also we don't want to do too much + # buffering in memory when we're serving an actual request. + if content.length is not None and content.length < 1048576: + content = content.copy_to_in_mem() + set_cached_content(content) + + return content + + +IMPL = StaticContentServer() + + +def parse_range_header(header_value, content_length): + """ + Returns the unit and a list of (start, end) tuples of ranges. + + Raises ValueError if header is syntactically invalid or does not contain a range. + + See spec for details: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35 + """ + + unit = None + ranges = [] + + if '=' in header_value: + unit, byte_ranges_string = header_value.split('=') + + # Parse the byte ranges. + for byte_range_string in byte_ranges_string.split(','): + byte_range_string = byte_range_string.strip() + # Case 0: + if '-' not in byte_range_string: # Invalid syntax of header value. # lint-amnesty, pylint: disable=no-else-raise + raise ValueError('Invalid syntax.') + # Case 1: -500 + elif byte_range_string.startswith('-'): + first = max(0, (content_length + int(byte_range_string))) + last = content_length - 1 + # Case 2: 500- + elif byte_range_string.endswith('-'): + first = int(byte_range_string[0:-1]) + last = content_length - 1 + # Case 3: 500-999 + else: + first, last = byte_range_string.split('-') + first = int(first) + last = min(int(last), content_length - 1) + + ranges.append((first, last)) + + if len(ranges) == 0: + raise ValueError('Invalid syntax') + + return unit, ranges diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py index 59bf3d86cda7..17ea280537c3 100644 --- a/openedx/core/djangoapps/credentials/tests/test_utils.py +++ b/openedx/core/djangoapps/credentials/tests/test_utils.py @@ -3,16 +3,11 @@ import uuid from unittest import mock -from django.conf import settings -from requests import Response -from requests.exceptions import HTTPError - from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.credentials.models import CredentialsApiConfig from openedx.core.djangoapps.credentials.tests import factories from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin from openedx.core.djangoapps.credentials.utils import ( - get_courses_completion_status, get_credentials, get_credentials_records_url, ) @@ -107,33 +102,3 @@ def test_get_credentials_records_url(self): result = get_credentials_records_url("abcdefgh-ijkl-mnop-qrst-uvwxyz123456") assert result == "https://credentials.example.com/records/programs/abcdefghijklmnopqrstuvwxyz123456" - - @mock.patch("requests.Response.raise_for_status") - @mock.patch("requests.Response.json") - @mock.patch(UTILS_MODULE + ".get_credentials_api_client") - def test_get_courses_completion_status(self, mock_get_api_client, mock_json, mock_raise): - """ - Test to verify the functionality of get_courses_completion_status - """ - UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME) - course_statuses = factories.UserCredentialsCourseRunStatus.create_batch(3) - response_data = [course_status["course_run"]["key"] for course_status in course_statuses] - mock_raise.return_value = None - mock_json.return_value = { - "lms_user_id": self.user.id, - "status": course_statuses, - "username": self.user.username, - } - mock_get_api_client.return_value.post.return_value = Response() - course_run_keys = [course_status["course_run"]["key"] for course_status in course_statuses] - api_response, is_exception = get_courses_completion_status(self.user.id, course_run_keys) - assert api_response == response_data - assert is_exception is False - - @mock.patch("requests.Response.raise_for_status") - def test_get_courses_completion_status_api_error(self, mock_raise): - mock_raise.return_value = HTTPError("An Error occured") - UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME) - api_response, is_exception = get_courses_completion_status(self.user.id, ["fake1", "fake2", "fake3"]) - assert api_response == [] - assert is_exception is True diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index 1b7b1b748667..9e8ad6b04df0 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -5,7 +5,6 @@ from urllib.parse import urljoin import requests -from django.conf import settings from django.contrib.auth import get_user_model from edx_rest_api_client.auth import SuppliedJwtAuth @@ -121,59 +120,3 @@ def get_credentials( cache_key=cache_key, raise_on_error=raise_on_error, ) - - -def get_courses_completion_status(username, course_run_ids): - """ - Given the username and course run ids, checks for course completion status - Arguments: - username (User): Username of the user whose credentials are being requested. - course_run_ids(List): list of course run ids for which we need to check the completion status - Returns: - list of course_run_ids for which user has completed the course - Boolean: True if an exception occurred while calling the api, False otherwise - """ - credential_configuration = CredentialsApiConfig.current() - if not credential_configuration.enabled: - log.warning("%s configuration is disabled.", credential_configuration.API_NAME) - return [], False - - completion_status_url = f"{settings.CREDENTIALS_INTERNAL_SERVICE_URL}/api" "/credentials/v1/learner_cert_status/" - try: - api_client = get_credentials_api_client(User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME)) - api_response = api_client.post( - completion_status_url, - json={ - "username": username, - "course_runs": course_run_ids, - }, - ) - api_response.raise_for_status() - course_completion_response = api_response.json() - except Exception as exc: # pylint: disable=broad-except - log.exception( - "An unexpected error occurred while reqeusting course completion statuses " - "for user [%s] for course_run_ids [%s] with exc [%s]:", - username, - course_run_ids, - exc, - ) - return [], True - log.info( - "Course completion status response for user [%s] for course_run_ids [%s] is [%s]", - username, - course_run_ids, - course_completion_response, - ) - # Yes, This is course_credentials_data. The key is named status but - # it contains all the courses data from credentials. - course_credentials_data = course_completion_response.get("status", []) - if course_credentials_data is not None: - filtered_records = [ - course_data["course_run"]["key"] - for course_data in course_credentials_data - if course_data["course_run"]["key"] in course_run_ids - and course_data["status"] == settings.CREDENTIALS_COURSE_COMPLETION_STATE - ] - return filtered_records, False - return [], False diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 2c696ec60d13..02b49df89444 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -181,8 +181,7 @@ 'push': True, 'email_cadence': EmailCadence.DAILY, 'non_editable': [], - 'content_template': _('<{p}>You have a new course update: ' - '<{strong}>{course_update_content}'), + 'content_template': _('<{p}><{strong}>{course_update_content}'), 'content_context': { 'course_update_content': 'Course update', }, @@ -208,6 +207,26 @@ 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], 'visible_to': [CourseStaffRole.ROLE, CourseInstructorRole.ROLE] }, + 'ora_grade_assigned': { + 'notification_app': 'grading', + 'name': 'ora_grade_assigned', + 'is_core': False, + 'info': '', + 'web': False, + 'email': False, + 'push': False, + 'email_cadence': EmailCadence.DAILY, + 'non_editable': [], + 'content_template': _('<{p}>You have received {points_earned} out of {points_possible} on your assessment: ' + '<{strong}>{ora_name}'), + 'content_context': { + 'ora_name': 'Name of ORA in course', + 'points_earned': 'Points earned', + 'points_possible': 'Points possible', + }, + 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], + }, } COURSE_NOTIFICATION_APPS = { diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index af89bb68574f..862dd32f7485 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -28,3 +28,13 @@ # .. toggle_warning: When the flag is ON, Email Notifications feature is enabled. # .. toggle_tickets: INF-1259 ENABLE_EMAIL_NOTIFICATIONS = WaffleFlag(f'{WAFFLE_NAMESPACE}.enable_email_notifications', __name__) + +# .. toggle_name: notifications.enable_ora_grade_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable ORA grade notifications +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2024-09-10 +# .. toggle_target_removal_date: 2024-10-10 +# .. toggle_tickets: INF-1304 +ENABLE_ORA_GRADE_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora_grade_notifications", __name__) diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index e36c435e8ac0..582e867d629d 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -4,6 +4,7 @@ import datetime import json +from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 @@ -195,6 +196,18 @@ def get_time_ago(datetime_obj): return f"{days_diff}d" +def add_zero_margin_to_root(html_string): + """ + Adds to zero margin to root element of html string + """ + soup = BeautifulSoup(html_string, 'html.parser') + element = soup.find() + if not element: + return html_string + element['style'] = "margin: 0;" + return str(soup) + + def add_additional_attributes_to_notifications(notifications, courses_data=None): """ Add attributes required for email content to notification instance @@ -214,6 +227,8 @@ def add_additional_attributes_to_notifications(notifications, courses_data=None) notification.course_name = course_info.get('name', '') notification.icon = get_icon_url_for_notification_type(notification_type) notification.time_ago = get_time_ago(notification.created) + notification.email_content = add_zero_margin_to_root(notification.content) + notification.details = add_zero_margin_to_root(notification.content_context.get('email_content', '')) return notifications diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index 505f4b5e7024..f28cb594ea6f 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -21,7 +21,7 @@ ForumRoleAudienceFilter, TeamAudienceFilter ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_ORA_GRADE_NOTIFICATION from openedx.core.djangoapps.notifications.models import CourseNotificationPreference log = logging.getLogger(__name__) @@ -72,6 +72,12 @@ def generate_user_notifications(signal, sender, notification_data, metadata, **k """ Watches for USER_NOTIFICATION_REQUESTED signal and calls send_web_notifications task """ + if ( + notification_data.notification_type == 'ora_grade_assigned' + and not ENABLE_ORA_GRADE_NOTIFICATION.is_enabled(notification_data.course_key) + ): + return + from openedx.core.djangoapps.notifications.tasks import send_notifications notification_data = notification_data.__dict__ notification_data['course_key'] = str(notification_data['course_key']) diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index e1bdf94acc33..77f7b991b546 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -23,7 +23,7 @@ ADDITIONAL_NOTIFICATION_CHANNEL_SETTINGS = ['email_cadence'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 11 +COURSE_NOTIFICATION_CONFIG_VERSION = 12 def get_course_notification_preference_config(): diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html index 13ac89d4ec29..f2e239bb7e6e 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html @@ -1,6 +1,3 @@ - {% for notification_app in email_content %}

{{ notification_app.title }} @@ -32,11 +29,15 @@

-

- {{ notification.content | truncatechars_html:600 | safe }} -

-

-

+

+ {{ notification.email_content | truncatechars_html:600 | safe }} +
+ {% if notification.details %} +
+ {{ notification.details | safe }} +
+ {% endif %} +
{{ notification.course_name }} {{ "·"|safe }} @@ -47,7 +48,7 @@

/', include([ + # render one of this XBlock's views (e.g. student_view) for embedding in an iframe + # NOTE: this endpoint is **unstable** and subject to changes after Sumac + re_path(r'^embed/(?P[\w\-]+)/$', views.embed_block_view), + ])), ] diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index 8c2d16839a67..7934e24bd2db 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -1,12 +1,16 @@ """ Views that implement a RESTful API for interacting with XBlocks. """ +import itertools +import json from common.djangoapps.util.json_request import JsonResponse from corsheaders.signals import check_request_enabled +from django.conf import settings from django.contrib.auth import get_user_model from django.db.transaction import atomic from django.http import Http404 +from django.shortcuts import render from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt @@ -21,6 +25,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey +import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl from openedx.core.lib.api.view_utils import view_auth_classes from ..api import ( @@ -87,6 +92,47 @@ def render_block_view(request, usage_key_str, view_name): return Response(response_data) +@api_view(['GET']) +@view_auth_classes(is_authenticated=False) +@permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context +@xframe_options_exempt +def embed_block_view(request, usage_key_str, view_name): + """ + Render the given XBlock in an