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:
Іван Нєдєльніцев
2024-09-11 15:40:47 +03:00
45 changed files with 1153 additions and 664 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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'))

View File

@@ -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",

View File

@@ -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:

View File

@@ -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',

View File

@@ -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):
"""

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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'),

View File

@@ -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)

View File

@@ -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()

View File

@@ -5,3 +5,7 @@ Exceptions for the verify student app
class WindowExpiredException(Exception):
pass
class VerificationAttemptInvalidStatus(Exception):
pass

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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'

View File

@@ -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%;

View File

@@ -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>

View File

@@ -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();
}
}
}

View File

@@ -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

View File

@@ -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__)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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