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">
- ${_("Course Number")}
${course.display_number_with_default}
+
+
+ ${_("Course Number")}
+ ${course.display_number_with_default}
+
% 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"):
- ${_("Requirements")}
${get_course_about_section(request, course, "prerequisites")}
+
+
+ ${_("Requirements")}
+ ${get_course_about_section(request, course, "prerequisites")}
+
% endif
%block>
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) { %>
-
-
- <%- hasActiveTrial
- ? gettext('Trial subscription')
- : subscriptionState === 'active'
- ? gettext('Active subscription')
- : gettext('Inactive subscription')
- %>
-
-
-
-<% } %>
<%- gettext('Program Record') %>
<% } %>
- <% 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 @@