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 b268bd6fcb5d..17e94112bafe 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 @@ -2239,11 +2242,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,10 +2278,10 @@ 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_update", + notification_type="course_updates", content_url=f"{settings.LMS_ROOT_URL}/courses/{str(course_key)}/course/updates", app_name="updates", audience_filters={}, diff --git a/cms/envs/common.py b/cms/envs/common.py index be837c518981..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' @@ -1880,6 +1878,7 @@ 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", diff --git a/cms/static/sass/studio-main-v1.scss b/cms/static/sass/studio-main-v1.scss index ac649970d644..5d0cdda2ea5f 100644 --- a/cms/static/sass/studio-main-v1.scss +++ b/cms/static/sass/studio-main-v1.scss @@ -15,6 +15,8 @@ // +Libs and Resets - *do not edit* // ==================== + +@import '_builtin-block-variables'; @import 'bourbon/bourbon'; // lib - bourbon @import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages @import 'build-v1'; // shared app style assets/rendering 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/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 5eae81b159e2..b4dcff0b4ab8 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/common/static/sass/_builtin-block-variables.scss b/common/static/sass/_builtin-block-variables.scss new file mode 100644 index 000000000000..2c567c6fb1f4 --- /dev/null +++ b/common/static/sass/_builtin-block-variables.scss @@ -0,0 +1,73 @@ +/* + * In pursuit of decoupling the built-in XBlocks from edx-platform's Sass build + * and ensuring comprehensive theming support in the extracted XBlocks, + * we need to expose Sass variables as CSS variables. + * + * Ticket/Issue: https://github.com/openedx/edx-platform/issues/35173 + */ +@import 'bourbon/bourbon'; +@import 'lms/theme/variables'; +@import 'lms/theme/variables-v1'; +@import 'cms/static/sass/partials/cms/theme/_variables'; +@import 'cms/static/sass/partials/cms/theme/_variables-v1'; +@import 'bootstrap/scss/variables'; +@import 'vendor/bi-app/bi-app-ltr'; +@import 'edx-pattern-library-shims/base/_variables.scss'; + +:root { + --action-primary-active-bg: $action-primary-active-bg; + --all-text-inputs: $all-text-inputs; + --base-font-size: $base-font-size; + --base-line-height: $base-line-height; + --baseline: $baseline; + --black: $black; + --black-t2: $black-t2; + --blue: $blue; + --blue-d1: $blue-d1; + --blue-d2: $blue-d2; + --blue-d4: $blue-d4; + --body-color: $body-color; + --border-color: $border-color; + --bp-screen-lg: $bp-screen-lg; + --btn-brand-focus-background: $btn-brand-focus-background; + --correct: $correct; + --danger: $danger; + --darkGrey: $darkGrey; + --error-color: $error-color; + --font-bold: $font-bold; + --font-family-sans-serif: $font-family-sans-serif; + --general-color-accent: $general-color-accent; + --gray: $gray; + --gray-300: $gray-300; + --gray-d1: $gray-d1; + --gray-l2: $gray-l2; + --gray-l3: $gray-l3; + --gray-l4: $gray-l4; + --gray-l6: $gray-l6; + --incorrect: $incorrect; + --lightGrey: $lightGrey; + --lighter-base-font-color: $lighter-base-font-color; + --link-color: $link-color; + --medium-font-size: $medium-font-size; + --partially-correct: $partially-correct; + --primary: $primary; + --shadow: $shadow; + --shadow-l1: $shadow-l1; + --sidebar-color: $sidebar-color; + --small-font-size: $small-font-size; + --static-path: $static-path; + --submitted: $submitted; + --success: $success; + --tmg-f2: $tmg-f2; + --tmg-s2: $tmg-s2; + --transparent: $transparent; + --uxpl-gray-background: $uxpl-gray-background; + --uxpl-gray-base: $uxpl-gray-base; + --uxpl-gray-dark: $uxpl-gray-dark; + --very-light-text: $very-light-text; + --warning: $warning; + --warning-color: $warning-color; + --warning-color-accent: $warning-color-accent; + --white: $white; + --yellow: $yellow; +} 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/apps.py b/lms/djangoapps/bulk_email/apps.py index 2cfb725ba85e..63a44fcfcde4 100644 --- a/lms/djangoapps/bulk_email/apps.py +++ b/lms/djangoapps/bulk_email/apps.py @@ -7,3 +7,7 @@ class BulkEmailConfig(AppConfig): Application Configuration for bulk_email. """ name = 'lms.djangoapps.bulk_email' + + def ready(self): + import lms.djangoapps.bulk_email.signals # lint-amnesty, pylint: disable=unused-import + from edx_ace.signals import ACE_MESSAGE_SENT # lint-amnesty, pylint: disable=unused-import diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index 818d222b7a34..086b3636f7ef 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -1,12 +1,13 @@ """ Signal handlers for the bulk_email app """ - - +from django.contrib.auth import get_user_model from django.dispatch import receiver +from eventtracking import tracker from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS +from edx_ace.signals import ACE_MESSAGE_SENT from .models import Optout @@ -24,3 +25,35 @@ def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused- for enrollment in CourseEnrollment.objects.filter(user=user): Optout.objects.get_or_create(user=user, course_id=enrollment.course.id) + + +@receiver(ACE_MESSAGE_SENT) +def ace_email_sent_handler(sender, **kwargs): + """ + When an email is sent using ACE, this method will create an event to detect ace email success status + """ + # Fetch the message object from kwargs, defaulting to None if not present + message = kwargs.get('message', None) + + user_model = get_user_model() + try: + user_id = user_model.objects.get(email=message.recipient.email_address).id + except user_model.DoesNotExist: + user_id = None + course_email = message.context.get('course_email', 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.ace.message_sent', + { + 'message_type': message.name, + 'channel': channel, + 'course_id': course_id, + 'user_id': user_id, + } + ) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index afad888fe0c5..0152d14ff01f 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -26,6 +26,7 @@ from django.utils.translation import gettext as _ from django.utils.translation import override as override_language from edx_django_utils.monitoring import set_code_owner_attribute +from eventtracking import tracker from markupsafe import escape from common.djangoapps.util.date_utils import get_default_time_display @@ -467,7 +468,14 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas "send." ) raise exc - + tracker.emit( + 'edx.bulk_email.created', + { + 'course_id': str(course_email.course_id), + 'to_list': [user_obj.get('email', '') for user_obj in to_list], + 'total_recipients': total_recipients, + } + ) # Exclude optouts (if not a retry): # Note that we don't have to do the optout logic at all if this is a retry, # because we have presumably already performed the optout logic on the first diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py index 528baf97b53c..927699091558 100644 --- a/lms/djangoapps/bulk_email/views.py +++ b/lms/djangoapps/bulk_email/views.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.http import Http404 +from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -60,4 +61,12 @@ 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, + } + ) + return render_to_response('bulk_email/unsubscribe_success.html', context) 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/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index f8242efa0c9c..62af24f0ee37 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -1458,6 +1458,8 @@ def _test_unicode_data(self, text, mock_request): @disable_signal(views, 'comment_created') @disable_signal(views, 'comment_voted') @disable_signal(views, 'comment_deleted') +@disable_signal(views, 'comment_flagged') +@disable_signal(views, 'thread_flagged') class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): # Most of the test points use the same ddt data. # args: user, commentable_id, status_code diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index f72271f3a60c..25abcf80d486 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -3,7 +3,10 @@ """ import re +from bs4 import BeautifulSoup from django.conf import settings +from django.utils.text import Truncator + from lms.djangoapps.discussion.django_comment_client.permissions import get_team from openedx_events.learning.data import UserNotificationData, CourseNotificationData from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED, COURSE_NOTIFICATION_REQUESTED @@ -27,13 +30,24 @@ class DiscussionNotificationSender: Class to send notifications to users who are subscribed to the thread. """ - def __init__(self, thread, course, creator, parent_id=None): + def __init__(self, thread, course, creator, parent_id=None, comment_id=None): self.thread = thread self.course = course self.creator = creator self.parent_id = parent_id + self.comment_id = comment_id self.parent_response = None + self.comment = None self._get_parent_response() + self._get_comment() + + def _get_comment(self): + """ + Get comment object + """ + if not self.comment_id: + return + self.comment = Comment(id=self.comment_id).retrieve() def _send_notification(self, user_ids, notification_type, extra_context=None): """ @@ -99,7 +113,10 @@ def send_new_response_notification(self): there is a response to the main thread. """ if not self.parent_id and self.creator.id != int(self.thread.user_id): - self._send_notification([self.thread.user_id], "new_response") + context = { + 'email_content': clean_thread_html_body(self.comment.body), + } + self._send_notification([self.thread.user_id], "new_response", extra_context=context) def _response_and_thread_has_same_creator(self) -> bool: """ @@ -118,9 +135,10 @@ def send_new_comment_notification(self): self.parent_response and self.creator.id != int(self.thread.user_id) ): + author_name = f"{self.parent_response.username}'s" # use your if author of response is same as author of post. # use 'their' if comment author is also response author. - author_name = ( + author_pronoun = ( # Translators: Replier commented on "your" response to your post _("your") if self._response_and_thread_has_same_creator() @@ -129,10 +147,13 @@ def send_new_comment_notification(self): _("their") if self._response_and_comment_has_same_creator() else f"{self.parent_response.username}'s" + ) ) context = { "author_name": str(author_name), + "author_pronoun": str(author_pronoun), + "email_content": clean_thread_html_body(self.comment.body), } self._send_notification([self.thread.user_id], "new_comment", extra_context=context) @@ -146,7 +167,14 @@ def send_new_comment_on_response_notification(self): self.creator.id != int(self.parent_response.user_id) and not self._response_and_thread_has_same_creator() ): - self._send_notification([self.parent_response.user_id], "new_comment_on_response") + context = { + "email_content": clean_thread_html_body(self.comment.body), + } + self._send_notification( + [self.parent_response.user_id], + "new_comment_on_response", + extra_context=context + ) def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool: """ @@ -187,12 +215,29 @@ def send_response_on_followed_post_notification(self): # Remove duplicate users from the list of users to send notification users = list(set(users)) if not self.parent_id: - self._send_notification(users, "response_on_followed_post") + self._send_notification( + users, + "response_on_followed_post", + extra_context={ + "email_content": clean_thread_html_body(self.comment.body), + }) else: + author_name = f"{self.parent_response.username}'s" + # use 'their' if comment author is also response author. + author_pronoun = ( + # Translators: Replier commented on "their" response in a post you're following + _("their") + if self._response_and_comment_has_same_creator() + else f"{self.parent_response.username}'s" + ) self._send_notification( users, "comment_on_followed_post", - extra_context={"author_name": self.parent_response.username} + extra_context={ + "author_name": str(author_name), + "author_pronoun": str(author_pronoun), + "email_content": clean_thread_html_body(self.comment.body), + } ) def _create_cohort_course_audience(self): @@ -241,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): """ @@ -275,7 +326,8 @@ def send_new_thread_created_notification(self): ] context = { 'username': self.creator.username, - 'post_title': self.thread.title + 'post_title': self.thread.title, + "email_content": clean_thread_html_body(self.thread.body), } self._send_course_wide_notification(notification_type, audience_filters, context) @@ -300,7 +352,7 @@ def send_reported_content_notification(self): content_type = thread_types[self.thread.type][getattr(self.thread, 'depth', 0)] context = { - 'username': self.creator.username, + 'username': self.thread.username, 'content_type': content_type, 'content': thread_body } @@ -325,3 +377,26 @@ def is_discussion_cohorted(course_key_str): def remove_html_tags(text): clean = re.compile('<.*?>') return re.sub(clean, '', text) + + +def clean_thread_html_body(html_body): + """ + Get post body with tags removed and limited 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) diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index 45bf41fe905f..cbf438988975 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -33,7 +33,7 @@ def send_thread_created_notification(thread_id, course_key_str, user_id): @shared_task @set_code_owner_attribute -def send_response_notifications(thread_id, course_key_str, user_id, parent_id=None): +def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None): """ Send notifications to users who are subscribed to the thread. """ @@ -43,7 +43,7 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, user, parent_id) + notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id) notification_sender.send_new_comment_notification() notification_sender.send_new_response_notification() notification_sender.send_new_comment_on_response_notification() @@ -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_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py index eeed292037d1..f1a71fd1239e 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py @@ -1,13 +1,14 @@ """ Unit tests for the DiscussionNotificationSender class """ - +import re import unittest from unittest.mock import MagicMock, patch import pytest -from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender, \ + clean_thread_html_body @patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender' @@ -44,7 +45,7 @@ def _assert_send_notification_called_with(self, mock_send_notification, expected self.assertEqual(notification_type, "content_reported") self.assertEqual(context, { - 'username': 'test_user', + 'username': self.thread.username, 'content_type': expected_content_type, 'content': 'Thread body' }) @@ -88,3 +89,82 @@ def test_send_reported_content_notification_for_thread(self, mock_send_notificat self.notification_sender.send_reported_content_notification() self._assert_send_notification_called_with(mock_send_notification, 'thread') + + +class TestCleanThreadHtmlBody(unittest.TestCase): + """ + Tests for the clean_thread_html_body function + """ + + def test_html_tags_removal(self): + """ + Test that the clean_thread_html_body function removes unwanted HTML tags + """ + html_body = """ +

This is a link to a page.

+

Here is an image: image

+

Embedded video:

+

Script test:

+

Some other content that should remain.

+ """ + expected_output = ("

This is a link to a page.

" + "

Here is an image:

" + "

Embedded video:

" + "

Script test: alert('hello');

" + "

Some other content that should remain.

") + + result = clean_thread_html_body(html_body) + + def normalize_html(text): + """ + Normalize the output by removing extra whitespace, newlines, and spaces between tags + """ + text = re.sub(r'\s+', ' ', text).strip() # Replace any sequence of whitespace with a single space + text = re.sub(r'>\s+<', '><', text) # Remove spaces between HTML tags + return text + + normalized_result = normalize_html(result) + normalized_expected_output = normalize_html(expected_output) + + self.assertEqual(normalized_result, normalized_expected_output) + + def test_truncate_html_body(self): + """ + Test that the clean_thread_html_body function truncates the HTML body to 500 characters + """ + html_body = """ +

This is a long text that should be truncated to 500 characters.

+ """ * 20 # Repeat to exceed 500 characters + + result = clean_thread_html_body(html_body) + self.assertGreaterEqual(500, len(result)) + + def test_no_tags_to_remove(self): + """ + Test that the clean_thread_html_body function does not remove any tags if there are no unwanted tags + """ + html_body = "

This paragraph has no tags to remove.

" + expected_output = "

This paragraph has no tags to remove.

" + + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_empty_html_body(self): + """ + Test that the clean_thread_html_body function returns an empty string if the input is an empty string + """ + html_body = "" + expected_output = "" + + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_only_script_tag(self): + """ + Test that the clean_thread_html_body function removes the script tag and its content + """ + html_body = "" + expected_output = "alert('Hello');" + + result = clean_thread_html_body(html_body) + self.assertEqual(result.strip(), expected_output) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index f3f2a68ae668..ddfc120a8e4b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -273,6 +273,17 @@ def setUp(self): }) self._register_subscriptions_endpoint() + self.comment = ThreadMock(thread_id=4, creator=self.user_2, title='test comment', body='comment body') + self.register_get_comment_response( + { + 'id': self.comment.id, + 'thread_id': self.thread.id, + 'parent_id': None, + 'user_id': self.comment.user_id, + 'body': self.comment.body, + } + ) + def test_basic(self): """ Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin @@ -292,7 +303,13 @@ def test_send_notification_to_thread_creator(self): # Post the form or do what it takes to send the signal - send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None) + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_2.id, + self.comment.id, + parent_id=None + ) self.assertEqual(handler.call_count, 2) args = handler.call_args_list[0][1]['notification_data'] self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id]) @@ -300,6 +317,7 @@ def test_send_notification_to_thread_creator(self): expected_context = { 'replier_name': self.user_2.username, 'post_title': 'test thread', + 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_2.id } @@ -325,7 +343,13 @@ def test_send_notification_to_parent_threads(self): 'user_id': self.thread_2.user_id }) - send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id) + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_3.id, + self.comment.id, + parent_id=self.thread_2.id + ) # check if 2 call are made to the handler i.e. one for the response creator and one for the thread creator self.assertEqual(handler.call_count, 2) @@ -337,7 +361,9 @@ def test_send_notification_to_parent_threads(self): expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, + 'email_content': self.comment.body, 'author_name': 'dummy\'s', + 'author_pronoun': 'dummy\'s', 'course_name': self.course.display_name, 'sender_id': self.user_3.id } @@ -354,6 +380,7 @@ def test_send_notification_to_parent_threads(self): expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, + 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_3.id } @@ -371,7 +398,13 @@ def test_no_signal_on_creators_own_thread(self): """ handler = mock.Mock() USER_NOTIFICATION_REQUESTED.connect(handler) - send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None) + + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_1.id, + self.comment.id, parent_id=None + ) self.assertEqual(handler.call_count, 1) def test_comment_creators_own_response(self): @@ -388,7 +421,13 @@ def test_comment_creators_own_response(self): 'user_id': self.thread_3.user_id }) - send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id) + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_3.id, + parent_id=self.thread_2.id, + comment_id=self.comment.id + ) # check if 1 call is made to the handler i.e. for the thread creator self.assertEqual(handler.call_count, 2) @@ -399,9 +438,11 @@ def test_comment_creators_own_response(self): expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, - 'author_name': 'your', + 'author_name': 'dummy\'s', + 'author_pronoun': 'your', 'course_name': self.course.display_name, 'sender_id': self.user_3.id, + 'email_content': self.comment.body } self.assertDictEqual(args_comment.context, expected_context) self.assertEqual( @@ -427,7 +468,13 @@ def test_send_notification_to_followers(self, parent_id, notification_type): USER_NOTIFICATION_REQUESTED.connect(handler) # Post the form or do what it takes to send the signal - notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id) + notification_sender = DiscussionNotificationSender( + self.thread, + self.course, + self.user_2, + parent_id=parent_id, + comment_id=self.comment.id + ) notification_sender.send_response_on_followed_post_notification() self.assertEqual(handler.call_count, 1) args = handler.call_args[1]['notification_data'] @@ -437,11 +484,13 @@ def test_send_notification_to_followers(self, parent_id, notification_type): expected_context = { 'replier_name': self.user_2.username, 'post_title': 'test thread', + 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_2.id, } if parent_id: - expected_context['author_name'] = 'dummy' + expected_context['author_name'] = 'dummy\'s' + expected_context['author_pronoun'] = 'dummy\'s' self.assertDictEqual(args.context, expected_context) self.assertEqual( args.content_url, @@ -513,6 +562,7 @@ def test_new_comment_notification(self): thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') response = ThreadMock(thread_id=2, creator=self.user_2, title='test response') + comment = ThreadMock(thread_id=3, creator=self.user_2, title='test comment', body='comment body') self.register_get_thread_response({ 'id': thread.id, 'course_id': str(self.course.id), @@ -527,11 +577,20 @@ def test_new_comment_notification(self): 'thread_id': thread.id, 'user_id': response.user_id }) + self.register_get_comment_response({ + 'id': comment.id, + 'parent_id': response.id, + 'user_id': comment.user_id, + 'body': comment.body + }) self.register_get_subscriptions(1, {}) - send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id) + send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id, + comment_id=comment.id) handler.assert_called_once() context = handler.call_args[1]['notification_data'].context - self.assertEqual(context['author_name'], 'their') + self.assertEqual(context['author_name'], 'dummy\'s') + self.assertEqual(context['author_pronoun'], 'their') + self.assertEqual(context['email_content'], comment.body) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @@ -604,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)) @@ -621,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/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 989fd63855d5..27e34705f5df 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -675,12 +675,13 @@ class ThreadMock(object): A mock thread object """ - def __init__(self, thread_id, creator, title, parent_id=None): + def __init__(self, thread_id, creator, title, parent_id=None, body=''): self.id = thread_id self.user_id = str(creator.id) self.username = creator.username self.title = title self.parent_id = parent_id + self.body = body def url_with_id(self, params): return f"http://example.com/{params['id']}" diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 35288cdbd9be..2aa7d36456c4 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -176,8 +176,9 @@ def create_comment_created_notification(*args, **kwargs): comment = kwargs['post'] thread_id = comment.attributes['thread_id'] parent_id = comment.attributes['parent_id'] + comment_id = comment.attributes['id'] course_key_str = comment.attributes['course_id'] - send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, parent_id]) + send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, comment_id, parent_id]) @receiver(signals.comment_endorsed) 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/grade_utils.py b/lms/djangoapps/grades/grade_utils.py index 0344cf6c20d1..05d2058f37ba 100644 --- a/lms/djangoapps/grades/grade_utils.py +++ b/lms/djangoapps/grades/grade_utils.py @@ -7,6 +7,7 @@ from datetime import timedelta from django.utils import timezone +from django.conf import settings from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -22,7 +23,7 @@ def are_grades_frozen(course_key): if ENFORCE_FREEZE_GRADE_AFTER_COURSE_END.is_enabled(course_key): course = CourseOverview.get_from_id(course_key) if course.end: - freeze_grade_date = course.end + timedelta(30) + freeze_grade_date = course.end + timedelta(settings.GRADEBOOK_FREEZE_DAYS) now = timezone.now() return now > freeze_grade_date return False 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/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 300543def6c2..896d0deadcd9 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -34,6 +34,7 @@ get_event_transaction_id, set_event_transaction_type ) +from lms.djangoapps.branding.api import get_logo_url_for_email from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.grades.api import constants as grades_constants from lms.djangoapps.grades.api import disconnect_submissions_signal_receiver @@ -489,6 +490,7 @@ def get_email_params(course, auto_enroll, secure=True, course_key=None, display_ 'contact_mailing_address': contact_mailing_address, 'platform_name': platform_name, 'site_configuration_values': configuration_helpers.get_current_site_configuration_values(), + 'logo_url': get_logo_url_for_email(), } return email_params diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 2548cc4f411d..b0e533ee6f7f 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -642,6 +642,25 @@ def setUp(self): last_name='Student' ) + def test_api_without_login(self): + """ + verify in case of no authentication it returns 401. + """ + self.client.logout() + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + assert response.status_code == 401 + + def test_api_without_permission(self): + """ + verify in case of no authentication it returns 403. + """ + # removed role from course for instructor + CourseInstructorRole(self.course.id).remove_users(self.instructor) + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + assert response.status_code == 403 + @patch('lms.djangoapps.instructor.views.api.log.info') @ddt.data( b"test_student@example.com,test_student_1,tester1,USA", # Typical use case. diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index 59ccfac6caa1..741f57ef6d2b 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -23,6 +23,7 @@ from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, anonymous_id_for_user from common.djangoapps.student.roles import CourseCcxCoachRole from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from lms.djangoapps.branding.api import get_logo_url_for_email from lms.djangoapps.ccx.tests.factories import CcxFactory from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.courseware.models import StudentModule @@ -940,6 +941,7 @@ def setUpClass(cls): ) cls.course_about_url = cls.course_url + 'about' cls.registration_url = f'https://{site}/register' + cls.logo_url = get_logo_url_for_email() def test_normal_params(self): # For a normal site, what do we expect to get for the URLs? @@ -950,6 +952,7 @@ def test_normal_params(self): assert result['course_about_url'] == self.course_about_url assert result['registration_url'] == self.registration_url assert result['course_url'] == self.course_url + assert result['logo_url'] == self.logo_url def test_marketing_params(self): # For a site with a marketing front end, what do we expect to get for the URLs? @@ -962,6 +965,19 @@ def test_marketing_params(self): assert result['course_about_url'] is None assert result['registration_url'] == self.registration_url assert result['course_url'] == self.course_url + assert result['logo_url'] == self.logo_url + + @patch('lms.djangoapps.instructor.enrollment.get_logo_url_for_email', return_value='https://www.logo.png') + def test_logo_url_params(self, mock_get_logo_url_for_email): + # Verify that the logo_url is correctly set in the email params + result = get_email_params(self.course, False) + + assert result['auto_enroll'] is False + assert result['course_about_url'] == self.course_about_url + assert result['registration_url'] == self.registration_url + assert result['course_url'] == self.course_url + mock_get_logo_url_for_email.assert_called_once() + assert result['logo_url'] == 'https://www.logo.png' @ddt.ddt diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index e7a82496456e..d9a301b07e7f 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 @@ -105,6 +106,9 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError 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 +) 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 from openedx.core.djangoapps.course_groups.models import CourseUserGroup @@ -282,299 +286,305 @@ def wrapped(request, course_id): return wrapped -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_ENROLL) -def register_and_enroll_students(request, course_id): # pylint: disable=too-many-statements +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class RegisterAndEnrollStudents(APIView): """ Create new account and Enroll students in this course. - Passing a csv file that contains a list of students. - Order in csv should be the following email = 0; username = 1; name = 2; country = 3. - If there are more than 4 columns in the csv: cohort = 4, course mode = 5. - Requires staff access. - - -If the email address and username already exists and the user is enrolled in the course, - do nothing (including no email gets sent out) - - -If the email address already exists, but the username is different, - match on the email address only and continue to enroll the user in the course using the email address - as the matching criteria. Note the change of username as a warning message (but not a failure). - Send a standard enrollment email which is the same as the existing manual enrollment - - -If the username already exists (but not the email), assume it is a different user and fail - to create the new account. - The failure will be messaged in a response in the browser. """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_ENROLL - if not configuration_helpers.get_value( - 'ALLOW_AUTOMATED_SIGNUPS', - settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False), - ): - return HttpResponseForbidden() + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): # pylint: disable=too-many-statements + """ + Create new account and Enroll students in this course. + Passing a csv file that contains a list of students. + Order in csv should be the following email = 0; username = 1; name = 2; country = 3. + If there are more than 4 columns in the csv: cohort = 4, course mode = 5. + Requires staff access. + + -If the email address and username already exists and the user is enrolled in the course, + do nothing (including no email gets sent out) + + -If the email address already exists, but the username is different, + match on the email address only and continue to enroll the user in the course using the email address + as the matching criteria. Note the change of username as a warning message (but not a failure). + Send a standard enrollment email which is the same as the existing manual enrollment + + -If the username already exists (but not the email), assume it is a different user and fail + to create the new account. + The failure will be messaged in a response in the browser. + """ + if not configuration_helpers.get_value( + 'ALLOW_AUTOMATED_SIGNUPS', + settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False), + ): + return HttpResponseForbidden() - course_id = CourseKey.from_string(course_id) - warnings = [] - row_errors = [] - general_errors = [] + course_id = CourseKey.from_string(course_id) + warnings = [] + row_errors = [] + general_errors = [] - # email-students is a checkbox input type; will be present in POST if checked, absent otherwise - notify_by_email = 'email-students' in request.POST + # email-students is a checkbox input type; will be present in POST if checked, absent otherwise + notify_by_email = 'email-students' in request.POST - # for white labels we use 'shopping cart' which uses CourseMode.HONOR as - # course mode for creating course enrollments. - if CourseMode.is_white_label(course_id): - default_course_mode = CourseMode.HONOR - else: - default_course_mode = None + # for white labels we use 'shopping cart' which uses CourseMode.HONOR as + # course mode for creating course enrollments. + if CourseMode.is_white_label(course_id): + default_course_mode = CourseMode.HONOR + else: + default_course_mode = None - # Allow bulk enrollments in all non-expired course modes including "credit" (which is non-selectable) - valid_course_modes = set(map(lambda x: x.slug, CourseMode.modes_for_course( - course_id=course_id, - only_selectable=False, - include_expired=False, - ))) + # Allow bulk enrollments in all non-expired course modes including "credit" (which is non-selectable) + valid_course_modes = set(map(lambda x: x.slug, CourseMode.modes_for_course( + course_id=course_id, + only_selectable=False, + include_expired=False, + ))) - if 'students_list' in request.FILES: # lint-amnesty, pylint: disable=too-many-nested-blocks - students = [] + if 'students_list' in request.FILES: # lint-amnesty, pylint: disable=too-many-nested-blocks + students = [] - try: - upload_file = request.FILES.get('students_list') - if upload_file.name.endswith('.csv'): - students = list(csv.reader(upload_file.read().decode('utf-8-sig').splitlines())) - course = get_course_by_id(course_id) - else: + try: + upload_file = request.FILES.get('students_list') + if upload_file.name.endswith('.csv'): + students = list(csv.reader(upload_file.read().decode('utf-8-sig').splitlines())) + course = get_course_by_id(course_id) + else: + general_errors.append({ + 'username': '', 'email': '', + 'response': _( + 'Make sure that the file you upload is in CSV format with no ' + 'extraneous characters or rows.') + }) + + except Exception: # pylint: disable=broad-except general_errors.append({ - 'username': '', 'email': '', - 'response': _( - 'Make sure that the file you upload is in CSV format with no extraneous characters or rows.') + 'username': '', 'email': '', 'response': _('Could not read uploaded file.') }) + finally: + upload_file.close() + + generated_passwords = [] + # To skip fetching cohorts from the DB while iterating on students, + # {: CourseUserGroup} + cohorts_cache = {} + already_warned_not_cohorted = False + extra_fields_is_enabled = configuration_helpers.get_value( + 'ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', + settings.FEATURES.get('ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', False), + ) - except Exception: # pylint: disable=broad-except - general_errors.append({ - 'username': '', 'email': '', 'response': _('Could not read uploaded file.') - }) - finally: - upload_file.close() - - generated_passwords = [] - # To skip fetching cohorts from the DB while iterating on students, - # {: CourseUserGroup} - cohorts_cache = {} - already_warned_not_cohorted = False - extra_fields_is_enabled = configuration_helpers.get_value( - 'ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', - settings.FEATURES.get('ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', False), - ) - - # Iterate each student in the uploaded csv file. - for row_num, student in enumerate(students, 1): + # Iterate each student in the uploaded csv file. + for row_num, student in enumerate(students, 1): - # Verify that we have the expected number of columns in every row - # but allow for blank lines. - if not student: - continue + # Verify that we have the expected number of columns in every row + # but allow for blank lines. + if not student: + continue - if extra_fields_is_enabled: - is_valid_csv = 4 <= len(student) <= 6 - error = _('Data in row #{row_num} must have between four and six columns: ' - 'email, username, full name, country, cohort, and course mode. ' - 'The last two columns are optional.').format(row_num=row_num) - else: - is_valid_csv = len(student) == 4 - error = _('Data in row #{row_num} must have exactly four columns: ' - 'email, username, full name, and country.').format(row_num=row_num) + if extra_fields_is_enabled: + is_valid_csv = 4 <= len(student) <= 6 + error = _('Data in row #{row_num} must have between four and six columns: ' + 'email, username, full name, country, cohort, and course mode. ' + 'The last two columns are optional.').format(row_num=row_num) + else: + is_valid_csv = len(student) == 4 + error = _('Data in row #{row_num} must have exactly four columns: ' + 'email, username, full name, and country.').format(row_num=row_num) + + if not is_valid_csv: + general_errors.append({ + 'username': '', + 'email': '', + 'response': error + }) + continue - if not is_valid_csv: - general_errors.append({ - 'username': '', - 'email': '', - 'response': error - }) - continue + # Extract each column, handle optional columns if they exist. + email, username, name, country, *optional_cols = student + if optional_cols: + optional_cols.append(default_course_mode) + cohort_name, course_mode, *_tail = optional_cols + else: + cohort_name = None + course_mode = None - # Extract each column, handle optional columns if they exist. - email, username, name, country, *optional_cols = student - if optional_cols: - optional_cols.append(default_course_mode) - cohort_name, course_mode, *_tail = optional_cols - else: - cohort_name = None - course_mode = None + # Validate cohort name, and get the cohort object. Skip if course + # is not cohorted. + cohort = None - # Validate cohort name, and get the cohort object. Skip if course - # is not cohorted. - cohort = None + if cohort_name and not already_warned_not_cohorted: + if not is_course_cohorted(course_id): + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Course is not cohorted but cohort provided. ' + 'Ignoring cohort assignment for all users.') + }) + already_warned_not_cohorted = True + elif cohort_name in cohorts_cache: + cohort = cohorts_cache[cohort_name] + else: + # Don't attempt to create cohort or assign student if cohort + # does not exist. + try: + cohort = get_cohort_by_name(course_id, cohort_name) + except CourseUserGroup.DoesNotExist: + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Cohort name not found: {cohort}. ' + 'Ignoring cohort assignment for ' + 'all users.').format(cohort=cohort_name) + }) + cohorts_cache[cohort_name] = cohort + + # Validate course mode. + if not course_mode: + course_mode = default_course_mode + + if (course_mode is not None + and course_mode not in valid_course_modes): + # If `default is None` and the user is already enrolled, + # `CourseEnrollment.change_mode()` will not update the mode, + # hence two error messages. + if default_course_mode is None: + err_msg = _( + 'Invalid course mode: {mode}. Falling back to the ' + 'default mode, or keeping the current mode in case the ' + 'user is already enrolled.' + ).format(mode=course_mode) + else: + err_msg = _( + 'Invalid course mode: {mode}. Failling back to ' + '{default_mode}, or resetting to {default_mode} in case ' + 'the user is already enrolled.' + ).format(mode=course_mode, default_mode=default_course_mode) + row_errors.append({ + 'username': username, + 'email': email, + 'response': err_msg, + }) + course_mode = default_course_mode - if cohort_name and not already_warned_not_cohorted: - if not is_course_cohorted(course_id): + email_params = get_email_params(course, True, secure=request.is_secure()) + try: + validate_email(email) # Raises ValidationError if invalid + except ValidationError: row_errors.append({ 'username': username, 'email': email, - 'response': _('Course is not cohorted but cohort provided. ' - 'Ignoring cohort assignment for all users.') + 'response': _('Invalid email {email_address}.').format(email_address=email) }) - already_warned_not_cohorted = True - elif cohort_name in cohorts_cache: - cohort = cohorts_cache[cohort_name] else: - # Don't attempt to create cohort or assign student if cohort - # does not exist. - try: - cohort = get_cohort_by_name(course_id, cohort_name) - except CourseUserGroup.DoesNotExist: + if User.objects.filter(email=email).exists(): + # Email address already exists. assume it is the correct user + # and just register the user in the course and send an enrollment email. + user = User.objects.get(email=email) + + # see if it is an exact match with email and username + # if it's not an exact match then just display a warning message, but continue onwards + if not User.objects.filter(email=email, username=username).exists(): + warning_message = _( + 'An account with email {email} exists but the provided username {username} ' + 'is different. Enrolling anyway with {email}.' + ).format(email=email, username=username) + + warnings.append({ + 'username': username, 'email': email, 'response': warning_message + }) + log.warning('email %s already exist', email) + else: + log.info( + "user already exists with username '%s' and email '%s'", + username, + email + ) + + # enroll a user if it is not already enrolled. + if not is_user_enrolled_in_course(user, course_id): + # Enroll user to the course and add manual enrollment audit trail + create_manual_course_enrollment( + user=user, + course_id=course_id, + mode=course_mode, + enrolled_by=request.user, + reason='Enrolling via csv upload', + state_transition=UNENROLLED_TO_ENROLLED, + ) + enroll_email(course_id=course_id, + student_email=email, + auto_enroll=True, + email_students=notify_by_email, + email_params=email_params) + else: + # update the course mode if already enrolled + existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) + if existing_enrollment.mode != course_mode: + existing_enrollment.change_mode(mode=course_mode) + if cohort: + try: + add_user_to_cohort(cohort, user) + except ValueError: + # user already in this cohort; ignore + pass + elif is_email_retired(email): + # We are either attempting to enroll a retired user or create a new user with an email which is + # already associated with a retired account. Simply block these attempts. row_errors.append({ 'username': username, 'email': email, - 'response': _('Cohort name not found: {cohort}. ' - 'Ignoring cohort assignment for ' - 'all users.').format(cohort=cohort_name) + 'response': _('Invalid email {email_address}.').format(email_address=email), }) - cohorts_cache[cohort_name] = cohort - - # Validate course mode. - if not course_mode: - course_mode = default_course_mode - - if (course_mode is not None - and course_mode not in valid_course_modes): - # If `default is None` and the user is already enrolled, - # `CourseEnrollment.change_mode()` will not update the mode, - # hence two error messages. - if default_course_mode is None: - err_msg = _( - 'Invalid course mode: {mode}. Falling back to the ' - 'default mode, or keeping the current mode in case the ' - 'user is already enrolled.' - ).format(mode=course_mode) - else: - err_msg = _( - 'Invalid course mode: {mode}. Failling back to ' - '{default_mode}, or resetting to {default_mode} in case ' - 'the user is already enrolled.' - ).format(mode=course_mode, default_mode=default_course_mode) - row_errors.append({ - 'username': username, - 'email': email, - 'response': err_msg, - }) - course_mode = default_course_mode - - email_params = get_email_params(course, True, secure=request.is_secure()) - try: - validate_email(email) # Raises ValidationError if invalid - except ValidationError: - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email) - }) - else: - if User.objects.filter(email=email).exists(): - # Email address already exists. assume it is the correct user - # and just register the user in the course and send an enrollment email. - user = User.objects.get(email=email) - - # see if it is an exact match with email and username - # if it's not an exact match then just display a warning message, but continue onwards - if not User.objects.filter(email=email, username=username).exists(): - warning_message = _( - 'An account with email {email} exists but the provided username {username} ' - 'is different. Enrolling anyway with {email}.' - ).format(email=email, username=username) - - warnings.append({ - 'username': username, 'email': email, 'response': warning_message - }) - log.warning('email %s already exist', email) + log.warning('Email address %s is associated with a retired user, so course enrollment was ' + # lint-amnesty, pylint: disable=logging-not-lazy + 'blocked.', email) else: - log.info( - "user already exists with username '%s' and email '%s'", + # This email does not yet exist, so we need to create a new account + # If username already exists in the database, then create_and_enroll_user + # will raise an IntegrityError exception. + password = generate_unique_password(generated_passwords) + errors = create_and_enroll_user( + email, username, - email - ) - - # enroll a user if it is not already enrolled. - if not is_user_enrolled_in_course(user, course_id): - # Enroll user to the course and add manual enrollment audit trail - create_manual_course_enrollment( - user=user, - course_id=course_id, - mode=course_mode, - enrolled_by=request.user, - reason='Enrolling via csv upload', - state_transition=UNENROLLED_TO_ENROLLED, + name, + country, + password, + course_id, + course_mode, + request.user, + email_params, + email_user=notify_by_email, ) - enroll_email(course_id=course_id, - student_email=email, - auto_enroll=True, - email_students=notify_by_email, - email_params=email_params) - else: - # update the course mode if already enrolled - existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) - if existing_enrollment.mode != course_mode: - existing_enrollment.change_mode(mode=course_mode) - if cohort: - try: - add_user_to_cohort(cohort, user) - except ValueError: - # user already in this cohort; ignore - pass - elif is_email_retired(email): - # We are either attempting to enroll a retired user or create a new user with an email which is - # already associated with a retired account. Simply block these attempts. - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email), - }) - log.warning('Email address %s is associated with a retired user, so course enrollment was ' + # lint-amnesty, pylint: disable=logging-not-lazy - 'blocked.', email) - else: - # This email does not yet exist, so we need to create a new account - # If username already exists in the database, then create_and_enroll_user - # will raise an IntegrityError exception. - password = generate_unique_password(generated_passwords) - errors = create_and_enroll_user( - email, - username, - name, - country, - password, - course_id, - course_mode, - request.user, - email_params, - email_user=notify_by_email, - ) - row_errors.extend(errors) - if cohort: - try: - add_user_to_cohort(cohort, email) - except ValueError: - # user already in this cohort; ignore - # NOTE: Checking this here may be unnecessary if we can prove that a new user will never be - # automatically assigned to a cohort from the above. - pass - except ValidationError: - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email), - }) + row_errors.extend(errors) + if cohort: + try: + add_user_to_cohort(cohort, email) + except ValueError: + # user already in this cohort; ignore + # NOTE: Checking this here may be unnecessary if we can prove that a + # new user will never be + # automatically assigned to a cohort from the above. + pass + except ValidationError: + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Invalid email {email_address}.').format(email_address=email), + }) - else: - general_errors.append({ - 'username': '', 'email': '', 'response': _('File is not attached.') - }) + else: + general_errors.append({ + 'username': '', 'email': '', 'response': _('File is not attached.') + }) - results = { - 'row_errors': row_errors, - 'general_errors': general_errors, - 'warnings': warnings - } - return JsonResponse(results) + results = { + 'row_errors': row_errors, + 'general_errors': general_errors, + 'warnings': warnings + } + return JsonResponse(results) def generate_random_string(length): @@ -980,17 +990,8 @@ def bulk_beta_modify_access(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EDIT_COURSE_ACCESS) -@require_post_params( - unique_student_identifier="email or username of user to change access", - rolename="'instructor', 'staff', 'beta', or 'ccx_coach'", - action="'allow' or 'revoke'" -) -@common_exceptions_400 -def modify_access(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ModifyAccess(APIView): """ Modify staff/instructor access of other user. Requires instructor access. @@ -1002,77 +1003,83 @@ def modify_access(request, course_id): rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] action is one of ['allow', 'revoke'] """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'instructor', course_id, depth=None - ) - unique_student_identifier = request.POST.get('unique_student_identifier') - try: - user = get_student_from_identifier(unique_student_identifier) - except User.DoesNotExist: - response_payload = { - 'unique_student_identifier': unique_student_identifier, - 'userDoesNotExist': True, - } - return JsonResponse(response_payload) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS + serializer_class = AccessSerializer - # Check that user is active, because add_users - # in common/djangoapps/student/roles.py fails - # silently when we try to add an inactive user. - if not user.is_active: - response_payload = { - 'unique_student_identifier': user.username, - 'inactiveUser': True, - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Modify staff/instructor access of other user. + Requires instructor access. + """ + course_id = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'instructor', course_id, depth=None + ) - rolename = request.POST.get('rolename') - action = request.POST.get('action') + serializer_data = AccessSerializer(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - if rolename not in ROLES: - error = strip_tags(f"unknown rolename '{rolename}'") - log.error(error) - return HttpResponseBadRequest(error) + user = serializer_data.validated_data.get('unique_student_identifier') + if not user: + response_payload = { + 'unique_student_identifier': request.data.get('unique_student_identifier'), + 'userDoesNotExist': True, + } + return JsonResponse(response_payload) + + if not user.is_active: + response_payload = { + 'unique_student_identifier': user.username, + 'inactiveUser': True, + } + return JsonResponse(response_payload) + + rolename = serializer_data.data['rolename'] + action = serializer_data.data['action'] + + if rolename not in ROLES: + error = strip_tags(f"unknown rolename '{rolename}'") + log.error(error) + return HttpResponseBadRequest(error) + + # disallow instructors from removing their own instructor access. + if rolename == 'instructor' and user == request.user and action != 'allow': + response_payload = { + 'unique_student_identifier': user.username, + 'rolename': rolename, + 'action': action, + 'removingSelfAsInstructor': True, + } + return JsonResponse(response_payload) + + if action == 'allow': + allow_access(course, user, rolename) + if not is_user_enrolled_in_course(user, course_id): + CourseEnrollment.enroll(user, course_id) + elif action == 'revoke': + revoke_access(course, user, rolename) + else: + return HttpResponseBadRequest(strip_tags( + f"unrecognized action u'{action}'" + )) - # disallow instructors from removing their own instructor access. - if rolename == 'instructor' and user == request.user and action != 'allow': response_payload = { 'unique_student_identifier': user.username, 'rolename': rolename, 'action': action, - 'removingSelfAsInstructor': True, + 'success': 'yes', } return JsonResponse(response_payload) - if action == 'allow': - allow_access(course, user, rolename) - if not is_user_enrolled_in_course(user, course_id): - CourseEnrollment.enroll(user, course_id) - elif action == 'revoke': - revoke_access(course, user, rolename) - else: - return HttpResponseBadRequest(strip_tags( - f"unrecognized action u'{action}'" - )) - - response_payload = { - 'unique_student_identifier': user.username, - 'rolename': rolename, - 'action': action, - 'success': 'yes', - } - return JsonResponse(response_payload) - -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EDIT_COURSE_ACCESS) -@require_post_params(rolename="'instructor', 'staff', or 'beta'") -def list_course_role_members(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListCourseRoleMembersView(APIView): """ - List instructors and staff. - Requires instructor access. + View to list instructors and staff for a specific course. + Requires the user to have instructor access. rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] @@ -1088,33 +1095,41 @@ def list_course_role_members(request, course_id): ] } """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'instructor', course_id, depth=None - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS - rolename = request.POST.get('rolename') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handles POST request to list instructors and staff. - if rolename not in ROLES: - return HttpResponseBadRequest() + Args: + request (HttpRequest): The request object containing user data. + course_id (str): The ID of the course to list instructors and staff for. - def extract_user_info(user): - """ convert user into dicts for json view """ + Returns: + Response: A Response object containing the list of instructors and staff or an error message. - return { - 'username': user.username, - 'email': user.email, - 'first_name': user.first_name, - 'last_name': user.last_name, + Raises: + Http404: If the course does not exist. + """ + course_id = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'instructor', course_id, depth=None + ) + role_serializer = RoleNameSerializer(data=request.data) + role_serializer.is_valid(raise_exception=True) + rolename = role_serializer.data['rolename'] + + users = list_with_level(course.id, rolename) + serializer = UserSerializer(users, many=True) + + response_payload = { + 'course_id': str(course_id), + rolename: serializer.data, } - response_payload = { - 'course_id': str(course_id), - rolename: list(map(extract_user_info, list_with_level( - course.id, rolename - ))), - } - return JsonResponse(response_payload) + return Response(response_payload, status=status.HTTP_200_OK) class ProblemResponseReportPostParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -1496,28 +1511,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): @@ -1649,18 +1674,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 @@ -2160,23 +2198,35 @@ def list_background_email_tasks(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EMAIL) -def list_email_content(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListEmailContent(APIView): """ List the content of bulk emails sent """ - course_id = CourseKey.from_string(course_id) - task_type = InstructorTaskTypes.BULK_COURSE_EMAIL - # First get tasks list of bulk emails sent - emails = task_api.get_instructor_task_history(course_id, task_type=task_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EMAIL - response_payload = { - 'emails': list(map(extract_email_features, emails)), - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List the content of bulk emails sent for a specific course. + + Args: + request (HttpRequest): The HTTP request object. + course_id (str): The ID of the course for which to list the bulk emails. + + Returns: + HttpResponse: A response object containing the list of bulk email contents. + """ + course_id = CourseKey.from_string(course_id) + task_type = InstructorTaskTypes.BULK_COURSE_EMAIL + # First get tasks list of bulk emails sent + emails = task_api.get_instructor_task_history(course_id, task_type=task_type) + + response_payload = { + 'emails': list(map(extract_email_features, emails)), + } + return JsonResponse(response_payload) class InstructorTaskSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -2366,46 +2416,51 @@ def _list_instructor_tasks(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.SHOW_TASKS) -def list_entrance_exam_instructor_tasks(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListEntranceExamInstructorTasks(APIView): """ List entrance exam related instructor tasks. - - Takes either of the following query parameters - - unique_student_identifier is an email or username - - all_students is a boolean """ - course_id = CourseKey.from_string(course_id) - course = get_course_by_id(course_id) - student = request.POST.get('unique_student_identifier', None) - if student is not None: - student = get_student_from_identifier(student) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.SHOW_TASKS - try: - entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id) - except InvalidKeyError: - return HttpResponseBadRequest(_("Course has no valid entrance exam section.")) - if student: - # Specifying for a single student's entrance exam history - tasks = task_api.get_entrance_exam_instructor_task_history( - course_id, - entrance_exam_key, - student - ) - else: - # Specifying for all student's entrance exam history - tasks = task_api.get_entrance_exam_instructor_task_history( - course_id, - entrance_exam_key - ) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List entrance exam related instructor tasks. - response_payload = { - 'tasks': list(map(extract_task_features, tasks)), - } - return JsonResponse(response_payload) + Takes either of the following query parameters + - unique_student_identifier is an email or username + - all_students is a boolean + """ + course_id = CourseKey.from_string(course_id) + course = get_course_by_id(course_id) + student = request.POST.get('unique_student_identifier', None) + if student is not None: + student = get_student_from_identifier(student) + + try: + entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id) + except InvalidKeyError: + return HttpResponseBadRequest(_("Course has no valid entrance exam section.")) + if student: + # Specifying for a single student's entrance exam history + tasks = task_api.get_entrance_exam_instructor_task_history( + course_id, + entrance_exam_key, + student + ) + else: + # Specifying for all student's entrance exam history + tasks = task_api.get_entrance_exam_instructor_task_history( + course_id, + entrance_exam_key + ) + + response_payload = { + 'tasks': list(map(extract_task_features, tasks)), + } + return JsonResponse(response_payload) class ReportDownloadSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -2950,20 +3005,50 @@ def show_unit_extensions(request, course_id): return JsonResponse(dump_block_extensions(course, unit)) -@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') -def show_student_extensions(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ShowStudentExtensions(APIView): """ Shows all of the due date extensions granted to a particular student in a particular course. """ - student = require_student_from_identifier(request.POST.get('student')) - course = get_course_by_id(CourseKey.from_string(course_id)) - return JsonResponse(dump_student_extensions(course, student)) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + serializer_class = ShowStudentExtensionSerializer + permission_name = permissions.GIVE_STUDENT_EXTENSION + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handles POST requests to retrieve due date extensions for a specific student + within a specified course. + + Parameters: + - `request`: The HTTP request object containing user-submitted data. + - `course_id`: The ID of the course for which the extensions are being queried. + + Data expected in the request: + - `student`: A required field containing the identifier of the student for whom + the due date extensions are being retrieved. This data is extracted from the + request body. + + Returns: + - A JSON response containing the details of the due date extensions granted to + the specified student in the specified course. + """ + data = { + 'student': request.data.get('student') + } + serializer_data = self.serializer_class(data=data) + + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) + + student = serializer_data.validated_data.get('student') + if not student: + response_payload = f'Could not find student matching identifier: {request.data.get("student")}' + return JsonResponse({'error': response_payload}, status=400) + + course = get_course_by_id(CourseKey.from_string(course_id)) + return Response(dump_student_extensions(course, student)) def _split_input_list(str_list): diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 6b072ef0c12e..14fe15c83c2e 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -22,16 +22,16 @@ urlpatterns = [ path('students_update_enrollment', api.students_update_enrollment, name='students_update_enrollment'), - path('register_and_enroll_students', api.register_and_enroll_students, name='register_and_enroll_students'), - path('list_course_role_members', api.list_course_role_members, name='list_course_role_members'), - path('modify_access', api.modify_access, name='modify_access'), + path('register_and_enroll_students', api.RegisterAndEnrollStudents.as_view(), name='register_and_enroll_students'), + path('list_course_role_members', api.ListCourseRoleMembersView.as_view(), name='list_course_role_members'), + path('modify_access', api.ModifyAccess.as_view(), name='modify_access'), path('bulk_beta_modify_access', api.bulk_beta_modify_access, name='bulk_beta_modify_access'), path('get_problem_responses', api.get_problem_responses, name='get_problem_responses'), 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'), @@ -40,20 +40,20 @@ path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam, name='reset_student_attempts_for_entrance_exam'), path('rescore_entrance_exam', api.rescore_entrance_exam, name='rescore_entrance_exam'), - path('list_entrance_exam_instructor_tasks', api.list_entrance_exam_instructor_tasks, + path('list_entrance_exam_instructor_tasks', api.ListEntranceExamInstructorTasks.as_view(), 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_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), - path('list_email_content', api.list_email_content, name='list_email_content'), + 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.reset_due_date, name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), - path('show_student_extensions', api.show_student_extensions, name='show_student_extensions'), + path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), # proctored exam downloads... path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py new file mode 100644 index 000000000000..0697bed6832d --- /dev/null +++ b/lms/djangoapps/instructor/views/serializer.py @@ -0,0 +1,79 @@ +""" Instructor apis serializers. """ + +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ +from rest_framework import serializers +from .tools import get_student_from_identifier + +from lms.djangoapps.instructor.access import ROLES + + +class RoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer that describes the response of the problem response report generation API. + """ + + rolename = serializers.CharField(help_text=_("Role name")) + + def validate_rolename(self, value): + """ + Check that the rolename is valid. + """ + if value not in ROLES: + raise ValidationError(_("Invalid role name.")) + return value + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['username', 'email', 'first_name', 'last_name'] + + +class AccessSerializer(serializers.Serializer): + """ + Serializer for managing user access changes. + This serializer validates and processes the data required to modify + user access within a system. + """ + unique_student_identifier = serializers.CharField( + max_length=255, + help_text="Email or username of user to change access" + ) + rolename = serializers.CharField( + help_text="Role name to assign to the user" + ) + action = serializers.ChoiceField( + choices=['allow', 'revoke'], + help_text="Action to perform on the user's access" + ) + + def validate_unique_student_identifier(self, value): + """ + Validate that the unique_student_identifier corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user + + +class ShowStudentExtensionSerializer(serializers.Serializer): + """ + Serializer for validating and processing the student identifier. + """ + student = serializers.CharField(write_only=True, required=True) + + def validate_student(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 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/verify_student/docs/decisions/0001_extending_identity_verification.rst b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst new file mode 100644 index 000000000000..08735188fcdc --- /dev/null +++ b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst @@ -0,0 +1,65 @@ +0001. Extending Identity Verification +##################################### + +Status +****** + +**Accepted** *2024-08-26* + +Context +******* + +The backend implementation of identity verification (IDV) is in the `verify_student Django application`_. The +`verify_student Django application`_ also contains a frontend user experience for performing photo IDV via an +integration with Software Secure. There is also a `React-based implementation of this flow`_ in the +`frontend-app-account MFE`_, so the frontend user experience stored in the `verify_student Django application`_ is often +called the "legacy flow". + +The current architecture of the `verify_student Django application`_ requires that any additional implementations of IDV +are stored in the application. For example, the Software Secure integration is stored in this application even though +it is a custom integration that the Open edX community does not use. + +Different Open edX operators have different IDV needs. There is currently no way to add additional IDV implementations +to the platform without committing them to the core. The `verify_student Django application`_ needs enhanced +extensibility mechanisms to enable per-deployment integration of IDV implementations without modifying the core. + +Decision +******** + +* We will support the integration of additional implementations of IDV through the use of Python plugins into the + platform. +* We will add a ``VerificationAttempt`` model, which will store generic, implementation-agnostic information about an + IDV attempt. +* We will expose a simple Python API to write and update instances of the ``VerificationAttempt`` model. This will + enable plugins to publish information about their IDV attempts to the platform. +* The ``VerificationAttempt`` model will be integrated into the `verify_student Django application`_, particularly into + the `IDVerificationService`_. +* We will emit Open edX events for each status change of a ``VerificationAttempt``. +* We will add an Open edX filter hook to change the URL of the photo IDV frontend. + +Consequences +************ + +* It will become possible for Open edX operators to implement and integrate any additional forms of IDV necessary for + their deployment. +* The `verify_student Django application`_ will contain both concrete implementations of forms of IDV (i.e. manual, SSO, + Software Secure, etc.) and a generic, extensible implementation. The work to deprecate and remove the Software Secure + integration and to transition the other existing forms of IDV (i.e. manual and SSO) to Django plugins will occur + independently of the improvements to extensibility described in this decision. + +Rejected Alternatives +********************* + +We considered introducing a ``fetch_verification_attempts`` filter hook to allow plugins to expose additional +``VerificationAttempts`` to the platform in lieu of an additional model. However, doing database queries via filter +hooks can cause unpredictable performance problems, and this has been a pain point for Open edX. + +References +********** +`[Proposal] Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_ +`Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_ + +.. _frontend-app-account MFE: https://github.com/openedx/frontend-app-account +.. _IDVerificationService: https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/verify_student/services.py#L55 +.. _React-based implementation of this flow: https://github.com/openedx/frontend-app-account/tree/master/src/id-verification +.. _verify_student Django application: https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/verify_student diff --git a/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py new file mode 100644 index 000000000000..3f01047f9f51 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-08-26 14:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('verify_student', '0014_remove_softwaresecurephotoverification_expiry_date'), + ] + + operations = [ + migrations.CreateModel( + name='VerificationAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(blank=True, max_length=255)), + ('status', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('approved', 'approved'), ('denied', 'denied')], max_length=64)), + ('expiration_datetime', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index f7750a4cd662..903d80bf9245 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy from model_utils import Choices from model_utils.models import StatusModel, TimeStampedModel +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from opaque_keys.edx.django.models import CourseKeyField from lms.djangoapps.verify_student.ssencrypt import ( @@ -1189,3 +1190,27 @@ class Meta: def __str__(self): return str(self.arguments) + + +class VerificationAttempt(TimeStampedModel): + """ + The model represents impelementation-agnostic information about identity verification (IDV) attempts. + + Plugins that implement forms of IDV can store information about IDV attempts in this model for use across + the platform. + """ + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + name = models.CharField(blank=True, max_length=255) + + STATUS_CHOICES = [ + VerificationAttemptStatus.created, + VerificationAttemptStatus.pending, + VerificationAttemptStatus.approved, + VerificationAttemptStatus.denied, + ] + status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES]) + + expiration_datetime = models.DateTimeField( + null=True, + blank=True, + ) diff --git a/lms/djangoapps/verify_student/statuses.py b/lms/djangoapps/verify_student/statuses.py new file mode 100644 index 000000000000..b55a9042e0f6 --- /dev/null +++ b/lms/djangoapps/verify_student/statuses.py @@ -0,0 +1,21 @@ +""" +Status enums for verify_student. +""" + + +class VerificationAttemptStatus: + """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" + + # A verification attempt is pending when it has been started but has not yet been completed. + pending = "pending" + + # A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual + # review, etc). + approved = "approved" + + # A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review, + # etc). + denied = "denied" diff --git a/lms/envs/common.py b/lms/envs/common.py index 9f9004976e0c..334669215397 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -56,7 +56,10 @@ ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, - ENTERPRISE_OPERATOR_ROLE + ENTERPRISE_OPERATOR_ROLE, + SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, + PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, + PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, ) from openedx.core.constants import COURSE_KEY_REGEX, COURSE_KEY_PATTERN, COURSE_ID_PATTERN @@ -1101,6 +1104,11 @@ # If this is true, random scores will be generated for the purpose of debugging the profile graphs GENERATE_PROFILE_SCORES = False +# .. setting_name: GRADEBOOK_FREEZE_DAYS +# .. setting_default: 30 +# .. setting_description: Sets the number of days after which the gradebook will freeze following the course's end. +GRADEBOOK_FREEZE_DAYS = 30 + # Used with XQueue XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds XQUEUE_INTERFACE = { @@ -2287,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 @@ -3386,6 +3393,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", @@ -3678,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', ) @@ -4679,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. @@ -4740,6 +4750,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, ], + SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [ + PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, + PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, + ], } DATA_CONSENT_SHARE_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours @@ -5369,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 @@ -5462,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/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 2387ade00b9e..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 render appropriate subscription text when subscription is active with trial', () => { - testSubscriptionState( - 'active', - 'Manage my subscription', - 'Trial ends', - true, - ); - }); - - it('should render appropriate subscription text when subscription is active', () => { - testSubscriptionState( - '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/_variables.scss b/lms/static/sass/_variables.scss index dff9826b94b7..e1ccc714266d 100644 --- a/lms/static/sass/_variables.scss +++ b/lms/static/sass/_variables.scss @@ -1,5 +1,7 @@ // LMS-specific variables +@import '_builtin-block-variables'; + $text-width-readability-max: 1080px; // LMS-only colors 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/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) { %> - -<% } %>