diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py
index 66b5f46128..8e220a334c 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py
@@ -277,18 +277,14 @@ class ProctoringExamSettingsPostTests(
# response is correct
assert response.status_code == status.HTTP_400_BAD_REQUEST
- self.assertDictEqual(
- response.data,
+ self.assertIn(
{
- "detail": [
- {
- "proctoring_provider": (
- "The selected proctoring provider, notvalidprovider, is not a valid provider. "
- "Please select from one of ['test_proctoring_provider']."
- )
- }
- ]
+ "proctoring_provider": (
+ "The selected proctoring provider, notvalidprovider, is not a valid provider. "
+ "Please select from one of ['test_proctoring_provider']."
+ )
},
+ response.data['detail'],
)
# course settings have been updated
@@ -408,18 +404,14 @@ class ProctoringExamSettingsPostTests(
# response is correct
assert response.status_code == status.HTTP_400_BAD_REQUEST
- self.assertDictEqual(
- response.data,
+ self.assertIn(
{
- "detail": [
- {
- "proctoring_provider": (
- "The selected proctoring provider, lti_external, is not a valid provider. "
- "Please select from one of ['null']."
- )
- }
- ]
+ "proctoring_provider": (
+ "The selected proctoring provider, lti_external, is not a valid provider. "
+ "Please select from one of ['null']."
+ )
},
+ response.data['detail'],
)
# course settings have been updated
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 17e94112ba..dd00f245ef 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -1537,6 +1537,7 @@ def get_library_context(request, request_is_json=False):
)
from cms.djangoapps.contentstore.views.library import (
LIBRARIES_ENABLED,
+ user_can_view_create_library_button,
)
libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else []
@@ -1550,7 +1551,7 @@ def get_library_context(request, request_is_json=False):
'in_process_course_actions': [],
'courses': [],
'libraries_enabled': LIBRARIES_ENABLED,
- 'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active,
+ 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active,
'user': request.user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(request.user),
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
index 870c192653..8c314caa66 100644
--- a/cms/djangoapps/contentstore/views/library.py
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -69,31 +69,7 @@ def should_redirect_to_library_authoring_mfe():
)
-def user_can_view_create_library_button(user):
- """
- Helper method for displaying the visibilty of the create_library_button.
- """
- if not LIBRARIES_ENABLED:
- return False
- elif user.is_staff:
- return True
- elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
- is_course_creator = get_course_creator_status(user) == 'granted'
- has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists()
- has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists()
- has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists()
- return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
- else:
- # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
- disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
- disable_course_creation = settings.FEATURES.get('DISABLE_COURSE_CREATION', False)
- if disable_library_creation is not None:
- return not disable_library_creation
- else:
- return not disable_course_creation
-
-
-def user_can_create_library(user, org):
+def _user_can_create_library_for_org(user, org=None):
"""
Helper method for returning the library creation status for a particular user,
taking into account the value LIBRARIES_ENABLED.
@@ -109,29 +85,29 @@ def user_can_create_library(user, org):
Course Staff: Can make libraries in the organization which has courses of which they are staff.
Course Admin: Can make libraries in the organization which has courses of which they are Admin.
"""
- if org is None:
- return False
if not LIBRARIES_ENABLED:
return False
elif user.is_staff:
return True
- if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ org_filter_params = {}
+ if org:
+ org_filter_params['org'] = org
is_course_creator = get_course_creator_status(user) == 'granted'
- has_org_staff_role = org in OrgStaffRole().get_orgs_for_user(user)
+ has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists()
has_course_staff_role = (
UserBasedRole(user=user, role=CourseStaffRole.ROLE)
.courses_with_role()
- .filter(org=org)
+ .filter(**org_filter_params)
.exists()
)
has_course_admin_role = (
UserBasedRole(user=user, role=CourseInstructorRole.ROLE)
.courses_with_role()
- .filter(org=org)
+ .filter(**org_filter_params)
.exists()
)
return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
-
else:
# EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
@@ -142,6 +118,22 @@ def user_can_create_library(user, org):
return not disable_course_creation
+def user_can_view_create_library_button(user):
+ """
+ Helper method for displaying the visibilty of the create_library_button.
+ """
+ return _user_can_create_library_for_org(user)
+
+
+def user_can_create_library(user, org):
+ """
+ Helper method for to check if user can create library for given org.
+ """
+ if org is None:
+ return False
+ return _user_can_create_library_for_org(user, org)
+
+
@login_required
@ensure_csrf_cookie
@require_http_methods(('GET', 'POST'))
diff --git a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
index a7ee7f0ab0..0f38722e12 100644
--- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
+++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
@@ -162,6 +162,39 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin):
else:
assert 'To update these settings go to the Advanced Settings page.' in alert_text
+ @override_settings(
+ PROCTORING_BACKENDS={
+ 'DEFAULT': 'test_proctoring_provider',
+ 'proctortrack': {},
+ 'test_proctoring_provider': {},
+ },
+ FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED,
+ )
+ @ddt.data(
+ "advanced_settings_handler",
+ "course_handler",
+ )
+ def test_invalid_provider_alert(self, page_handler):
+ """
+ An alert should appear if the course has a proctoring provider that is not valid.
+ """
+ # create an error by setting an invalid proctoring provider
+ self.course.proctoring_provider = 'invalid_provider'
+ self.course.enable_proctored_exams = True
+ self.save_course()
+
+ url = reverse_course_url(page_handler, self.course.id)
+ resp = self.client.get(url, HTTP_ACCEPT='text/html')
+ alert_text = self._get_exam_settings_alert_text(resp.content)
+ assert (
+ 'This course has proctored exam settings that are incomplete or invalid.'
+ in alert_text
+ )
+ assert (
+ 'The proctoring provider configured for this course, \'invalid_provider\', is not valid.'
+ in alert_text
+ )
+
@ddt.data(
"advanced_settings_handler",
"course_handler",
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index fd5219dfb4..5d4ac5a4a3 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -217,7 +217,10 @@ class CourseMetadata:
try:
val = model['value']
if hasattr(block, key) and getattr(block, key) != val:
- key_values[key] = block.fields[key].from_json(val)
+ if key == 'proctoring_provider':
+ key_values[key] = block.fields[key].from_json(val, validate_providers=True)
+ else:
+ key_values[key] = block.fields[key].from_json(val)
except (TypeError, ValueError) as err:
raise ValueError(_("Incorrect format for field '{name}'. {detailed_message}").format( # lint-amnesty, pylint: disable=raise-missing-from
name=model['display_name'], detailed_message=str(err)))
@@ -253,7 +256,10 @@ class CourseMetadata:
try:
val = model['value']
if hasattr(block, key) and getattr(block, key) != val:
- key_values[key] = block.fields[key].from_json(val)
+ if key == 'proctoring_provider':
+ key_values[key] = block.fields[key].from_json(val, validate_providers=True)
+ else:
+ key_values[key] = block.fields[key].from_json(val)
except (TypeError, ValueError, ValidationError) as err:
did_validate = False
errors.append({'key': key, 'message': str(err), 'model': model})
@@ -484,6 +490,24 @@ class CourseMetadata:
enable_proctoring = block.enable_proctored_exams
if enable_proctoring:
+
+ if proctoring_provider_model:
+ proctoring_provider = proctoring_provider_model.get('value')
+ else:
+ proctoring_provider = block.proctoring_provider
+
+ # If the proctoring provider stored in the course block no longer
+ # matches the available providers for this instance, show an error
+ if proctoring_provider not in available_providers:
+ message = (
+ f'The proctoring provider configured for this course, \'{proctoring_provider}\', is not valid.'
+ )
+ errors.append({
+ 'key': 'proctoring_provider',
+ 'message': message,
+ 'model': proctoring_provider_model
+ })
+
# Require a valid escalation email if Proctortrack is chosen as the proctoring provider
escalation_email_model = settings_dict.get('proctoring_escalation_email')
if escalation_email_model:
@@ -491,11 +515,6 @@ class CourseMetadata:
else:
escalation_email = block.proctoring_escalation_email
- if proctoring_provider_model:
- proctoring_provider = proctoring_provider_model.get('value')
- else:
- proctoring_provider = block.proctoring_provider
-
missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.'
if proctoring_provider_model and proctoring_provider == 'proctortrack':
if not escalation_email:
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 8daa08aeb1..34dd8503f3 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -949,7 +949,6 @@ MIDDLEWARE = [
'openedx.core.djangoapps.cache_toolbox.middleware.CacheBackedAuthenticationMiddleware',
'common.djangoapps.student.middleware.UserStandingMiddleware',
- 'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'common.djangoapps.track.middleware.TrackMiddleware',
diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py
index 88358ce205..4a16c2a20a 100644
--- a/common/djangoapps/util/tests/test_db.py
+++ b/common/djangoapps/util/tests/test_db.py
@@ -1,6 +1,5 @@
"""Tests for util.db module."""
-import unittest
from io import StringIO
import ddt
@@ -121,9 +120,6 @@ class MigrationTests(TestCase):
Tests for migrations.
"""
- @unittest.skip(
- "Temporary skip for ENT-8971 while the client id and secret columns in Canvas replaced."
- )
@override_settings(MIGRATION_MODULES={})
def test_migrations_are_in_sync(self):
"""
diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb
index b4dcff0b4a..2e9d481096 100644
Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ
diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py
index afac652deb..086b3636f7 100644
--- a/lms/djangoapps/bulk_email/signals.py
+++ b/lms/djangoapps/bulk_email/signals.py
@@ -44,10 +44,15 @@ def ace_email_sent_handler(sender, **kwargs):
course_id = message.context.get('course_id')
if not course_id:
course_id = course_email.course_id if course_email else None
+ try:
+ channel = sender.__class__.__name__
+ except AttributeError:
+ channel = 'Other'
tracker.emit(
- 'edx.bulk_email.sent',
+ 'edx.ace.message_sent',
{
'message_type': message.name,
+ 'channel': channel,
'course_id': course_id,
'user_id': user_id,
}
diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py
index 9276990915..7ee3ea81b1 100644
--- a/lms/djangoapps/bulk_email/views.py
+++ b/lms/djangoapps/bulk_email/views.py
@@ -61,12 +61,16 @@ def opt_out_email_updates(request, token, course_id):
course_id,
)
- tracker.emit(
- 'edx.bulk_email.opt_out',
- {
- 'course_id': course_id,
- 'user_id': user.id,
- }
- )
+ event_name = 'edx.bulk_email.opt_out'
+ event_data = {
+ "username": user.username,
+ "user_id": user.id,
+ "course_id": course_id,
+ }
+ with tracker.get_tracker().context(event_name, event_data):
+ tracker.emit(
+ event_name,
+ event_data
+ )
return render_to_response('bulk_email/unsubscribe_success.html', context)
diff --git a/lms/djangoapps/course_home_api/course_metadata/serializers.py b/lms/djangoapps/course_home_api/course_metadata/serializers.py
index 7683a90894..29b92fc7b0 100644
--- a/lms/djangoapps/course_home_api/course_metadata/serializers.py
+++ b/lms/djangoapps/course_home_api/course_metadata/serializers.py
@@ -43,6 +43,7 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer):
"""
celebrations = serializers.DictField()
course_access = serializers.DictField()
+ studio_access = serializers.BooleanField()
course_id = serializers.CharField()
is_enrolled = serializers.BooleanField()
is_self_paced = serializers.BooleanField()
diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py
index 248f90389d..02c30ff62e 100644
--- a/lms/djangoapps/course_home_api/course_metadata/views.py
+++ b/lms/djangoapps/course_home_api/course_metadata/views.py
@@ -20,7 +20,7 @@ from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.course_api.api import course_detail
from lms.djangoapps.course_goals.models import UserActivity
from lms.djangoapps.course_home_api.course_metadata.serializers import CourseHomeMetadataSerializer
-from lms.djangoapps.courseware.access import has_access
+from lms.djangoapps.courseware.access import has_access, has_cms_access
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
from lms.djangoapps.courseware.courses import check_course_access
from lms.djangoapps.courseware.masquerade import setup_masquerade
@@ -124,6 +124,7 @@ class CourseHomeMetadataView(RetrieveAPIView):
data = {
'course_id': course.id,
'username': username,
+ 'studio_access': has_cms_access(request.user, course_key),
'is_staff': has_access(request.user, 'staff', course_key).has_access,
'original_user_is_staff': original_user_is_staff,
'number': course.display_number_with_default,
diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py
index 74f1d74f83..436cb3514a 100644
--- a/lms/djangoapps/courseware/access.py
+++ b/lms/djangoapps/courseware/access.py
@@ -53,7 +53,8 @@ from common.djangoapps.student.roles import (
GlobalStaff,
OrgInstructorRole,
OrgStaffRole,
- SupportStaffRole
+ SupportStaffRole,
+ CourseLimitedStaffRole,
)
from common.djangoapps.util import milestones_helpers as milestones_helpers # lint-amnesty, pylint: disable=useless-import-alias
from common.djangoapps.util.milestones_helpers import (
@@ -97,6 +98,31 @@ def has_ccx_coach_role(user, course_key):
return False
+def has_cms_access(user, course_key):
+ """
+ Check if user has access to the CMS. When requesting from the LMS, a user with the
+ limited staff access role needs access to the CMS APIs, but not the CMS site. This
+ function accounts for this edge case when determining if a user has access to the CMS
+ site.
+
+ Arguments:
+ user (User): the user whose course access we are checking.
+ course_key: Key to course.
+
+ Returns:
+ bool: whether user has access to the CMS site.
+ """
+ has_course_author_access = auth.has_course_author_access(user, course_key)
+ is_limited_staff = auth.user_has_role(
+ user, CourseLimitedStaffRole(course_key)
+ ) and not GlobalStaff().has_user(user)
+
+ if is_limited_staff and has_course_author_access:
+ return False
+
+ return has_course_author_access
+
+
@function_trace('has_access')
def has_access(user, action, obj, course_key=None):
"""
diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py
index d53d620d3e..bd0c1854ab 100644
--- a/lms/djangoapps/courseware/tests/test_about.py
+++ b/lms/djangoapps/courseware/tests/test_about.py
@@ -156,7 +156,10 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
assert resp.status_code == 200
pre_requisite_courses = get_prerequisite_courses_display(course)
pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])])
- assert '{}'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long
+ assert (
+ f'You must successfully complete '
+ f'{pre_requisite_courses[0]["display"]} before you begin this course.'
+ ) in resp.content.decode(resp.charset).strip('\n')
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True})
def test_about_page_unfulfilled_prereqs(self):
@@ -190,7 +193,10 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
assert resp.status_code == 200
pre_requisite_courses = get_prerequisite_courses_display(course)
pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])])
- assert '{}'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long
+ assert (
+ f'You must successfully complete '
+ f'{pre_requisite_courses[0]["display"]} before you begin this course.'
+ ) in resp.content.decode(resp.charset).strip('\n')
url = reverse('about_course', args=[str(pre_requisite_course.id)])
resp = self.client.get(url)
diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
index 96e392c35d..25abcf80d4 100644
--- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
@@ -286,13 +286,19 @@ class DiscussionNotificationSender:
response on his thread has been endorsed
"""
if self.creator.id != int(self.thread.user_id):
- self._send_notification([self.thread.user_id], "response_endorsed_on_thread")
+ context = {
+ "email_content": clean_thread_html_body(self.comment.body)
+ }
+ self._send_notification([self.thread.user_id], "response_endorsed_on_thread", extra_context=context)
def send_response_endorsed_notification(self):
"""
Sends a notification to the author of the response
"""
- self._send_notification([self.creator.id], "response_endorsed")
+ context = {
+ "email_content": clean_thread_html_body(self.comment.body)
+ }
+ self._send_notification([self.creator.id], "response_endorsed", extra_context=context)
def send_new_thread_created_notification(self):
"""
diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py
index a87fafd4ca..cbf4389889 100644
--- a/lms/djangoapps/discussion/rest_api/tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tasks.py
@@ -64,7 +64,7 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str,
creator = User.objects.get(id=response.user_id)
endorser = User.objects.get(id=endorsed_by)
course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True)
- notification_sender = DiscussionNotificationSender(thread, course, creator)
+ notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id)
# skip sending notification to author of thread if they are the same as the author of the response
if response.user_id != thread.user_id:
# sends notification to author of thread
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
index 8efd5cd49c..ddfc120a8e 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
@@ -663,6 +663,7 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT
'post_title': 'test thread',
'course_name': self.course.display_name,
'sender_id': int(self.user_2.id),
+ 'email_content': 'dummy'
}
self.assertDictEqual(notification_data.context, expected_context)
self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id))
@@ -680,6 +681,7 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT
'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/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index b0e533ee6f..65d35221d4 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -3515,6 +3515,14 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm
self.client.logout()
url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, self.full_test_message)
+ assert response.status_code == 401
+
+ def test_send_email_logged_in_but_no_perms(self):
+ self.client.logout()
+ user = UserFactory()
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
+ url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
+ response = self.client.post(url, self.full_test_message)
assert response.status_code == 403
def test_send_email_but_not_staff(self):
@@ -3635,6 +3643,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm
url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
with LogCapture() as log:
+
response = self.client.post(url, self.full_test_message)
assert response.status_code == 400
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 990170d7b4..e48c4d8865 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -107,7 +107,8 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, Queue
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
+ AccessSerializer, RoleNameSerializer, ShowStudentExtensionSerializer,
+ UserSerializer, SendEmailSerializer, StudentAttemptsSerializer
)
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
@@ -1818,23 +1819,24 @@ class StudentProgressUrl(APIView):
return Response(serializer.data)
-@transaction.non_atomic_requests
-@require_POST
-@ensure_csrf_cookie
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_course_permission(permissions.GIVE_STUDENT_EXTENSION)
-@require_post_params(
- problem_to_reset="problem urlname to reset"
-)
-@common_exceptions_400
-def reset_student_attempts(request, course_id):
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+@method_decorator(transaction.non_atomic_requests, name='dispatch')
+class ResetStudentAttempts(DeveloperErrorViewMixin, APIView):
"""
-
Resets a students attempts counter or starts a task to reset all students
attempts counters. Optionally deletes student state for a problem. Limited
to staff access. Some sub-methods limited to instructor access.
+ """
+ http_method_names = ['post']
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ permission_name = permissions.GIVE_STUDENT_EXTENSION
+ serializer_class = StudentAttemptsSerializer
- Takes some of the following query parameters
+ @method_decorator(ensure_csrf_cookie)
+ @transaction.non_atomic_requests
+ def post(self, request, course_id):
+ """
+ Takes some of the following query parameters
- problem_to_reset is a urlname of a problem
- unique_student_identifier is an email or username
- all_students is a boolean
@@ -1844,65 +1846,74 @@ def reset_student_attempts(request, course_id):
- delete_module is a boolean
requires instructor access
mutually exclusive with all_students
- """
- course_id = CourseKey.from_string(course_id)
- course = get_course_with_access(
- request.user, 'staff', course_id, depth=None
- )
- all_students = _get_boolean_param(request, 'all_students')
+ """
+ course_id = CourseKey.from_string(course_id)
+ serializer_data = self.serializer_class(data=request.data)
- if all_students and not has_access(request.user, 'instructor', course):
- return HttpResponseForbidden("Requires instructor access.")
+ if not serializer_data.is_valid():
+ return HttpResponseBadRequest(reason=serializer_data.errors)
- problem_to_reset = strip_if_string(request.POST.get('problem_to_reset'))
- student_identifier = request.POST.get('unique_student_identifier', None)
- student = None
- if student_identifier is not None:
- student = get_student_from_identifier(student_identifier)
- delete_module = _get_boolean_param(request, 'delete_module')
-
- # parameter combinations
- if all_students and student:
- return HttpResponseBadRequest(
- "all_students and unique_student_identifier are mutually exclusive."
- )
- if all_students and delete_module:
- return HttpResponseBadRequest(
- "all_students and delete_module are mutually exclusive."
+ course = get_course_with_access(
+ request.user, 'staff', course_id, depth=None
)
- try:
- module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id)
- except InvalidKeyError:
- return HttpResponseBadRequest()
+ all_students = serializer_data.validated_data.get('all_students')
- response_payload = {}
- response_payload['problem_to_reset'] = problem_to_reset
+ if all_students and not has_access(request.user, 'instructor', course):
+ return HttpResponseForbidden("Requires instructor access.")
- if student:
- try:
- enrollment.reset_student_attempts(
- course_id,
- student,
- module_state_key,
- requesting_user=request.user,
- delete_module=delete_module
+ problem_to_reset = strip_if_string(serializer_data.validated_data.get('problem_to_reset'))
+ student_identifier = request.POST.get('unique_student_identifier', None)
+ student = serializer_data.validated_data.get('unique_student_identifier')
+ delete_module = serializer_data.validated_data.get('delete_module')
+
+ # parameter combinations
+ if all_students and student:
+ return HttpResponseBadRequest(
+ "all_students and unique_student_identifier are mutually exclusive."
+ )
+ if all_students and delete_module:
+ return HttpResponseBadRequest(
+ "all_students and delete_module are mutually exclusive."
)
- except StudentModule.DoesNotExist:
- return HttpResponseBadRequest(_("Module does not exist."))
- except sub_api.SubmissionError:
- # Trust the submissions API to log the error
- error_msg = _("An error occurred while deleting the score.")
- return HttpResponse(error_msg, status=500)
- response_payload['student'] = student_identifier
- elif all_students:
- task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
- response_payload['task'] = TASK_SUBMISSION_OK
- response_payload['student'] = 'All Students'
- else:
- return HttpResponseBadRequest()
- return JsonResponse(response_payload)
+ try:
+ module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id)
+ except InvalidKeyError:
+ return HttpResponseBadRequest()
+
+ response_payload = {}
+ response_payload['problem_to_reset'] = problem_to_reset
+
+ if student:
+ try:
+ enrollment.reset_student_attempts(
+ course_id,
+ student,
+ module_state_key,
+ requesting_user=request.user,
+ delete_module=delete_module
+ )
+ except StudentModule.DoesNotExist:
+ return HttpResponseBadRequest(_("Module does not exist."))
+ except sub_api.SubmissionError:
+ # Trust the submissions API to log the error
+ error_msg = _("An error occurred while deleting the score.")
+ return HttpResponse(error_msg, status=500)
+ response_payload['student'] = student_identifier
+
+ elif all_students:
+ try:
+ task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
+ response_payload['task'] = TASK_SUBMISSION_OK
+ response_payload['student'] = 'All Students'
+ except Exception: # pylint: disable=broad-except
+ error_msg = _("An error occurred while attempting to reset for all students.")
+ return HttpResponse(error_msg, status=500)
+ else:
+ return HttpResponseBadRequest()
+
+ return JsonResponse(response_payload)
@transaction.non_atomic_requests
@@ -1939,8 +1950,10 @@ def reset_student_attempts_for_entrance_exam(request, course_id):
student_identifier = request.POST.get('unique_student_identifier', None)
student = None
+
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
+
all_students = _get_boolean_param(request, 'all_students')
delete_module = _get_boolean_param(request, 'delete_module')
@@ -2542,16 +2555,22 @@ class ReportDownloads(DeveloperErrorViewMixin, APIView):
return _list_report_downloads(request=request, course_id=course_id)
-@require_POST
-@ensure_csrf_cookie
-def list_report_downloads(request, course_id):
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+class ListReportDownloads(APIView):
+
"""
List grade CSV files that are available for download for this course.
Takes the following query parameters:
- (optional) report_name - name of the report
"""
- return _list_report_downloads(request=request, course_id=course_id)
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ permission_name = permissions.CAN_RESEARCH
+
+ @method_decorator(ensure_csrf_cookie)
+ def post(self, request, course_id):
+
+ return _list_report_downloads(request=request, course_id=course_id)
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@@ -2765,81 +2784,96 @@ def list_forum_members(request, course_id):
return JsonResponse(response_payload)
-@transaction.non_atomic_requests
-@require_POST
-@ensure_csrf_cookie
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_course_permission(permissions.EMAIL)
-@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
-@common_exceptions_400
-def send_email(request, course_id):
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+@method_decorator(transaction.non_atomic_requests, name='dispatch')
+class SendEmail(DeveloperErrorViewMixin, APIView):
"""
Send an email to self, staff, cohorts, or everyone involved in a course.
- Query Parameters:
- - 'send_to' specifies what group the email should be sent to
- Options are defined by the CourseEmail model in
- lms/djangoapps/bulk_email/models.py
- - 'subject' specifies email's subject
- - 'message' specifies email's content
"""
- course_id = CourseKey.from_string(course_id)
- course_overview = CourseOverview.get_from_id(course_id)
+ http_method_names = ['post']
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ permission_name = permissions.EMAIL
+ serializer_class = SendEmailSerializer
- if not is_bulk_email_feature_enabled(course_id):
- log.warning(f"Email is not enabled for course {course_id}")
- return HttpResponseForbidden("Email is not enabled for this course.")
+ @method_decorator(ensure_csrf_cookie)
+ @method_decorator(transaction.non_atomic_requests)
+ def post(self, request, course_id):
+ """
+ Query Parameters:
+ - 'send_to' specifies what group the email should be sent to
+ Options are defined by the CourseEmail model in
+ lms/djangoapps/bulk_email/models.py
+ - 'subject' specifies email's subject
+ - 'message' specifies email's content
+ """
+ course_id = CourseKey.from_string(course_id)
+ course_overview = CourseOverview.get_from_id(course_id)
- targets = json.loads(request.POST.get("send_to"))
- subject = request.POST.get("subject")
- message = request.POST.get("message")
- # optional, this is a date and time in the form of an ISO8601 string
- schedule = request.POST.get("schedule", "")
+ if not is_bulk_email_feature_enabled(course_id):
+ log.warning(f"Email is not enabled for course {course_id}")
+ return HttpResponseForbidden("Email is not enabled for this course.")
- schedule_dt = None
- if schedule:
+ serializer_data = self.serializer_class(data=request.data)
+ if not serializer_data.is_valid():
+ return HttpResponseBadRequest(reason=serializer_data.errors)
+
+ # Skipping serializer validation to avoid potential disruptions.
+ # The API handles numerous input variations, and changes here could introduce breaking issues.
+
+ targets = json.loads(request.POST.get("send_to"))
+
+ subject = serializer_data.validated_data.get("subject")
+ message = serializer_data.validated_data.get("message")
+ # optional, this is a date and time in the form of an ISO8601 string
+ schedule = serializer_data.validated_data.get("schedule", "")
+
+ schedule_dt = None
+ if schedule:
+ try:
+ # convert the schedule from a string to a datetime, then check if its a
+ # valid future date and time, dateutil
+ # will throw a ValueError if the schedule is no good.
+ schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc)
+ if schedule_dt < datetime.datetime.now(pytz.utc):
+ raise ValueError("the requested schedule is in the past")
+ except ValueError as value_error:
+ error_message = (
+ f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: "
+ f"{value_error}"
+ )
+ log.error(error_message)
+ return HttpResponseBadRequest(error_message)
+
+ # Retrieve the customized email "from address" and email template from site configuration for the c
+ # ourse/partner.
+ # If there is no site configuration enabled for the current site then we use system defaults for both.
+ from_addr = _get_branded_email_from_address(course_overview)
+ template_name = _get_branded_email_template(course_overview)
+
+ # Create the CourseEmail object. This is saved immediately so that any transaction that has been
+ # pending up to this point will also be committed.
try:
- # convert the schedule from a string to a datetime, then check if its a valid future date and time, dateutil
- # will throw a ValueError if the schedule is no good.
- schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc)
- if schedule_dt < datetime.datetime.now(pytz.utc):
- raise ValueError("the requested schedule is in the past")
- except ValueError as value_error:
- error_message = (
- f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: "
- f"{value_error}"
+ email = create_course_email(
+ course_id,
+ request.user,
+ targets,
+ subject,
+ message,
+ template_name=template_name,
+ from_addr=from_addr,
)
- log.error(error_message)
- return HttpResponseBadRequest(error_message)
+ except ValueError as err:
+ return HttpResponseBadRequest(repr(err))
- # Retrieve the customized email "from address" and email template from site configuration for the course/partner. If
- # there is no site configuration enabled for the current site then we use system defaults for both.
- from_addr = _get_branded_email_from_address(course_overview)
- template_name = _get_branded_email_template(course_overview)
+ # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
+ task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt)
- # Create the CourseEmail object. This is saved immediately so that any transaction that has been pending up to this
- # point will also be committed.
- try:
- email = create_course_email(
- course_id,
- request.user,
- targets,
- subject,
- message,
- template_name=template_name,
- from_addr=from_addr,
- )
- except ValueError as err:
- return HttpResponseBadRequest(repr(err))
+ response_payload = {
+ 'course_id': str(course_id),
+ 'success': True,
+ }
- # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
- task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt)
-
- response_payload = {
- 'course_id': str(course_id),
- 'success': True,
- }
-
- return JsonResponse(response_payload)
+ return JsonResponse(response_payload)
@require_POST
diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py
index 14fe15c83c..b54d38d804 100644
--- a/lms/djangoapps/instructor/views/api_urls.py
+++ b/lms/djangoapps/instructor/views/api_urls.py
@@ -34,7 +34,7 @@ urlpatterns = [
path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'),
path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"),
path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'),
- path('reset_student_attempts', api.reset_student_attempts, name='reset_student_attempts'),
+ path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'),
path('rescore_problem', api.rescore_problem, name='rescore_problem'),
path('override_problem_score', api.override_problem_score, name='override_problem_score'),
path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam,
@@ -49,7 +49,7 @@ urlpatterns = [
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('send_email', api.SendEmail.as_view(), 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'),
@@ -59,7 +59,7 @@ urlpatterns = [
path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'),
# Grade downloads...
- path('list_report_downloads', api.list_report_downloads, name='list_report_downloads'),
+ path('list_report_downloads', api.ListReportDownloads.as_view(), name='list_report_downloads'),
path('calculate_grades_csv', api.calculate_grades_csv, name='calculate_grades_csv'),
path('problem_grade_report', api.problem_grade_report, name='problem_grade_report'),
diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py
index 0697bed683..d4a3d4e832 100644
--- a/lms/djangoapps/instructor/views/serializer.py
+++ b/lms/djangoapps/instructor/views/serializer.py
@@ -77,3 +77,75 @@ class ShowStudentExtensionSerializer(serializers.Serializer):
return None
return user
+
+
+class StudentAttemptsSerializer(serializers.Serializer):
+ """
+ Serializer for resetting a students attempts counter or starts a task to reset all students
+ attempts counters.
+ """
+ problem_to_reset = serializers.CharField(
+ help_text="The identifier or description of the problem that needs to be reset."
+ )
+
+ # following are optional params.
+ unique_student_identifier = serializers.CharField(
+ help_text="Email or username of student.", required=False
+ )
+ all_students = serializers.CharField(required=False)
+ delete_module = serializers.CharField(required=False)
+
+ def validate_all_students(self, value):
+ """
+ converts the all_student params value to bool.
+ """
+ return self.verify_bool(value)
+
+ def validate_delete_module(self, value):
+ """
+ converts the all_student params value.
+ """
+ return self.verify_bool(value)
+
+ def validate_unique_student_identifier(self, value):
+ """
+ Validate that the student corresponds to an existing user.
+ """
+ try:
+ user = get_student_from_identifier(value)
+ except User.DoesNotExist:
+ return None
+
+ return user
+
+ def verify_bool(self, value):
+ """
+ Returns the value of the boolean parameter with the given
+ name in the POST request. Handles translation from string
+ values to boolean values.
+ """
+ if value is not None:
+ return value in ['true', 'True', True]
+
+ return False
+
+
+class SendEmailSerializer(serializers.Serializer):
+ """
+ Serializer for sending an email with optional scheduling.
+
+ Fields:
+ send_to (str): The email address of the recipient. This field is required.
+ subject (str): The subject line of the email. This field is required.
+ message (str): The body of the email. This field is required.
+ schedule (str, optional):
+ An optional field to specify when the email should be sent.
+ If provided, this should be a string that can be parsed into a
+ datetime format or some other scheduling logic.
+ """
+ send_to = serializers.CharField(write_only=True, required=True)
+
+ # set max length as per model field.
+ subject = serializers.CharField(max_length=128, write_only=True, required=True)
+ message = serializers.CharField(required=True)
+ schedule = serializers.CharField(required=False)
diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py
index c974fa0c8e..f61b90d682 100644
--- a/lms/djangoapps/verify_student/api.py
+++ b/lms/djangoapps/verify_student/api.py
@@ -1,12 +1,25 @@
"""
API module.
"""
+import logging
+
from django.conf import settings
+from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
+from datetime import datetime
+from typing import Optional
+
from lms.djangoapps.verify_student.emails import send_verification_approved_email
+from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus
+from lms.djangoapps.verify_student.models import VerificationAttempt
+from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus
from lms.djangoapps.verify_student.tasks import send_verification_status_email
+log = logging.getLogger(__name__)
+
+User = get_user_model()
+
def send_approval_email(attempt):
"""
@@ -33,3 +46,82 @@ def send_approval_email(attempt):
else:
email_context = {'user': attempt.user, 'expiration_datetime': expiration_datetime.strftime("%m/%d/%Y")}
send_verification_approved_email(context=email_context)
+
+
+def create_verification_attempt(user: User, name: str, status: str, expiration_datetime: Optional[datetime] = None):
+ """
+ Create a verification attempt.
+
+ This method is intended to be used by IDV implementation plugins to create VerificationAttempt instances.
+
+ Args:
+ user (User): the user (usually a learner) performing the verification attempt
+ name (string): the name being ID verified
+ status (string): the initial status of the verification attempt
+ expiration_datetime (datetime, optional): When the verification attempt expires. Defaults to None.
+
+ Returns:
+ id (int): The id of the created VerificationAttempt instance
+ """
+ verification_attempt = VerificationAttempt.objects.create(
+ user=user,
+ name=name,
+ status=status,
+ expiration_datetime=expiration_datetime,
+ )
+
+ return verification_attempt.id
+
+
+def update_verification_attempt(
+ attempt_id: int,
+ name: Optional[str] = None,
+ status: Optional[str] = None,
+ expiration_datetime: Optional[datetime] = None
+):
+ """
+ Update a verification attempt.
+
+ This method is intended to be used by IDV implementation plugins to update VerificationAttempt instances.
+
+ Arguments:
+ * attempt_id (int): the verification attempt id of the attempt to update
+ * name (string, optional): the new name being ID verified
+ * status (string, optional): the new status of the verification attempt
+ * expiration_datetime (datetime, optional): The new expiration date and time
+
+ Returns:
+ * None
+ """
+ try:
+ attempt = VerificationAttempt.objects.get(id=attempt_id)
+ except VerificationAttempt.DoesNotExist:
+ log.error(
+ f'VerificationAttempt with id {attempt_id} was not found '
+ f'when updating the attempt to status={status}',
+ )
+ raise
+
+ if name is not None:
+ attempt.name = name
+
+ if status is not None:
+ attempt.status = status
+
+ status_list = list(VerificationAttemptStatus)
+ if status not in status_list:
+ log.error(
+ 'Attempted to call update_verification_attempt called with invalid status: %(status)s. '
+ 'Status must be one of: %(status_list)s',
+ {
+ 'status': status,
+ 'status_list': VerificationAttempt.STATUS_CHOICES,
+ },
+ )
+ raise VerificationAttemptInvalidStatus
+
+ # NOTE: Generally, we only set the expiration date from the time that an IDV attempt is marked approved,
+ # so we allow expiration_datetime to = None for other status updates (e.g. pending).
+ attempt.expiration_datetime = expiration_datetime
+
+ attempt.save()
diff --git a/lms/djangoapps/verify_student/exceptions.py b/lms/djangoapps/verify_student/exceptions.py
index 59e7d5623f..d13e52d3e7 100644
--- a/lms/djangoapps/verify_student/exceptions.py
+++ b/lms/djangoapps/verify_student/exceptions.py
@@ -5,3 +5,7 @@ Exceptions for the verify student app
class WindowExpiredException(Exception):
pass
+
+
+class VerificationAttemptInvalidStatus(Exception):
+ pass
diff --git a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py
index b87b2eee45..3a08ede0aa 100644
--- a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py
+++ b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py
@@ -8,7 +8,6 @@ import os
import time
from pprint import pformat
-from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.management.base import BaseCommand, CommandError
from lms.djangoapps.verify_student.api import send_approval_email
diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py
index 903d80bf92..57ef79e8b0 100644
--- a/lms/djangoapps/verify_student/models.py
+++ b/lms/djangoapps/verify_student/models.py
@@ -1203,10 +1203,10 @@ class VerificationAttempt(TimeStampedModel):
name = models.CharField(blank=True, max_length=255)
STATUS_CHOICES = [
- VerificationAttemptStatus.created,
- VerificationAttemptStatus.pending,
- VerificationAttemptStatus.approved,
- VerificationAttemptStatus.denied,
+ VerificationAttemptStatus.CREATED,
+ VerificationAttemptStatus.PENDING,
+ VerificationAttemptStatus.APPROVED,
+ VerificationAttemptStatus.DENIED,
]
status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES])
@@ -1214,3 +1214,13 @@ class VerificationAttempt(TimeStampedModel):
null=True,
blank=True,
)
+
+ @classmethod
+ def retire_user(cls, user_id):
+ """
+ Retire user as part of GDPR pipeline
+
+ :param user_id: int
+ """
+ verification_attempts = cls.objects.filter(user_id=user_id)
+ verification_attempts.delete()
diff --git a/lms/djangoapps/verify_student/signals.py b/lms/djangoapps/verify_student/signals.py
index d929af68dd..ae54deb742 100644
--- a/lms/djangoapps/verify_student/signals.py
+++ b/lms/djangoapps/verify_student/signals.py
@@ -10,9 +10,9 @@ from django.dispatch.dispatcher import receiver
from xmodule.modulestore.django import SignalHandler, modulestore
from common.djangoapps.student.models_api import get_name, get_pending_name_change
-from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
+from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC
-from .models import SoftwareSecurePhotoVerification, VerificationDeadline
+from .models import SoftwareSecurePhotoVerification, VerificationDeadline, VerificationAttempt
log = logging.getLogger(__name__)
@@ -75,3 +75,9 @@ def send_idv_update(sender, instance, **kwargs): # pylint: disable=unused-argum
photo_id_name=instance.name,
full_name=full_name
)
+
+
+@receiver(USER_RETIRE_LMS_MISC)
+def _listen_for_lms_retire_verification_attempts(sender, **kwargs): # pylint: disable=unused-argument
+ user = kwargs.get('user')
+ VerificationAttempt.retire_user(user.id)
diff --git a/lms/djangoapps/verify_student/statuses.py b/lms/djangoapps/verify_student/statuses.py
index b55a9042e0..41ef381cfe 100644
--- a/lms/djangoapps/verify_student/statuses.py
+++ b/lms/djangoapps/verify_student/statuses.py
@@ -1,21 +1,22 @@
"""
Status enums for verify_student.
"""
+from enum import StrEnum, auto
-class VerificationAttemptStatus:
+class VerificationAttemptStatus(StrEnum):
"""This class describes valid statuses for a verification attempt to be in."""
# This is the initial state of a verification attempt, before a learner has started IDV.
- created = "created"
+ CREATED = auto()
# A verification attempt is pending when it has been started but has not yet been completed.
- pending = "pending"
+ PENDING = auto()
# A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual
# review, etc).
- approved = "approved"
+ APPROVED = auto()
# A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review,
# etc).
- denied = "denied"
+ DENIED = auto()
diff --git a/lms/djangoapps/verify_student/tests/factories.py b/lms/djangoapps/verify_student/tests/factories.py
index da35e98cc5..d7eaeaf302 100644
--- a/lms/djangoapps/verify_student/tests/factories.py
+++ b/lms/djangoapps/verify_student/tests/factories.py
@@ -3,7 +3,7 @@ Factories related to student verification.
"""
from factory.django import DjangoModelFactory
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt
class SoftwareSecurePhotoVerificationFactory(DjangoModelFactory):
@@ -19,3 +19,8 @@ class SoftwareSecurePhotoVerificationFactory(DjangoModelFactory):
class SSOVerificationFactory(DjangoModelFactory):
class Meta():
model = SSOVerification
+
+
+class VerificationAttemptFactory(DjangoModelFactory):
+ class Meta:
+ model = VerificationAttempt
diff --git a/lms/djangoapps/verify_student/tests/test_api.py b/lms/djangoapps/verify_student/tests/test_api.py
index acdebaa70c..747c76f82b 100644
--- a/lms/djangoapps/verify_student/tests/test_api.py
+++ b/lms/djangoapps/verify_student/tests/test_api.py
@@ -3,14 +3,21 @@ Tests of API module.
"""
from unittest.mock import patch
+from datetime import datetime, timezone
import ddt
from django.conf import settings
from django.core import mail
from django.test import TestCase
from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.verify_student.api import send_approval_email
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
+from lms.djangoapps.verify_student.api import (
+ create_verification_attempt,
+ send_approval_email,
+ update_verification_attempt,
+)
+from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus
+from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationAttempt
+from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus
@ddt.ddt
@@ -18,6 +25,7 @@ class TestSendApprovalEmail(TestCase):
"""
Test cases for the send_approval_email API method.
"""
+
def setUp(self):
super().setUp()
@@ -41,3 +49,138 @@ class TestSendApprovalEmail(TestCase):
with patch.dict(settings.VERIFY_STUDENT, {'USE_DJANGO_MAIL': use_ace}):
send_approval_email(self.attempt)
self._assert_verification_approved_email(self.attempt.expiration_datetime)
+
+
+@ddt.ddt
+class CreateVerificationAttempt(TestCase):
+ """
+ Test cases for the create_verification_attempt API method.
+ """
+
+ def setUp(self):
+ super().setUp()
+
+ self.user = UserFactory.create()
+ self.attempt = VerificationAttempt(
+ user=self.user,
+ name='Tester McTest',
+ status=VerificationAttemptStatus.CREATED,
+ expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc)
+ )
+ self.attempt.save()
+
+ def test_create_verification_attempt(self):
+ expected_id = 2
+ self.assertEqual(
+ create_verification_attempt(
+ user=self.user,
+ name='Tester McTest',
+ status=VerificationAttemptStatus.CREATED,
+ expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc)
+ ),
+ expected_id
+ )
+ verification_attempt = VerificationAttempt.objects.get(id=expected_id)
+
+ self.assertEqual(verification_attempt.user, self.user)
+ self.assertEqual(verification_attempt.name, 'Tester McTest')
+ self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED)
+ self.assertEqual(verification_attempt.expiration_datetime, datetime(2024, 12, 31, tzinfo=timezone.utc))
+
+ def test_create_verification_attempt_no_expiration_datetime(self):
+ expected_id = 2
+ self.assertEqual(
+ create_verification_attempt(
+ user=self.user,
+ name='Tester McTest',
+ status=VerificationAttemptStatus.CREATED,
+ ),
+ expected_id
+ )
+ verification_attempt = VerificationAttempt.objects.get(id=expected_id)
+
+ self.assertEqual(verification_attempt.user, self.user)
+ self.assertEqual(verification_attempt.name, 'Tester McTest')
+ self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED)
+ self.assertEqual(verification_attempt.expiration_datetime, None)
+
+
+@ddt.ddt
+class UpdateVerificationAttempt(TestCase):
+ """
+ Test cases for the update_verification_attempt API method.
+ """
+
+ def setUp(self):
+ super().setUp()
+
+ self.user = UserFactory.create()
+ self.attempt = VerificationAttempt(
+ user=self.user,
+ name='Tester McTest',
+ status=VerificationAttemptStatus.CREATED,
+ expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc)
+ )
+ self.attempt.save()
+
+ @ddt.data(
+ ('Tester McTest', VerificationAttemptStatus.PENDING, datetime(2024, 12, 31, tzinfo=timezone.utc)),
+ ('Tester McTest2', VerificationAttemptStatus.APPROVED, datetime(2025, 12, 31, tzinfo=timezone.utc)),
+ ('Tester McTest3', VerificationAttemptStatus.DENIED, datetime(2026, 12, 31, tzinfo=timezone.utc)),
+ )
+ @ddt.unpack
+ def test_update_verification_attempt(self, name, status, expiration_datetime):
+ update_verification_attempt(
+ attempt_id=self.attempt.id,
+ name=name,
+ status=status,
+ expiration_datetime=expiration_datetime,
+ )
+
+ verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id)
+
+ # Values should change as a result of this update.
+ self.assertEqual(verification_attempt.user, self.user)
+ self.assertEqual(verification_attempt.name, name)
+ self.assertEqual(verification_attempt.status, status)
+ self.assertEqual(verification_attempt.expiration_datetime, expiration_datetime)
+
+ def test_update_verification_attempt_none_values(self):
+ update_verification_attempt(
+ attempt_id=self.attempt.id,
+ name=None,
+ status=None,
+ expiration_datetime=None,
+ )
+
+ verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id)
+
+ # Values should not change as a result of the values passed in being None, except for expiration_datetime.
+ self.assertEqual(verification_attempt.user, self.user)
+ self.assertEqual(verification_attempt.name, self.attempt.name)
+ self.assertEqual(verification_attempt.status, self.attempt.status)
+ self.assertEqual(verification_attempt.expiration_datetime, None)
+
+ def test_update_verification_attempt_not_found(self):
+ self.assertRaises(
+ VerificationAttempt.DoesNotExist,
+ update_verification_attempt,
+ attempt_id=999999,
+ status=VerificationAttemptStatus.APPROVED,
+ )
+
+ @ddt.data(
+ 'completed',
+ 'failed',
+ 'submitted',
+ 'expired',
+ )
+ def test_update_verification_attempt_invalid(self, status):
+ self.assertRaises(
+ VerificationAttemptInvalidStatus,
+ update_verification_attempt,
+ attempt_id=self.attempt.id,
+ name=None,
+ status=status,
+ expiration_datetime=None,
+ )
diff --git a/lms/djangoapps/verify_student/tests/test_signals.py b/lms/djangoapps/verify_student/tests/test_signals.py
index fb32edeccd..8d607988d4 100644
--- a/lms/djangoapps/verify_student/tests/test_signals.py
+++ b/lms/djangoapps/verify_student/tests/test_signals.py
@@ -10,9 +10,20 @@ from unittest.mock import patch # lint-amnesty, pylint: disable=wrong-import-or
from common.djangoapps.student.models_api import do_name_change_request
from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
-from lms.djangoapps.verify_student.signals import _listen_for_course_publish, _listen_for_lms_retire
-from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
+from lms.djangoapps.verify_student.models import (
+ SoftwareSecurePhotoVerification,
+ VerificationDeadline,
+ VerificationAttempt
+)
+from lms.djangoapps.verify_student.signals import (
+ _listen_for_course_publish,
+ _listen_for_lms_retire,
+ _listen_for_lms_retire_verification_attempts
+)
+from lms.djangoapps.verify_student.tests.factories import (
+ SoftwareSecurePhotoVerificationFactory,
+ VerificationAttemptFactory
+)
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
@@ -174,3 +185,26 @@ class PostSavePhotoVerificationTest(ModuleStoreTestCase):
photo_id_name=attempt.name,
full_name=pending_name_change.new_name
)
+
+
+class RetirementSignalVerificationAttemptsTest(ModuleStoreTestCase):
+ """
+ Tests for the LMS User Retirement signal for Verification Attempts
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.user = UserFactory.create()
+ self.other_user = UserFactory.create()
+ VerificationAttemptFactory.create(user=self.user)
+ VerificationAttemptFactory.create(user=self.other_user)
+
+ def test_retirement_signal(self):
+ _listen_for_lms_retire_verification_attempts(sender=self.__class__, user=self.user)
+ self.assertEqual(len(VerificationAttempt.objects.filter(user=self.user)), 0)
+ self.assertEqual(len(VerificationAttempt.objects.filter(user=self.other_user)), 1)
+
+ def test_retirement_signal_no_attempts(self):
+ no_attempt_user = UserFactory.create()
+ _listen_for_lms_retire_verification_attempts(sender=self.__class__, user=no_attempt_user)
+ self.assertEqual(len(VerificationAttempt.objects.all()), 2)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index a6c8e810d2..3346692153 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -2295,7 +2295,6 @@ MIDDLEWARE = [
'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
@@ -5466,6 +5465,10 @@ EVENT_BUS_PRODUCER_CONFIG = {
'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 890164b0bd..7a06f71799 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -522,6 +522,10 @@ course_access_role_removed_event_setting = EVENT_BUS_PRODUCER_CONFIG[
]
course_access_role_removed_event_setting['learning-course-access-role-lifecycle']['enabled'] = True
+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'
API_ACCESS_FROM_EMAIL = 'api-requests@example.com'
diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss
index 629b306577..a8a34ccec5 100644
--- a/lms/static/sass/multicourse/_course_about.scss
+++ b/lms/static/sass/multicourse/_course_about.scss
@@ -44,6 +44,11 @@
> div.table {
display: table;
width: 100%;
+
+ @include media-breakpoint-down(sm) {
+ display: flex;
+ flex-direction: column;
+ }
}
.intro {
@@ -51,6 +56,11 @@
@include clearfix();
+ @include media-breakpoint-down(sm) {
+ width: auto;
+ order: 2;
+ }
+
display: table-cell;
vertical-align: middle;
padding: $baseline;
@@ -127,6 +137,10 @@
a.add-to-cart {
@include button(shiny, $button-color);
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+
box-sizing: border-box;
border-radius: 3px;
display: block;
@@ -189,6 +203,11 @@
@include float(left);
@include margin(1px, flex-gutter(8), 0, 0);
@include transition(none);
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ margin-right: 0;
+ margin-bottom: 10px;
+ }
width: flex-grid(5, 8);
}
@@ -213,6 +232,11 @@
width: flex-grid(4);
z-index: 2;
+ @include media-breakpoint-down(sm) {
+ width: auto;
+ order: 1;
+ }
+
.hero {
border: 1px solid $border-color-3;
height: 100%;
diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html
index 91d7a2a28e..eec9caeadb 100644
--- a/lms/templates/courseware/course_about.html
+++ b/lms/templates/courseware/course_about.html
@@ -62,11 +62,10 @@ from openedx.core.lib.courses import course_image_url
-
- ${course.display_name_with_default}
-
+
${course.display_org_with_default}
+
${course.display_name_with_default}
-
${course.display_org_with_default}
+
${get_course_about_section(request, course, 'short_description')}
@@ -160,7 +159,11 @@ from openedx.core.lib.courses import course_image_url
<%block name="course_about_important_dates">
- ${_("Course Number")}
${course.display_number_with_default}
+ -
+
+
${_("Course Number")}
+ ${course.display_number_with_default}
+
% if not course.start_date_is_still_default:
<%
course_start_date = course.advertised_start or course.start
@@ -231,7 +234,11 @@ from openedx.core.lib.courses import course_image_url
% endif
% if get_course_about_section(request, course, "prerequisites"):
- ${_("Requirements")}
${get_course_about_section(request, course, "prerequisites")}
+ -
+
+
${_("Requirements")}
+ ${get_course_about_section(request, course, "prerequisites")}
+
% endif
%block>
diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html
index e8411c9e42..deeda26c43 100644
--- a/lms/templates/courseware/courseware-chromeless.html
+++ b/lms/templates/courseware/courseware-chromeless.html
@@ -144,10 +144,12 @@ ${HTML(fragment.foot_html())}
// to stay in relatively the same position so viewing of the video
// is not disrupted.
if ($(this).attr('class') === 'transcript-start'||$(this).attr('class') === 'transcript-end') {
+ var target = $(targetId)[0];
event.preventDefault();
- $(targetId)[0].scrollIntoView({
+ target.scrollIntoView({
block: 'nearest',
});
+ target.focus();
} else {
var targetName = $(this).attr('href').slice(1);
// Checks if the target uses an id or name.
@@ -159,6 +161,7 @@ ${HTML(fragment.foot_html())}
target.scrollIntoView({
block: 'start',
});
+ target.focus();
}
}
}
diff --git a/openedx/core/djangoapps/contentserver/middleware.py b/openedx/core/djangoapps/contentserver/middleware.py
deleted file mode 100644
index 6eb72da458..0000000000
--- a/openedx/core/djangoapps/contentserver/middleware.py
+++ /dev/null
@@ -1,368 +0,0 @@
-"""
-Middleware to serve assets.
-"""
-
-
-import datetime
-import logging
-
-from django.http import (
- HttpResponse,
- HttpResponseBadRequest,
- HttpResponseForbidden,
- HttpResponseNotFound,
- HttpResponseNotModified,
- HttpResponsePermanentRedirect
-)
-from django.utils.deprecation import MiddlewareMixin
-from edx_django_utils.monitoring import set_custom_attribute
-from edx_toggles.toggles import WaffleFlag
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.locator import AssetLocator
-
-from openedx.core.djangoapps.header_control import force_header_for_response
-from common.djangoapps.student.models import CourseEnrollment
-from xmodule.assetstore.assetmgr import AssetManager # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.contentstore.content import XASSET_LOCATION_TAG, StaticContent # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore import InvalidLocationError # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
-
-from .caching import get_cached_content, set_cached_content
-from .models import CdnUserAgentsConfig, CourseAssetCacheTtlConfig
-
-log = logging.getLogger(__name__)
-
-# .. toggle_name: content_server.use_view
-# .. toggle_implementation: WaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Deployment flag for switching asset serving from a middleware
-# to a view. Intended to be used once in each environment to test the cutover and
-# ensure there are no errors or changes in behavior. Once this has been tested,
-# the middleware can be fully converted to a view.
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2024-05-02
-# .. toggle_target_removal_date: 2024-07-01
-# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/34702
-CONTENT_SERVER_USE_VIEW = WaffleFlag('content_server.use_view', module_name=__name__)
-
-# TODO: Soon as we have a reasonable way to serialize/deserialize AssetKeys, we need
-# to change this file so instead of using course_id_partial, we're just using asset keys
-
-HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
-
-
-class StaticContentServerMiddleware(MiddlewareMixin):
- """
- Shim to maintain old pattern of serving course assets from a middleware. See views.py.
- """
- def process_request(self, request):
- """Intercept asset request or allow view to handle it, depending on config."""
- if CONTENT_SERVER_USE_VIEW.is_enabled():
- return
- else:
- set_custom_attribute('content_server.handled_by.middleware', True)
- return IMPL.process_request(request)
-
-
-class StaticContentServer():
- """
- Serves course assets to end users. Colloquially referred to as "contentserver."
- """
- def is_asset_request(self, request):
- """Determines whether the given request is an asset request"""
- # Don't change this without updating urls.py! See docstring of views.py.
- return (
- request.path.startswith('/' + XASSET_LOCATION_TAG + '/')
- or
- request.path.startswith('/' + AssetLocator.CANONICAL_NAMESPACE)
- or
- StaticContent.is_versioned_asset_path(request.path)
- )
-
- # pylint: disable=too-many-statements
- def process_request(self, request):
- """Process the given request"""
- asset_path = request.path
-
- if self.is_asset_request(request): # lint-amnesty, pylint: disable=too-many-nested-blocks
- # Make sure we can convert this request into a location.
- if AssetLocator.CANONICAL_NAMESPACE in asset_path:
- asset_path = asset_path.replace('block/', 'block@', 1)
-
- # If this is a versioned request, pull out the digest and chop off the prefix.
- requested_digest = None
- if StaticContent.is_versioned_asset_path(asset_path):
- requested_digest, asset_path = StaticContent.parse_versioned_asset_path(asset_path)
-
- # Make sure we have a valid location value for this asset.
- try:
- loc = StaticContent.get_location_from_path(asset_path)
- except (InvalidLocationError, InvalidKeyError):
- return HttpResponseBadRequest()
-
- # Attempt to load the asset to make sure it exists, and grab the asset digest
- # if we're able to load it.
- actual_digest = None
- try:
- content = self.load_asset_from_location(loc)
- actual_digest = getattr(content, "content_digest", None)
- except (ItemNotFoundError, NotFoundError):
- return HttpResponseNotFound()
-
- # If this was a versioned asset, and the digest doesn't match, redirect
- # them to the actual version.
- if requested_digest is not None and actual_digest is not None and (actual_digest != requested_digest):
- actual_asset_path = StaticContent.add_version_to_asset_path(asset_path, actual_digest)
- return HttpResponsePermanentRedirect(actual_asset_path)
-
- # Set the basics for this request. Make sure that the course key for this
- # asset has a run, which old-style courses do not. Otherwise, this will
- # explode when the key is serialized to be sent to NR.
- safe_course_key = loc.course_key
- if safe_course_key.run is None:
- safe_course_key = safe_course_key.replace(run='only')
-
- set_custom_attribute('course_id', safe_course_key)
- set_custom_attribute('org', loc.org)
- set_custom_attribute('contentserver.path', loc.path)
-
- # Figure out if this is a CDN using us as the origin.
- is_from_cdn = StaticContentServer.is_cdn_request(request)
- set_custom_attribute('contentserver.from_cdn', is_from_cdn)
-
- # Check if this content is locked or not.
- locked = self.is_content_locked(content)
- set_custom_attribute('contentserver.locked', locked)
-
- # Check that user has access to the content.
- if not self.is_user_authorized(request, content, loc):
- return HttpResponseForbidden('Unauthorized')
-
- # Figure out if the client sent us a conditional request, and let them know
- # if this asset has changed since then.
- last_modified_at_str = content.last_modified_at.strftime(HTTP_DATE_FORMAT)
- if 'HTTP_IF_MODIFIED_SINCE' in request.META:
- if_modified_since = request.META['HTTP_IF_MODIFIED_SINCE']
- if if_modified_since == last_modified_at_str:
- return HttpResponseNotModified()
-
- # *** File streaming within a byte range ***
- # If a Range is provided, parse Range attribute of the request
- # Add Content-Range in the response if Range is structurally correct
- # Request -> Range attribute structure: "Range: bytes=first-[last]"
- # Response -> Content-Range attribute structure: "Content-Range: bytes first-last/totalLength"
- # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
- response = None
- if request.META.get('HTTP_RANGE'):
- # If we have a StaticContent, get a StaticContentStream. Can't manipulate the bytes otherwise.
- if isinstance(content, StaticContent):
- content = AssetManager.find(loc, as_stream=True)
-
- header_value = request.META['HTTP_RANGE']
- try:
- unit, ranges = parse_range_header(header_value, content.length)
- except ValueError as exception:
- # If the header field is syntactically invalid it should be ignored.
- log.exception(
- "%s in Range header: %s for content: %s",
- str(exception), header_value, str(loc)
- )
- else:
- if unit != 'bytes':
- # Only accept ranges in bytes
- log.warning("Unknown unit in Range header: %s for content: %s", header_value, str(loc))
- elif len(ranges) > 1:
- # According to Http/1.1 spec content for multiple ranges should be sent as a multipart message.
- # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16
- # But we send back the full content.
- log.warning(
- "More than 1 ranges in Range header: %s for content: %s", header_value, str(loc)
- )
- else:
- first, last = ranges[0]
-
- if 0 <= first <= last < content.length:
- # If the byte range is satisfiable
- response = HttpResponse(content.stream_data_in_range(first, last))
- response['Content-Range'] = 'bytes {first}-{last}/{length}'.format(
- first=first, last=last, length=content.length
- )
- response['Content-Length'] = str(last - first + 1)
- response.status_code = 206 # Partial Content
-
- set_custom_attribute('contentserver.ranged', True)
- else:
- log.warning(
- "Cannot satisfy ranges in Range header: %s for content: %s",
- header_value, str(loc)
- )
- return HttpResponse(status=416) # Requested Range Not Satisfiable
-
- # If Range header is absent or syntactically invalid return a full content response.
- if response is None:
- response = HttpResponse(content.stream_data())
- response['Content-Length'] = content.length
-
- set_custom_attribute('contentserver.content_len', content.length)
- set_custom_attribute('contentserver.content_type', content.content_type)
-
- # "Accept-Ranges: bytes" tells the user that only "bytes" ranges are allowed
- response['Accept-Ranges'] = 'bytes'
- response['Content-Type'] = content.content_type
- response['X-Frame-Options'] = 'ALLOW'
-
- # Set any caching headers, and do any response cleanup needed. Based on how much
- # middleware we have in place, there's no easy way to use the built-in Django
- # utilities and properly sanitize and modify a response to ensure that it is as
- # cacheable as possible, which is why we do it ourselves.
- self.set_caching_headers(content, response)
-
- return response
-
- def set_caching_headers(self, content, response):
- """
- Sets caching headers based on whether or not the asset is locked.
- """
-
- is_locked = getattr(content, "locked", False)
-
- # We want to signal to the end user's browser, and to any intermediate proxies/caches,
- # whether or not this asset is cacheable. If we have a TTL configured, we inform the
- # caller, for unlocked assets, how long they are allowed to cache it. Since locked
- # assets should be restricted to enrolled students, we simply send headers that
- # indicate there should be no caching whatsoever.
- cache_ttl = CourseAssetCacheTtlConfig.get_cache_ttl()
- if cache_ttl > 0 and not is_locked:
- set_custom_attribute('contentserver.cacheable', True)
-
- response['Expires'] = StaticContentServer.get_expiration_value(datetime.datetime.utcnow(), cache_ttl)
- response['Cache-Control'] = "public, max-age={ttl}, s-maxage={ttl}".format(ttl=cache_ttl)
- elif is_locked:
- set_custom_attribute('contentserver.cacheable', False)
-
- response['Cache-Control'] = "private, no-cache, no-store"
-
- response['Last-Modified'] = content.last_modified_at.strftime(HTTP_DATE_FORMAT)
-
- # Force the Vary header to only vary responses on Origin, so that XHR and browser requests get cached
- # separately and don't screw over one another. i.e. a browser request that doesn't send Origin, and
- # caches a version of the response without CORS headers, in turn breaking XHR requests.
- force_header_for_response(response, 'Vary', 'Origin')
-
- @staticmethod
- def is_cdn_request(request):
- """
- Attempts to determine whether or not the given request is coming from a CDN.
-
- Currently, this is a static check because edx.org only uses CloudFront, but may
- be expanded in the future.
- """
- cdn_user_agents = CdnUserAgentsConfig.get_cdn_user_agents()
- user_agent = request.META.get('HTTP_USER_AGENT', '')
- if user_agent in cdn_user_agents:
- # This is a CDN request.
- return True
-
- return False
-
- @staticmethod
- def get_expiration_value(now, cache_ttl):
- """Generates an RFC1123 datetime string based on a future offset."""
- expire_dt = now + datetime.timedelta(seconds=cache_ttl)
- return expire_dt.strftime(HTTP_DATE_FORMAT)
-
- def is_content_locked(self, content):
- """
- Determines whether or not the given content is locked.
- """
- return bool(getattr(content, "locked", False))
-
- def is_user_authorized(self, request, content, location):
- """
- Determines whether or not the user for this request is authorized to view the given asset.
- """
- if not self.is_content_locked(content):
- return True
-
- if not hasattr(request, "user") or not request.user.is_authenticated:
- return False
-
- if not request.user.is_staff:
- deprecated = getattr(location, 'deprecated', False)
- if deprecated and not CourseEnrollment.is_enrolled_by_partial(request.user, location.course_key):
- return False
- if not deprecated and not CourseEnrollment.is_enrolled(request.user, location.course_key):
- return False
-
- return True
-
- def load_asset_from_location(self, location):
- """
- Loads an asset based on its location, either retrieving it from a cache
- or loading it directly from the contentstore.
- """
-
- # See if we can load this item from cache.
- content = get_cached_content(location)
- if content is None:
- # Not in cache, so just try and load it from the asset manager.
- try:
- content = AssetManager.find(location, as_stream=True)
- except (ItemNotFoundError, NotFoundError): # lint-amnesty, pylint: disable=try-except-raise
- raise
-
- # Now that we fetched it, let's go ahead and try to cache it. We cap this at 1MB
- # because it's the default for memcached and also we don't want to do too much
- # buffering in memory when we're serving an actual request.
- if content.length is not None and content.length < 1048576:
- content = content.copy_to_in_mem()
- set_cached_content(content)
-
- return content
-
-
-IMPL = StaticContentServer()
-
-
-def parse_range_header(header_value, content_length):
- """
- Returns the unit and a list of (start, end) tuples of ranges.
-
- Raises ValueError if header is syntactically invalid or does not contain a range.
-
- See spec for details: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
- """
-
- unit = None
- ranges = []
-
- if '=' in header_value:
- unit, byte_ranges_string = header_value.split('=')
-
- # Parse the byte ranges.
- for byte_range_string in byte_ranges_string.split(','):
- byte_range_string = byte_range_string.strip()
- # Case 0:
- if '-' not in byte_range_string: # Invalid syntax of header value. # lint-amnesty, pylint: disable=no-else-raise
- raise ValueError('Invalid syntax.')
- # Case 1: -500
- elif byte_range_string.startswith('-'):
- first = max(0, (content_length + int(byte_range_string)))
- last = content_length - 1
- # Case 2: 500-
- elif byte_range_string.endswith('-'):
- first = int(byte_range_string[0:-1])
- last = content_length - 1
- # Case 3: 500-999
- else:
- first, last = byte_range_string.split('-')
- first = int(first)
- last = min(int(last), content_length - 1)
-
- ranges.append((first, last))
-
- if len(ranges) == 0:
- raise ValueError('Invalid syntax')
-
- return unit, ranges
diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py
index e9ee1f7ee5..e1c5e01c0a 100644
--- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py
+++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py
@@ -4,7 +4,6 @@ Tests for StaticContentServer
import copy
-
import datetime
import logging
import unittest
@@ -17,18 +16,18 @@ from django.test import RequestFactory
from django.test.client import Client
from django.test.utils import override_settings
from opaque_keys import InvalidKeyError
-from xmodule.contentstore.django import contentstore
-from xmodule.contentstore.content import StaticContent, VERSIONED_ASSETS_PREFIX
-from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
-from xmodule.modulestore.xml_importer import import_course_from_xml
-from xmodule.assetstore.assetmgr import AssetManager
-from xmodule.modulestore.exceptions import ItemNotFoundError
from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.tests.factories import UserFactory, AdminFactory
+from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
+from xmodule.assetstore.assetmgr import AssetManager
+from xmodule.contentstore.content import VERSIONED_ASSETS_PREFIX, StaticContent
+from xmodule.contentstore.django import contentstore
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
+from xmodule.modulestore.xml_importer import import_course_from_xml
-from ..middleware import parse_range_header, HTTP_DATE_FORMAT, StaticContentServer
+from ..views import HTTP_DATE_FORMAT, StaticContentServer, parse_range_header
log = logging.getLogger(__name__)
diff --git a/openedx/core/djangoapps/contentserver/views.py b/openedx/core/djangoapps/contentserver/views.py
index 84bd4c3b10..7c40026415 100644
--- a/openedx/core/djangoapps/contentserver/views.py
+++ b/openedx/core/djangoapps/contentserver/views.py
@@ -9,28 +9,41 @@ Django what view handled the request, it does so by looking at the result of the
`resolve` utility, but these URLs get a Resolver404 (because there's no
registered urlpattern).
-We'd like to turn this into a proper view:
-https://github.com/openedx/edx-platform/issues/34702
+We've turned it into a proper view, with a few warts remaining:
-The first step, seen here, is to have urlpatterns (redundant with the
-middleware's `is_asset_request` method) and a view, but the view just calls into
-the same code the middleware uses. The implementation of the middleware has been
-moved into StaticContentServerImpl, leaving the middleware as just a shell
-around the latter.
-
-A waffle flag chooses whether to allow the middleware to handle the request, or
-whether to pass the request along to the view. Why? Because we might be relying
-by accident on some weird behavior inherent to misusing a middleware this way,
-and we need a way to quickly switch back if we encounter problems.
-
-If the view works, we can move all of StaticContentServerImpl directly into the
-view and drop the middleware and the waffle flag.
+- The view implementation is all bundled into a StaticContentServer class that
+ doesn't appear to have any state. The methods could likely just be extracted
+ as top-level functions.
+- All three urlpatterns are registered to the same view, which then has to
+ re-parse the URL to determine which pattern is in effect. We should probably
+ have 3 views as entry points.
"""
-from django.http import HttpResponseNotFound
+import datetime
+import logging
+
+from django.http import (
+ HttpResponse,
+ HttpResponseBadRequest,
+ HttpResponseForbidden,
+ HttpResponseNotFound,
+ HttpResponseNotModified,
+ HttpResponsePermanentRedirect
+)
from django.views.decorators.http import require_safe
from edx_django_utils.monitoring import set_custom_attribute
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.locator import AssetLocator
-from .middleware import CONTENT_SERVER_USE_VIEW, IMPL
+from common.djangoapps.student.models import CourseEnrollment
+from openedx.core.djangoapps.header_control import force_header_for_response
+from xmodule.assetstore.assetmgr import AssetManager
+from xmodule.contentstore.content import XASSET_LOCATION_TAG, StaticContent
+from xmodule.exceptions import NotFoundError
+from xmodule.modulestore import InvalidLocationError
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+from .caching import get_cached_content, set_cached_content
+from .models import CdnUserAgentsConfig, CourseAssetCacheTtlConfig
@require_safe
@@ -38,21 +51,315 @@ def course_assets_view(request):
"""
Serve course assets to end users. Colloquially referred to as "contentserver."
"""
- set_custom_attribute('content_server.handled_by.view', True)
+ return IMPL.process_request(request)
- if not CONTENT_SERVER_USE_VIEW.is_enabled():
- # Should never happen; keep track of occurrences.
- set_custom_attribute('content_server.view.called_when_disabled', True)
- # But handle the request anyhow.
- # We'll delegate request handling to an instance of the middleware
- # until we can verify that the behavior is identical when requests
- # come all the way through to the view.
- response = IMPL.process_request(request)
+log = logging.getLogger(__name__)
- if response is None:
- # Shouldn't happen
- set_custom_attribute('content_server.view.no_response_from_impl', True)
- return HttpResponseNotFound()
- else:
- return response
+# TODO: Soon as we have a reasonable way to serialize/deserialize AssetKeys, we need
+# to change this file so instead of using course_id_partial, we're just using asset keys
+
+HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+
+
+class StaticContentServer():
+ """
+ Serves course assets to end users. Colloquially referred to as "contentserver."
+ """
+ def is_asset_request(self, request):
+ """Determines whether the given request is an asset request"""
+ # Don't change this without updating urls.py! See docstring of views.py.
+ return (
+ request.path.startswith('/' + XASSET_LOCATION_TAG + '/')
+ or
+ request.path.startswith('/' + AssetLocator.CANONICAL_NAMESPACE)
+ or
+ StaticContent.is_versioned_asset_path(request.path)
+ )
+
+ # pylint: disable=too-many-statements
+ def process_request(self, request):
+ """Process the given request"""
+ asset_path = request.path
+
+ if self.is_asset_request(request): # lint-amnesty, pylint: disable=too-many-nested-blocks
+ # Make sure we can convert this request into a location.
+ if AssetLocator.CANONICAL_NAMESPACE in asset_path:
+ asset_path = asset_path.replace('block/', 'block@', 1)
+
+ # If this is a versioned request, pull out the digest and chop off the prefix.
+ requested_digest = None
+ if StaticContent.is_versioned_asset_path(asset_path):
+ requested_digest, asset_path = StaticContent.parse_versioned_asset_path(asset_path)
+
+ # Make sure we have a valid location value for this asset.
+ try:
+ loc = StaticContent.get_location_from_path(asset_path)
+ except (InvalidLocationError, InvalidKeyError):
+ return HttpResponseBadRequest()
+
+ # Attempt to load the asset to make sure it exists, and grab the asset digest
+ # if we're able to load it.
+ actual_digest = None
+ try:
+ content = self.load_asset_from_location(loc)
+ actual_digest = getattr(content, "content_digest", None)
+ except (ItemNotFoundError, NotFoundError):
+ return HttpResponseNotFound()
+
+ # If this was a versioned asset, and the digest doesn't match, redirect
+ # them to the actual version.
+ if requested_digest is not None and actual_digest is not None and (actual_digest != requested_digest):
+ actual_asset_path = StaticContent.add_version_to_asset_path(asset_path, actual_digest)
+ return HttpResponsePermanentRedirect(actual_asset_path)
+
+ # Set the basics for this request. Make sure that the course key for this
+ # asset has a run, which old-style courses do not. Otherwise, this will
+ # explode when the key is serialized to be sent to NR.
+ safe_course_key = loc.course_key
+ if safe_course_key.run is None:
+ safe_course_key = safe_course_key.replace(run='only')
+
+ set_custom_attribute('course_id', safe_course_key)
+ set_custom_attribute('org', loc.org)
+ set_custom_attribute('contentserver.path', loc.path)
+
+ # Figure out if this is a CDN using us as the origin.
+ is_from_cdn = StaticContentServer.is_cdn_request(request)
+ set_custom_attribute('contentserver.from_cdn', is_from_cdn)
+
+ # Check if this content is locked or not.
+ locked = self.is_content_locked(content)
+ set_custom_attribute('contentserver.locked', locked)
+
+ # Check that user has access to the content.
+ if not self.is_user_authorized(request, content, loc):
+ return HttpResponseForbidden('Unauthorized')
+
+ # Figure out if the client sent us a conditional request, and let them know
+ # if this asset has changed since then.
+ last_modified_at_str = content.last_modified_at.strftime(HTTP_DATE_FORMAT)
+ if 'HTTP_IF_MODIFIED_SINCE' in request.META:
+ if_modified_since = request.META['HTTP_IF_MODIFIED_SINCE']
+ if if_modified_since == last_modified_at_str:
+ return HttpResponseNotModified()
+
+ # *** File streaming within a byte range ***
+ # If a Range is provided, parse Range attribute of the request
+ # Add Content-Range in the response if Range is structurally correct
+ # Request -> Range attribute structure: "Range: bytes=first-[last]"
+ # Response -> Content-Range attribute structure: "Content-Range: bytes first-last/totalLength"
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
+ response = None
+ if request.META.get('HTTP_RANGE'):
+ # If we have a StaticContent, get a StaticContentStream. Can't manipulate the bytes otherwise.
+ if isinstance(content, StaticContent):
+ content = AssetManager.find(loc, as_stream=True)
+
+ header_value = request.META['HTTP_RANGE']
+ try:
+ unit, ranges = parse_range_header(header_value, content.length)
+ except ValueError as exception:
+ # If the header field is syntactically invalid it should be ignored.
+ log.exception(
+ "%s in Range header: %s for content: %s",
+ str(exception), header_value, str(loc)
+ )
+ else:
+ if unit != 'bytes':
+ # Only accept ranges in bytes
+ log.warning("Unknown unit in Range header: %s for content: %s", header_value, str(loc))
+ elif len(ranges) > 1:
+ # According to Http/1.1 spec content for multiple ranges should be sent as a multipart message.
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16
+ # But we send back the full content.
+ log.warning(
+ "More than 1 ranges in Range header: %s for content: %s", header_value, str(loc)
+ )
+ else:
+ first, last = ranges[0]
+
+ if 0 <= first <= last < content.length:
+ # If the byte range is satisfiable
+ response = HttpResponse(content.stream_data_in_range(first, last))
+ response['Content-Range'] = 'bytes {first}-{last}/{length}'.format(
+ first=first, last=last, length=content.length
+ )
+ response['Content-Length'] = str(last - first + 1)
+ response.status_code = 206 # Partial Content
+
+ set_custom_attribute('contentserver.ranged', True)
+ else:
+ log.warning(
+ "Cannot satisfy ranges in Range header: %s for content: %s",
+ header_value, str(loc)
+ )
+ return HttpResponse(status=416) # Requested Range Not Satisfiable
+
+ # If Range header is absent or syntactically invalid return a full content response.
+ if response is None:
+ response = HttpResponse(content.stream_data())
+ response['Content-Length'] = content.length
+
+ set_custom_attribute('contentserver.content_len', content.length)
+ set_custom_attribute('contentserver.content_type', content.content_type)
+
+ # "Accept-Ranges: bytes" tells the user that only "bytes" ranges are allowed
+ response['Accept-Ranges'] = 'bytes'
+ response['Content-Type'] = content.content_type
+ response['X-Frame-Options'] = 'ALLOW'
+
+ # Set any caching headers, and do any response cleanup needed. Based on how much
+ # middleware we have in place, there's no easy way to use the built-in Django
+ # utilities and properly sanitize and modify a response to ensure that it is as
+ # cacheable as possible, which is why we do it ourselves.
+ self.set_caching_headers(content, response)
+
+ return response
+
+ def set_caching_headers(self, content, response):
+ """
+ Sets caching headers based on whether or not the asset is locked.
+ """
+
+ is_locked = getattr(content, "locked", False)
+
+ # We want to signal to the end user's browser, and to any intermediate proxies/caches,
+ # whether or not this asset is cacheable. If we have a TTL configured, we inform the
+ # caller, for unlocked assets, how long they are allowed to cache it. Since locked
+ # assets should be restricted to enrolled students, we simply send headers that
+ # indicate there should be no caching whatsoever.
+ cache_ttl = CourseAssetCacheTtlConfig.get_cache_ttl()
+ if cache_ttl > 0 and not is_locked:
+ set_custom_attribute('contentserver.cacheable', True)
+
+ response['Expires'] = StaticContentServer.get_expiration_value(datetime.datetime.utcnow(), cache_ttl)
+ response['Cache-Control'] = "public, max-age={ttl}, s-maxage={ttl}".format(ttl=cache_ttl)
+ elif is_locked:
+ set_custom_attribute('contentserver.cacheable', False)
+
+ response['Cache-Control'] = "private, no-cache, no-store"
+
+ response['Last-Modified'] = content.last_modified_at.strftime(HTTP_DATE_FORMAT)
+
+ # Force the Vary header to only vary responses on Origin, so that XHR and browser requests get cached
+ # separately and don't screw over one another. i.e. a browser request that doesn't send Origin, and
+ # caches a version of the response without CORS headers, in turn breaking XHR requests.
+ force_header_for_response(response, 'Vary', 'Origin')
+
+ @staticmethod
+ def is_cdn_request(request):
+ """
+ Attempts to determine whether or not the given request is coming from a CDN.
+
+ Currently, this is a static check because edx.org only uses CloudFront, but may
+ be expanded in the future.
+ """
+ cdn_user_agents = CdnUserAgentsConfig.get_cdn_user_agents()
+ user_agent = request.META.get('HTTP_USER_AGENT', '')
+ if user_agent in cdn_user_agents:
+ # This is a CDN request.
+ return True
+
+ return False
+
+ @staticmethod
+ def get_expiration_value(now, cache_ttl):
+ """Generates an RFC1123 datetime string based on a future offset."""
+ expire_dt = now + datetime.timedelta(seconds=cache_ttl)
+ return expire_dt.strftime(HTTP_DATE_FORMAT)
+
+ def is_content_locked(self, content):
+ """
+ Determines whether or not the given content is locked.
+ """
+ return bool(getattr(content, "locked", False))
+
+ def is_user_authorized(self, request, content, location):
+ """
+ Determines whether or not the user for this request is authorized to view the given asset.
+ """
+ if not self.is_content_locked(content):
+ return True
+
+ if not hasattr(request, "user") or not request.user.is_authenticated:
+ return False
+
+ if not request.user.is_staff:
+ deprecated = getattr(location, 'deprecated', False)
+ if deprecated and not CourseEnrollment.is_enrolled_by_partial(request.user, location.course_key):
+ return False
+ if not deprecated and not CourseEnrollment.is_enrolled(request.user, location.course_key):
+ return False
+
+ return True
+
+ def load_asset_from_location(self, location):
+ """
+ Loads an asset based on its location, either retrieving it from a cache
+ or loading it directly from the contentstore.
+ """
+
+ # See if we can load this item from cache.
+ content = get_cached_content(location)
+ if content is None:
+ # Not in cache, so just try and load it from the asset manager.
+ try:
+ content = AssetManager.find(location, as_stream=True)
+ except (ItemNotFoundError, NotFoundError): # lint-amnesty, pylint: disable=try-except-raise
+ raise
+
+ # Now that we fetched it, let's go ahead and try to cache it. We cap this at 1MB
+ # because it's the default for memcached and also we don't want to do too much
+ # buffering in memory when we're serving an actual request.
+ if content.length is not None and content.length < 1048576:
+ content = content.copy_to_in_mem()
+ set_cached_content(content)
+
+ return content
+
+
+IMPL = StaticContentServer()
+
+
+def parse_range_header(header_value, content_length):
+ """
+ Returns the unit and a list of (start, end) tuples of ranges.
+
+ Raises ValueError if header is syntactically invalid or does not contain a range.
+
+ See spec for details: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
+ """
+
+ unit = None
+ ranges = []
+
+ if '=' in header_value:
+ unit, byte_ranges_string = header_value.split('=')
+
+ # Parse the byte ranges.
+ for byte_range_string in byte_ranges_string.split(','):
+ byte_range_string = byte_range_string.strip()
+ # Case 0:
+ if '-' not in byte_range_string: # Invalid syntax of header value. # lint-amnesty, pylint: disable=no-else-raise
+ raise ValueError('Invalid syntax.')
+ # Case 1: -500
+ elif byte_range_string.startswith('-'):
+ first = max(0, (content_length + int(byte_range_string)))
+ last = content_length - 1
+ # Case 2: 500-
+ elif byte_range_string.endswith('-'):
+ first = int(byte_range_string[0:-1])
+ last = content_length - 1
+ # Case 3: 500-999
+ else:
+ first, last = byte_range_string.split('-')
+ first = int(first)
+ last = min(int(last), content_length - 1)
+
+ ranges.append((first, last))
+
+ if len(ranges) == 0:
+ raise ValueError('Invalid syntax')
+
+ return unit, ranges
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index c96cb6b578..7fab48dea6 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==4.24.0
+edx-enterprise==4.25.9
# Stay on LTS version, remove once this is added to common constraint
Django<5.0
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 85167e0d36..6c789e9f84 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.24.0
+edx-enterprise==4.25.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -549,7 +549,7 @@ elasticsearch==7.13.4
# edx-search
enmerkar==0.7.1
# via enmerkar-underscore
-enmerkar-underscore==2.3.0
+enmerkar-underscore==2.3.1
# via -r requirements/edx/kernel.in
event-tracking==3.0.0
# via
@@ -814,6 +814,7 @@ openedx-django-wiki==2.1.0
openedx-events==9.12.0
# via
# -r requirements/edx/kernel.in
+ # edx-enterprise
# edx-event-bus-kafka
# edx-event-bus-redis
# event-tracking
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 876bc4fcc4..190f02b687 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.24.0
+edx-enterprise==4.25.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -861,7 +861,7 @@ enmerkar==0.7.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# enmerkar-underscore
-enmerkar-underscore==2.3.0
+enmerkar-underscore==2.3.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1362,6 +1362,7 @@ openedx-events==9.12.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
+ # edx-enterprise
# edx-event-bus-kafka
# edx-event-bus-redis
# event-tracking
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index a222e78518..c0a7792839 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.24.0
+edx-enterprise==4.25.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -635,7 +635,7 @@ enmerkar==0.7.1
# via
# -r requirements/edx/base.txt
# enmerkar-underscore
-enmerkar-underscore==2.3.0
+enmerkar-underscore==2.3.1
# via -r requirements/edx/base.txt
event-tracking==3.0.0
# via
@@ -973,6 +973,7 @@ openedx-django-wiki==2.1.0
openedx-events==9.12.0
# via
# -r requirements/edx/base.txt
+ # edx-enterprise
# edx-event-bus-kafka
# edx-event-bus-redis
# event-tracking
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index ac468974eb..391f183bf3 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.24.0
+edx-enterprise==4.25.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -661,7 +661,7 @@ enmerkar==0.7.1
# via
# -r requirements/edx/base.txt
# enmerkar-underscore
-enmerkar-underscore==2.3.0
+enmerkar-underscore==2.3.1
# via -r requirements/edx/base.txt
event-tracking==3.0.0
# via
@@ -1024,6 +1024,7 @@ openedx-django-wiki==2.1.0
openedx-events==9.12.0
# via
# -r requirements/edx/base.txt
+ # edx-enterprise
# edx-event-bus-kafka
# edx-event-bus-redis
# event-tracking
diff --git a/xmodule/course_block.py b/xmodule/course_block.py
index 52422ffa34..46ad7476f6 100644
--- a/xmodule/course_block.py
+++ b/xmodule/course_block.py
@@ -228,7 +228,7 @@ class ProctoringProvider(String):
and default that pulls from edx platform settings.
"""
- def from_json(self, value):
+ def from_json(self, value, validate_providers=False):
"""
Return ProctoringProvider as full featured Python type. Perform validation on the provider
and include any inherited values from the platform default.
@@ -237,7 +237,8 @@ class ProctoringProvider(String):
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
# Only validate the provider value if ProctoredExams are enabled on the environment
# Otherwise, the passed in provider does not matter. We should always return default
- self._validate_proctoring_provider(value)
+ if validate_providers:
+ self._validate_proctoring_provider(value)
value = self._get_proctoring_value(value)
return value
else:
diff --git a/xmodule/tests/test_course_block.py b/xmodule/tests/test_course_block.py
index 39c6c39e87..f2956cca0d 100644
--- a/xmodule/tests/test_course_block.py
+++ b/xmodule/tests/test_course_block.py
@@ -542,14 +542,27 @@ class ProctoringProviderTestCase(unittest.TestCase):
with override_settings(FEATURES=FEATURES_WITH_PROCTORED_EXAMS):
if proctored_exams_setting_enabled:
with pytest.raises(InvalidProctoringProvider) as context_manager:
- self.proctoring_provider.from_json(provider)
+ self.proctoring_provider.from_json(provider, validate_providers=True)
expected_error = f'The selected proctoring provider, {provider}, is not a valid provider. ' \
f'Please select from one of {allowed_proctoring_providers}.'
assert str(context_manager.value) == expected_error
else:
- provider_value = self.proctoring_provider.from_json(provider)
+ provider_value = self.proctoring_provider.from_json(provider, validate_providers=True)
assert provider_value == self.proctoring_provider.default
+ def test_from_json_validate_providers(self):
+ """
+ Test that an invalid provider is ignored if validate providers is set to false
+ """
+ provider = 'invalid-provider'
+
+ FEATURES_WITH_PROCTORED_EXAMS = settings.FEATURES.copy()
+ FEATURES_WITH_PROCTORED_EXAMS['ENABLE_PROCTORED_EXAMS'] = True
+
+ with override_settings(FEATURES=FEATURES_WITH_PROCTORED_EXAMS):
+ provider_value = self.proctoring_provider.from_json(provider, validate_providers=False)
+ assert provider_value == provider
+
def test_from_json_adds_platform_default_for_missing_provider(self):
"""
Test that a value with no provider will inherit the default provider