Merge branch 'NiedielnitsevIvan/FC-0047/feature/implement-push-notifications-chanel' of github.com:raccoongang/edx-platform into NiedielnitsevIvan/FC-0047/feature/implement-push-notifications-chanel
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 '<span class="important-dates-item-text pre-requisite"><a href="{}">{}</a></span>'.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 <a href="{pre_requisite_course_about_url}">'
|
||||
f'{pre_requisite_courses[0]["display"]}</a> 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 '<span class="important-dates-item-text pre-requisite"><a href="{}">{}</a></span>'.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 <a href="{pre_requisite_course_about_url}">'
|
||||
f'{pre_requisite_courses[0]["display"]}</a> 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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,3 +5,7 @@ Exceptions for the verify student app
|
||||
|
||||
class WindowExpiredException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VerificationAttemptInvalidStatus(Exception):
|
||||
pass
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -62,11 +62,10 @@ from openedx.core.lib.courses import course_image_url
|
||||
<div class="table">
|
||||
<section class="intro">
|
||||
<div class="heading-group">
|
||||
<h1>
|
||||
${course.display_name_with_default}
|
||||
</h1>
|
||||
<p><small>${course.display_org_with_default}</small></p>
|
||||
<h1>${course.display_name_with_default}</h1>
|
||||
<br />
|
||||
<span>${course.display_org_with_default}</span>
|
||||
<p>${get_course_about_section(request, course, 'short_description')}</p>
|
||||
</div>
|
||||
|
||||
<div class="main-cta">
|
||||
@@ -160,7 +159,11 @@ from openedx.core.lib.courses import course_image_url
|
||||
|
||||
<%block name="course_about_important_dates">
|
||||
<ol class="important-dates">
|
||||
<li class="important-dates-item"><span class="icon fa fa-info-circle" aria-hidden="true"></span><p class="important-dates-item-title">${_("Course Number")}</p><span class="important-dates-item-text course-number">${course.display_number_with_default}</span></li>
|
||||
<li class="important-dates-item">
|
||||
<span class="icon fa fa-info-circle" aria-hidden="true"></span>
|
||||
<p class="important-dates-item-title">${_("Course Number")}</p>
|
||||
<span class="important-dates-item-text course-number">${course.display_number_with_default}</span>
|
||||
</li>
|
||||
% 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"):
|
||||
<li class="important-dates-item"><span class="icon fa fa-book" aria-hidden="true"></span><p class="important-dates-item-title">${_("Requirements")}</p><span class="important-dates-item-text prerequisites">${get_course_about_section(request, course, "prerequisites")}</span></li>
|
||||
<li class="important-dates-item">
|
||||
<span class="icon fa fa-book" aria-hidden="true"></span>
|
||||
<p class="important-dates-item-title">${_("Requirements")}</p>
|
||||
<span class="important-dates-item-text prerequisites">${get_course_about_section(request, course, "prerequisites")}</span>
|
||||
</li>
|
||||
% endif
|
||||
</ol>
|
||||
</%block>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user