")
+
+ 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) { %>
-
-<% } %>