diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
index 21b1e27fcdf8..96e392c35d2a 100644
--- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
@@ -3,7 +3,10 @@
"""
import re
+from bs4 import BeautifulSoup
from django.conf import settings
+from django.utils.text import Truncator
+
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
from openedx_events.learning.data import UserNotificationData, CourseNotificationData
from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED, COURSE_NOTIFICATION_REQUESTED
@@ -27,13 +30,24 @@ class DiscussionNotificationSender:
Class to send notifications to users who are subscribed to the thread.
"""
- def __init__(self, thread, course, creator, parent_id=None):
+ def __init__(self, thread, course, creator, parent_id=None, comment_id=None):
self.thread = thread
self.course = course
self.creator = creator
self.parent_id = parent_id
+ self.comment_id = comment_id
self.parent_response = None
+ self.comment = None
self._get_parent_response()
+ self._get_comment()
+
+ def _get_comment(self):
+ """
+ Get comment object
+ """
+ if not self.comment_id:
+ return
+ self.comment = Comment(id=self.comment_id).retrieve()
def _send_notification(self, user_ids, notification_type, extra_context=None):
"""
@@ -99,7 +113,10 @@ def send_new_response_notification(self):
there is a response to the main thread.
"""
if not self.parent_id and self.creator.id != int(self.thread.user_id):
- self._send_notification([self.thread.user_id], "new_response")
+ context = {
+ 'email_content': clean_thread_html_body(self.comment.body),
+ }
+ self._send_notification([self.thread.user_id], "new_response", extra_context=context)
def _response_and_thread_has_same_creator(self) -> bool:
"""
@@ -136,6 +153,7 @@ def send_new_comment_notification(self):
context = {
"author_name": str(author_name),
"author_pronoun": str(author_pronoun),
+ "email_content": clean_thread_html_body(self.comment.body),
}
self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
@@ -149,7 +167,14 @@ def send_new_comment_on_response_notification(self):
self.creator.id != int(self.parent_response.user_id) and not
self._response_and_thread_has_same_creator()
):
- self._send_notification([self.parent_response.user_id], "new_comment_on_response")
+ context = {
+ "email_content": clean_thread_html_body(self.comment.body),
+ }
+ self._send_notification(
+ [self.parent_response.user_id],
+ "new_comment_on_response",
+ extra_context=context
+ )
def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool:
"""
@@ -190,7 +215,12 @@ def send_response_on_followed_post_notification(self):
# Remove duplicate users from the list of users to send notification
users = list(set(users))
if not self.parent_id:
- self._send_notification(users, "response_on_followed_post")
+ self._send_notification(
+ users,
+ "response_on_followed_post",
+ extra_context={
+ "email_content": clean_thread_html_body(self.comment.body),
+ })
else:
author_name = f"{self.parent_response.username}'s"
# use 'their' if comment author is also response author.
@@ -206,6 +236,7 @@ def send_response_on_followed_post_notification(self):
extra_context={
"author_name": str(author_name),
"author_pronoun": str(author_pronoun),
+ "email_content": clean_thread_html_body(self.comment.body),
}
)
@@ -289,7 +320,8 @@ def send_new_thread_created_notification(self):
]
context = {
'username': self.creator.username,
- 'post_title': self.thread.title
+ 'post_title': self.thread.title,
+ "email_content": clean_thread_html_body(self.thread.body),
}
self._send_course_wide_notification(notification_type, audience_filters, context)
@@ -339,3 +371,26 @@ def is_discussion_cohorted(course_key_str):
def remove_html_tags(text):
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
+
+
+def clean_thread_html_body(html_body):
+ """
+ Get post body with tags removed and limited to 500 characters
+ """
+ html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
+
+ tags_to_remove = [
+ "a", "link", # Link Tags
+ "img", "picture", "source", # Image Tags
+ "video", "track", # Video Tags
+ "audio", # Audio Tags
+ "embed", "object", "iframe", # Embedded Content
+ "script"
+ ]
+
+ # Remove the specified tags while keeping their content
+ for tag in tags_to_remove:
+ for match in html_body.find_all(tag):
+ match.unwrap()
+
+ return str(html_body)
diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py
index 45bf41fe905f..a87fafd4ca54 100644
--- a/lms/djangoapps/discussion/rest_api/tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tasks.py
@@ -33,7 +33,7 @@ def send_thread_created_notification(thread_id, course_key_str, user_id):
@shared_task
@set_code_owner_attribute
-def send_response_notifications(thread_id, course_key_str, user_id, parent_id=None):
+def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None):
"""
Send notifications to users who are subscribed to the thread.
"""
@@ -43,7 +43,7 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No
thread = Thread(id=thread_id).retrieve()
user = User.objects.get(id=user_id)
course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True)
- notification_sender = DiscussionNotificationSender(thread, course, user, parent_id)
+ notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id)
notification_sender.send_new_comment_notification()
notification_sender.send_new_response_notification()
notification_sender.send_new_comment_on_response_notification()
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
index b4d3f3d18a0d..f1a71fd1239e 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
@@ -1,13 +1,14 @@
"""
Unit tests for the DiscussionNotificationSender class
"""
-
+import re
import unittest
from unittest.mock import MagicMock, patch
import pytest
-from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender
+from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender, \
+ clean_thread_html_body
@patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender'
@@ -88,3 +89,82 @@ def test_send_reported_content_notification_for_thread(self, mock_send_notificat
self.notification_sender.send_reported_content_notification()
self._assert_send_notification_called_with(mock_send_notification, 'thread')
+
+
+class TestCleanThreadHtmlBody(unittest.TestCase):
+ """
+ Tests for the clean_thread_html_body function
+ """
+
+ def test_html_tags_removal(self):
+ """
+ Test that the clean_thread_html_body function removes unwanted HTML tags
+ """
+ html_body = """
+
This is a link to a page.
+ Here is an image:
+ Embedded video:
+ Script test:
+ Some other content that should remain.
+ """
+ expected_output = ("This is a link to a page.
"
+ "Here is an image:
"
+ "Embedded video:
"
+ "Script test: alert('hello');
"
+ "Some other content that should remain.
")
+
+ result = clean_thread_html_body(html_body)
+
+ def normalize_html(text):
+ """
+ Normalize the output by removing extra whitespace, newlines, and spaces between tags
+ """
+ text = re.sub(r'\s+', ' ', text).strip() # Replace any sequence of whitespace with a single space
+ text = re.sub(r'>\s+<', '><', text) # Remove spaces between HTML tags
+ return text
+
+ normalized_result = normalize_html(result)
+ normalized_expected_output = normalize_html(expected_output)
+
+ self.assertEqual(normalized_result, normalized_expected_output)
+
+ def test_truncate_html_body(self):
+ """
+ Test that the clean_thread_html_body function truncates the HTML body to 500 characters
+ """
+ html_body = """
+ This is a long text that should be truncated to 500 characters.
+ """ * 20 # Repeat to exceed 500 characters
+
+ result = clean_thread_html_body(html_body)
+ self.assertGreaterEqual(500, len(result))
+
+ def test_no_tags_to_remove(self):
+ """
+ Test that the clean_thread_html_body function does not remove any tags if there are no unwanted tags
+ """
+ html_body = "This paragraph has no tags to remove.
"
+ expected_output = "This paragraph has no tags to remove.
"
+
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result, expected_output)
+
+ def test_empty_html_body(self):
+ """
+ Test that the clean_thread_html_body function returns an empty string if the input is an empty string
+ """
+ html_body = ""
+ expected_output = ""
+
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result, expected_output)
+
+ def test_only_script_tag(self):
+ """
+ Test that the clean_thread_html_body function removes the script tag and its content
+ """
+ html_body = ""
+ expected_output = "alert('Hello');"
+
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result.strip(), expected_output)
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
index 28cfe3395cb6..8efd5cd49cbd 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
@@ -273,6 +273,17 @@ def setUp(self):
})
self._register_subscriptions_endpoint()
+ self.comment = ThreadMock(thread_id=4, creator=self.user_2, title='test comment', body='comment body')
+ self.register_get_comment_response(
+ {
+ 'id': self.comment.id,
+ 'thread_id': self.thread.id,
+ 'parent_id': None,
+ 'user_id': self.comment.user_id,
+ 'body': self.comment.body,
+ }
+ )
+
def test_basic(self):
"""
Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin
@@ -292,7 +303,13 @@ def test_send_notification_to_thread_creator(self):
# Post the form or do what it takes to send the signal
- send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None)
+ send_response_notifications(
+ self.thread.id,
+ str(self.course.id),
+ self.user_2.id,
+ self.comment.id,
+ parent_id=None
+ )
self.assertEqual(handler.call_count, 2)
args = handler.call_args_list[0][1]['notification_data']
self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id])
@@ -300,6 +317,7 @@ def test_send_notification_to_thread_creator(self):
expected_context = {
'replier_name': self.user_2.username,
'post_title': 'test thread',
+ 'email_content': self.comment.body,
'course_name': self.course.display_name,
'sender_id': self.user_2.id
}
@@ -325,7 +343,13 @@ def test_send_notification_to_parent_threads(self):
'user_id': self.thread_2.user_id
})
- send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id)
+ send_response_notifications(
+ self.thread.id,
+ str(self.course.id),
+ self.user_3.id,
+ self.comment.id,
+ parent_id=self.thread_2.id
+ )
# check if 2 call are made to the handler i.e. one for the response creator and one for the thread creator
self.assertEqual(handler.call_count, 2)
@@ -337,6 +361,7 @@ def test_send_notification_to_parent_threads(self):
expected_context = {
'replier_name': self.user_3.username,
'post_title': self.thread.title,
+ 'email_content': self.comment.body,
'author_name': 'dummy\'s',
'author_pronoun': 'dummy\'s',
'course_name': self.course.display_name,
@@ -355,6 +380,7 @@ def test_send_notification_to_parent_threads(self):
expected_context = {
'replier_name': self.user_3.username,
'post_title': self.thread.title,
+ 'email_content': self.comment.body,
'course_name': self.course.display_name,
'sender_id': self.user_3.id
}
@@ -372,7 +398,13 @@ def test_no_signal_on_creators_own_thread(self):
"""
handler = mock.Mock()
USER_NOTIFICATION_REQUESTED.connect(handler)
- send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None)
+
+ send_response_notifications(
+ self.thread.id,
+ str(self.course.id),
+ self.user_1.id,
+ self.comment.id, parent_id=None
+ )
self.assertEqual(handler.call_count, 1)
def test_comment_creators_own_response(self):
@@ -389,7 +421,13 @@ def test_comment_creators_own_response(self):
'user_id': self.thread_3.user_id
})
- send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id)
+ send_response_notifications(
+ self.thread.id,
+ str(self.course.id),
+ self.user_3.id,
+ parent_id=self.thread_2.id,
+ comment_id=self.comment.id
+ )
# check if 1 call is made to the handler i.e. for the thread creator
self.assertEqual(handler.call_count, 2)
@@ -404,6 +442,7 @@ def test_comment_creators_own_response(self):
'author_pronoun': 'your',
'course_name': self.course.display_name,
'sender_id': self.user_3.id,
+ 'email_content': self.comment.body
}
self.assertDictEqual(args_comment.context, expected_context)
self.assertEqual(
@@ -429,7 +468,13 @@ def test_send_notification_to_followers(self, parent_id, notification_type):
USER_NOTIFICATION_REQUESTED.connect(handler)
# Post the form or do what it takes to send the signal
- notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id)
+ notification_sender = DiscussionNotificationSender(
+ self.thread,
+ self.course,
+ self.user_2,
+ parent_id=parent_id,
+ comment_id=self.comment.id
+ )
notification_sender.send_response_on_followed_post_notification()
self.assertEqual(handler.call_count, 1)
args = handler.call_args[1]['notification_data']
@@ -439,6 +484,7 @@ def test_send_notification_to_followers(self, parent_id, notification_type):
expected_context = {
'replier_name': self.user_2.username,
'post_title': 'test thread',
+ 'email_content': self.comment.body,
'course_name': self.course.display_name,
'sender_id': self.user_2.id,
}
@@ -516,6 +562,7 @@ def test_new_comment_notification(self):
thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread')
response = ThreadMock(thread_id=2, creator=self.user_2, title='test response')
+ comment = ThreadMock(thread_id=3, creator=self.user_2, title='test comment', body='comment body')
self.register_get_thread_response({
'id': thread.id,
'course_id': str(self.course.id),
@@ -530,12 +577,20 @@ def test_new_comment_notification(self):
'thread_id': thread.id,
'user_id': response.user_id
})
+ self.register_get_comment_response({
+ 'id': comment.id,
+ 'parent_id': response.id,
+ 'user_id': comment.user_id,
+ 'body': comment.body
+ })
self.register_get_subscriptions(1, {})
- send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id)
+ send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id,
+ comment_id=comment.id)
handler.assert_called_once()
context = handler.call_args[1]['notification_data'].context
self.assertEqual(context['author_name'], 'dummy\'s')
self.assertEqual(context['author_pronoun'], 'their')
+ self.assertEqual(context['email_content'], comment.body)
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py
index 989fd63855d5..27e34705f5df 100644
--- a/lms/djangoapps/discussion/rest_api/tests/utils.py
+++ b/lms/djangoapps/discussion/rest_api/tests/utils.py
@@ -675,12 +675,13 @@ class ThreadMock(object):
A mock thread object
"""
- def __init__(self, thread_id, creator, title, parent_id=None):
+ def __init__(self, thread_id, creator, title, parent_id=None, body=''):
self.id = thread_id
self.user_id = str(creator.id)
self.username = creator.username
self.title = title
self.parent_id = parent_id
+ self.body = body
def url_with_id(self, params):
return f"http://example.com/{params['id']}"
diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py
index 35288cdbd9be..2aa7d36456c4 100644
--- a/lms/djangoapps/discussion/signals/handlers.py
+++ b/lms/djangoapps/discussion/signals/handlers.py
@@ -176,8 +176,9 @@ def create_comment_created_notification(*args, **kwargs):
comment = kwargs['post']
thread_id = comment.attributes['thread_id']
parent_id = comment.attributes['parent_id']
+ comment_id = comment.attributes['id']
course_key_str = comment.attributes['course_id']
- send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, parent_id])
+ send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, comment_id, parent_id])
@receiver(signals.comment_endorsed)
diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py
index 300543def6c2..896d0deadcd9 100644
--- a/lms/djangoapps/instructor/enrollment.py
+++ b/lms/djangoapps/instructor/enrollment.py
@@ -34,6 +34,7 @@
get_event_transaction_id,
set_event_transaction_type
)
+from lms.djangoapps.branding.api import get_logo_url_for_email
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.grades.api import constants as grades_constants
from lms.djangoapps.grades.api import disconnect_submissions_signal_receiver
@@ -489,6 +490,7 @@ def get_email_params(course, auto_enroll, secure=True, course_key=None, display_
'contact_mailing_address': contact_mailing_address,
'platform_name': platform_name,
'site_configuration_values': configuration_helpers.get_current_site_configuration_values(),
+ 'logo_url': get_logo_url_for_email(),
}
return email_params
diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py
index 59ccfac6caa1..741f57ef6d2b 100644
--- a/lms/djangoapps/instructor/tests/test_enrollment.py
+++ b/lms/djangoapps/instructor/tests/test_enrollment.py
@@ -23,6 +23,7 @@
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, anonymous_id_for_user
from common.djangoapps.student.roles import CourseCcxCoachRole
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
+from lms.djangoapps.branding.api import get_logo_url_for_email
from lms.djangoapps.ccx.tests.factories import CcxFactory
from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.courseware.models import StudentModule
@@ -940,6 +941,7 @@ def setUpClass(cls):
)
cls.course_about_url = cls.course_url + 'about'
cls.registration_url = f'https://{site}/register'
+ cls.logo_url = get_logo_url_for_email()
def test_normal_params(self):
# For a normal site, what do we expect to get for the URLs?
@@ -950,6 +952,7 @@ def test_normal_params(self):
assert result['course_about_url'] == self.course_about_url
assert result['registration_url'] == self.registration_url
assert result['course_url'] == self.course_url
+ assert result['logo_url'] == self.logo_url
def test_marketing_params(self):
# For a site with a marketing front end, what do we expect to get for the URLs?
@@ -962,6 +965,19 @@ def test_marketing_params(self):
assert result['course_about_url'] is None
assert result['registration_url'] == self.registration_url
assert result['course_url'] == self.course_url
+ assert result['logo_url'] == self.logo_url
+
+ @patch('lms.djangoapps.instructor.enrollment.get_logo_url_for_email', return_value='https://www.logo.png')
+ def test_logo_url_params(self, mock_get_logo_url_for_email):
+ # Verify that the logo_url is correctly set in the email params
+ result = get_email_params(self.course, False)
+
+ assert result['auto_enroll'] is False
+ assert result['course_about_url'] == self.course_about_url
+ assert result['registration_url'] == self.registration_url
+ assert result['course_url'] == self.course_url
+ mock_get_logo_url_for_email.assert_called_once()
+ assert result['logo_url'] == 'https://www.logo.png'
@ddt.ddt
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 7ca1e70467b0..1aa40b5e3376 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -105,7 +105,9 @@
from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
from lms.djangoapps.instructor_task.models import ReportStore
-from lms.djangoapps.instructor.views.serializer import RoleNameSerializer, UserSerializer, AccessSerializer
+from lms.djangoapps.instructor.views.serializer import (
+ AccessSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer
+)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
@@ -2979,20 +2981,50 @@ def show_unit_extensions(request, course_id):
return JsonResponse(dump_block_extensions(course, unit))
-@handle_dashboard_error
-@require_POST
-@ensure_csrf_cookie
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_course_permission(permissions.GIVE_STUDENT_EXTENSION)
-@require_post_params('student')
-def show_student_extensions(request, course_id):
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+class ShowStudentExtensions(APIView):
"""
Shows all of the due date extensions granted to a particular student in a
particular course.
"""
- student = require_student_from_identifier(request.POST.get('student'))
- course = get_course_by_id(CourseKey.from_string(course_id))
- return JsonResponse(dump_student_extensions(course, student))
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ serializer_class = ShowStudentExtensionSerializer
+ permission_name = permissions.GIVE_STUDENT_EXTENSION
+
+ @method_decorator(ensure_csrf_cookie)
+ def post(self, request, course_id):
+ """
+ Handles POST requests to retrieve due date extensions for a specific student
+ within a specified course.
+
+ Parameters:
+ - `request`: The HTTP request object containing user-submitted data.
+ - `course_id`: The ID of the course for which the extensions are being queried.
+
+ Data expected in the request:
+ - `student`: A required field containing the identifier of the student for whom
+ the due date extensions are being retrieved. This data is extracted from the
+ request body.
+
+ Returns:
+ - A JSON response containing the details of the due date extensions granted to
+ the specified student in the specified course.
+ """
+ data = {
+ 'student': request.data.get('student')
+ }
+ serializer_data = self.serializer_class(data=data)
+
+ if not serializer_data.is_valid():
+ return HttpResponseBadRequest(reason=serializer_data.errors)
+
+ student = serializer_data.validated_data.get('student')
+ if not student:
+ response_payload = f'Could not find student matching identifier: {request.data.get("student")}'
+ return JsonResponse({'error': response_payload}, status=400)
+
+ course = get_course_by_id(CourseKey.from_string(course_id))
+ return Response(dump_student_extensions(course, student))
def _split_input_list(str_list):
diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py
index c0e12e46756d..18da0b63b218 100644
--- a/lms/djangoapps/instructor/views/api_urls.py
+++ b/lms/djangoapps/instructor/views/api_urls.py
@@ -53,7 +53,7 @@
path('change_due_date', api.change_due_date, name='change_due_date'),
path('reset_due_date', api.reset_due_date, name='reset_due_date'),
path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'),
- path('show_student_extensions', api.show_student_extensions, name='show_student_extensions'),
+ path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'),
# proctored exam downloads...
path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'),
diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py
index 463f05ad45b8..0697bed6832d 100644
--- a/lms/djangoapps/instructor/views/serializer.py
+++ b/lms/djangoapps/instructor/views/serializer.py
@@ -59,3 +59,21 @@ def validate_unique_student_identifier(self, value):
return None
return user
+
+
+class ShowStudentExtensionSerializer(serializers.Serializer):
+ """
+ Serializer for validating and processing the student identifier.
+ """
+ student = serializers.CharField(write_only=True, required=True)
+
+ def validate_student(self, value):
+ """
+ Validate that the student corresponds to an existing user.
+ """
+ try:
+ user = get_student_from_identifier(value)
+ except User.DoesNotExist:
+ return None
+
+ return user
diff --git a/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst
new file mode 100644
index 000000000000..08735188fcdc
--- /dev/null
+++ b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst
@@ -0,0 +1,65 @@
+0001. Extending Identity Verification
+#####################################
+
+Status
+******
+
+**Accepted** *2024-08-26*
+
+Context
+*******
+
+The backend implementation of identity verification (IDV) is in the `verify_student Django application`_. The
+`verify_student Django application`_ also contains a frontend user experience for performing photo IDV via an
+integration with Software Secure. There is also a `React-based implementation of this flow`_ in the
+`frontend-app-account MFE`_, so the frontend user experience stored in the `verify_student Django application`_ is often
+called the "legacy flow".
+
+The current architecture of the `verify_student Django application`_ requires that any additional implementations of IDV
+are stored in the application. For example, the Software Secure integration is stored in this application even though
+it is a custom integration that the Open edX community does not use.
+
+Different Open edX operators have different IDV needs. There is currently no way to add additional IDV implementations
+to the platform without committing them to the core. The `verify_student Django application`_ needs enhanced
+extensibility mechanisms to enable per-deployment integration of IDV implementations without modifying the core.
+
+Decision
+********
+
+* We will support the integration of additional implementations of IDV through the use of Python plugins into the
+ platform.
+* We will add a ``VerificationAttempt`` model, which will store generic, implementation-agnostic information about an
+ IDV attempt.
+* We will expose a simple Python API to write and update instances of the ``VerificationAttempt`` model. This will
+ enable plugins to publish information about their IDV attempts to the platform.
+* The ``VerificationAttempt`` model will be integrated into the `verify_student Django application`_, particularly into
+ the `IDVerificationService`_.
+* We will emit Open edX events for each status change of a ``VerificationAttempt``.
+* We will add an Open edX filter hook to change the URL of the photo IDV frontend.
+
+Consequences
+************
+
+* It will become possible for Open edX operators to implement and integrate any additional forms of IDV necessary for
+ their deployment.
+* The `verify_student Django application`_ will contain both concrete implementations of forms of IDV (i.e. manual, SSO,
+ Software Secure, etc.) and a generic, extensible implementation. The work to deprecate and remove the Software Secure
+ integration and to transition the other existing forms of IDV (i.e. manual and SSO) to Django plugins will occur
+ independently of the improvements to extensibility described in this decision.
+
+Rejected Alternatives
+*********************
+
+We considered introducing a ``fetch_verification_attempts`` filter hook to allow plugins to expose additional
+``VerificationAttempts`` to the platform in lieu of an additional model. However, doing database queries via filter
+hooks can cause unpredictable performance problems, and this has been a pain point for Open edX.
+
+References
+**********
+`[Proposal] Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_
+`Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_
+
+.. _frontend-app-account MFE: https://github.com/openedx/frontend-app-account
+.. _IDVerificationService: https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/verify_student/services.py#L55
+.. _React-based implementation of this flow: https://github.com/openedx/frontend-app-account/tree/master/src/id-verification
+.. _verify_student Django application: https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/verify_student
diff --git a/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py
new file mode 100644
index 000000000000..3f01047f9f51
--- /dev/null
+++ b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.2.15 on 2024-08-26 14:05
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('verify_student', '0014_remove_softwaresecurephotoverification_expiry_date'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VerificationAttempt',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('name', models.CharField(blank=True, max_length=255)),
+ ('status', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('approved', 'approved'), ('denied', 'denied')], max_length=64)),
+ ('expiration_datetime', models.DateTimeField(blank=True, null=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py
index f7750a4cd662..903d80bf9245 100644
--- a/lms/djangoapps/verify_student/models.py
+++ b/lms/djangoapps/verify_student/models.py
@@ -31,6 +31,7 @@
from django.utils.translation import gettext_lazy
from model_utils import Choices
from model_utils.models import StatusModel, TimeStampedModel
+from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus
from opaque_keys.edx.django.models import CourseKeyField
from lms.djangoapps.verify_student.ssencrypt import (
@@ -1189,3 +1190,27 @@ class Meta:
def __str__(self):
return str(self.arguments)
+
+
+class VerificationAttempt(TimeStampedModel):
+ """
+ The model represents impelementation-agnostic information about identity verification (IDV) attempts.
+
+ Plugins that implement forms of IDV can store information about IDV attempts in this model for use across
+ the platform.
+ """
+ user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
+ name = models.CharField(blank=True, max_length=255)
+
+ STATUS_CHOICES = [
+ VerificationAttemptStatus.created,
+ VerificationAttemptStatus.pending,
+ VerificationAttemptStatus.approved,
+ VerificationAttemptStatus.denied,
+ ]
+ status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES])
+
+ expiration_datetime = models.DateTimeField(
+ null=True,
+ blank=True,
+ )
diff --git a/lms/djangoapps/verify_student/statuses.py b/lms/djangoapps/verify_student/statuses.py
new file mode 100644
index 000000000000..b55a9042e0f6
--- /dev/null
+++ b/lms/djangoapps/verify_student/statuses.py
@@ -0,0 +1,21 @@
+"""
+Status enums for verify_student.
+"""
+
+
+class VerificationAttemptStatus:
+ """This class describes valid statuses for a verification attempt to be in."""
+
+ # This is the initial state of a verification attempt, before a learner has started IDV.
+ created = "created"
+
+ # A verification attempt is pending when it has been started but has not yet been completed.
+ pending = "pending"
+
+ # A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual
+ # review, etc).
+ approved = "approved"
+
+ # A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review,
+ # etc).
+ denied = "denied"
diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py
index 4262b8a7b13f..9fb49b24b6d1 100644
--- a/openedx/core/djangoapps/content/search/api.py
+++ b/openedx/core/djangoapps/content/search/api.py
@@ -19,6 +19,7 @@
from meilisearch.models.task import TaskInfo
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locator import LibraryLocatorV2
+from openedx_learning.api import authoring as authoring_api
from common.djangoapps.student.roles import GlobalStaff
from rest_framework.request import Request
from common.djangoapps.student.role_helpers import get_course_roles
@@ -31,8 +32,9 @@
Fields,
meili_id_from_opaque_key,
searchable_doc_for_course_block,
+ searchable_doc_for_collection,
searchable_doc_for_library_block,
- searchable_doc_tags
+ searchable_doc_tags,
)
log = logging.getLogger(__name__)
@@ -294,12 +296,16 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
status_cb("Counting courses...")
num_courses = CourseOverview.objects.count()
+ # Get the list of collections
+ status_cb("Counting collections...")
+ num_collections = authoring_api.get_collections().count()
+
# Some counters so we can track our progress as indexing progresses:
- num_contexts = num_courses + num_libraries
+ num_contexts = num_courses + num_libraries + num_collections
num_contexts_done = 0 # How many courses/libraries we've indexed
num_blocks_done = 0 # How many individual components/XBlocks we've indexed
- status_cb(f"Found {num_courses} courses and {num_libraries} libraries.")
+ status_cb(f"Found {num_courses} courses, {num_libraries} libraries and {num_collections} collections.")
with _using_temp_index(status_cb) as temp_index_name:
############## Configure the index ##############
@@ -323,6 +329,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.type,
Fields.access_id,
Fields.last_published,
+ Fields.content + "." + Fields.problem_types,
])
# Mark which attributes are used for keyword search, in order of importance:
client.index(temp_index_name).update_searchable_attributes([
@@ -331,6 +338,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.block_id,
Fields.content,
Fields.tags,
+ Fields.description,
# If we don't list the following sub-fields _explicitly_, they're only sometimes searchable - that is, they
# are searchable only if at least one document in the index has a value. If we didn't list them here and,
# say, there were no tags.level3 tags in the index, the client would get an error if trying to search for
@@ -362,8 +370,8 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
############## Libraries ##############
status_cb("Indexing libraries...")
- for lib_key in lib_keys:
- status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing library {lib_key}")
+
+ def index_library(lib_key: str) -> list:
docs = []
for component in lib_api.get_library_components(lib_key):
try:
@@ -374,48 +382,88 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
docs.append(doc)
except Exception as err: # pylint: disable=broad-except
status_cb(f"Error indexing library component {component}: {err}")
- finally:
- num_blocks_done += 1
if docs:
try:
# Add all the docs in this library at once (usually faster than adding one at a time):
_wait_for_meili_task(client.index(temp_index_name).add_documents(docs))
except (TypeError, KeyError, MeilisearchError) as err:
status_cb(f"Error indexing library {lib_key}: {err}")
+ return docs
+ for lib_key in lib_keys:
+ status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing library {lib_key}")
+ lib_docs = index_library(lib_key)
+ num_blocks_done += len(lib_docs)
num_contexts_done += 1
############## Courses ##############
status_cb("Indexing courses...")
# To reduce memory usage on large instances, split up the CourseOverviews into pages of 1,000 courses:
+
+ def index_course(course: CourseOverview) -> list:
+ docs = []
+ # Pre-fetch the course with all of its children:
+ course = store.get_course(course.id, depth=None)
+
+ def add_with_children(block):
+ """ Recursively index the given XBlock/component """
+ doc = searchable_doc_for_course_block(block)
+ doc.update(searchable_doc_tags(block.usage_key))
+ docs.append(doc) # pylint: disable=cell-var-from-loop
+ _recurse_children(block, add_with_children) # pylint: disable=cell-var-from-loop
+
+ # Index course children
+ _recurse_children(course, add_with_children)
+
+ if docs:
+ # Add all the docs in this course at once (usually faster than adding one at a time):
+ _wait_for_meili_task(client.index(temp_index_name).add_documents(docs))
+ return docs
+
paginator = Paginator(CourseOverview.objects.only('id', 'display_name'), 1000)
for p in paginator.page_range:
for course in paginator.page(p).object_list:
status_cb(
f"{num_contexts_done + 1}/{num_contexts}. Now indexing course {course.display_name} ({course.id})"
)
- docs = []
-
- # Pre-fetch the course with all of its children:
- course = store.get_course(course.id, depth=None)
+ course_docs = index_course(course)
+ num_contexts_done += 1
+ num_blocks_done += len(course_docs)
- def add_with_children(block):
- """ Recursively index the given XBlock/component """
- doc = searchable_doc_for_course_block(block)
- doc.update(searchable_doc_tags(block.usage_key))
- docs.append(doc) # pylint: disable=cell-var-from-loop
- _recurse_children(block, add_with_children) # pylint: disable=cell-var-from-loop
+ ############## Collections ##############
+ status_cb("Indexing collections...")
- # Index course children
- _recurse_children(course, add_with_children)
+ def index_collection_batch(batch, num_contexts_done) -> int:
+ docs = []
+ for collection in batch:
+ status_cb(
+ f"{num_contexts_done + 1}/{num_contexts}. "
+ f"Now indexing collection {collection.title} ({collection.id})"
+ )
+ try:
+ doc = searchable_doc_for_collection(collection)
+ # Uncomment below line once collections are tagged.
+ # doc.update(searchable_doc_tags(collection.id))
+ docs.append(doc)
+ except Exception as err: # pylint: disable=broad-except
+ status_cb(f"Error indexing collection {collection}: {err}")
+ finally:
+ num_contexts_done += 1
- if docs:
- # Add all the docs in this course at once (usually faster than adding one at a time):
+ if docs:
+ try:
+ # Add docs in batch of 100 at once (usually faster than adding one at a time):
_wait_for_meili_task(client.index(temp_index_name).add_documents(docs))
- num_contexts_done += 1
- num_blocks_done += len(docs)
+ except (TypeError, KeyError, MeilisearchError) as err:
+ status_cb(f"Error indexing collection batch {p}: {err}")
+ return num_contexts_done
+
+ # To reduce memory usage on large instances, split up the Collections into pages of 100 collections:
+ paginator = Paginator(authoring_api.get_collections(enabled=True), 100)
+ for p in paginator.page_range:
+ num_contexts_done = index_collection_batch(paginator.page(p).object_list, num_contexts_done)
- status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses and libraries.")
+ status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses, collections and libraries.")
def upsert_xblock_index_doc(usage_key: UsageKey, recursive: bool = True) -> None:
diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py
index 032023f97c60..a45b37ab2ad2 100644
--- a/openedx/core/djangoapps/content/search/documents.py
+++ b/openedx/core/djangoapps/content/search/documents.py
@@ -13,6 +13,7 @@
from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.djangoapps.content_tagging import api as tagging_api
from openedx.core.djangoapps.xblock import api as xblock_api
+from openedx_learning.api.authoring_models import LearningPackage
log = logging.getLogger(__name__)
@@ -27,10 +28,12 @@ class Fields:
type = "type" # DocType.course_block or DocType.library_block (see below)
block_id = "block_id" # The block_id part of the usage key. Sometimes human-readable, sometimes a random hex ID
display_name = "display_name"
+ description = "description"
modified = "modified"
created = "created"
last_published = "last_published"
block_type = "block_type"
+ problem_types = "problem_types"
context_key = "context_key"
org = "org"
access_id = "access_id" # .models.SearchAccess.id
@@ -65,6 +68,7 @@ class DocType:
"""
course_block = "course_block"
library_block = "library_block"
+ collection = "collection"
def meili_id_from_opaque_key(usage_key: UsageKey) -> str:
@@ -275,3 +279,38 @@ def searchable_doc_for_course_block(block) -> dict:
doc.update(_fields_from_block(block))
return doc
+
+
+def searchable_doc_for_collection(collection) -> dict:
+ """
+ Generate a dictionary document suitable for ingestion into a search engine
+ like Meilisearch or Elasticsearch, so that the given collection can be
+ found using faceted search.
+ """
+ doc = {
+ Fields.id: collection.id,
+ Fields.type: DocType.collection,
+ Fields.display_name: collection.title,
+ Fields.description: collection.description,
+ Fields.created: collection.created.timestamp(),
+ Fields.modified: collection.modified.timestamp(),
+ # Add related learning_package.key as context_key by default.
+ # If related contentlibrary is found, it will override this value below.
+ # Mostly contentlibrary.library_key == learning_package.key
+ Fields.context_key: collection.learning_package.key,
+ }
+ # Just in case learning_package is not related to a library
+ try:
+ context_key = collection.learning_package.contentlibrary.library_key
+ org = str(context_key.org)
+ doc.update({
+ Fields.context_key: str(context_key),
+ Fields.org: org,
+ })
+ except LearningPackage.contentlibrary.RelatedObjectDoesNotExist:
+ log.warning(f"Related library not found for {collection}")
+ doc[Fields.access_id] = _meili_access_id_from_context_key(doc[Fields.context_key])
+ # Add the breadcrumbs.
+ doc[Fields.breadcrumbs] = [{"display_name": collection.learning_package.title}]
+
+ return doc
diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py
index e8616cee60a8..549817700370 100644
--- a/openedx/core/djangoapps/content/search/tests/test_api.py
+++ b/openedx/core/djangoapps/content/search/tests/test_api.py
@@ -6,12 +6,13 @@
import copy
from datetime import datetime, timezone
-from unittest.mock import MagicMock, call, patch
+from unittest.mock import MagicMock, Mock, call, patch
from opaque_keys.edx.keys import UsageKey
import ddt
from django.test import override_settings
from freezegun import freeze_time
+from openedx_learning.api import authoring as authoring_api
from organizations.tests.factories import OrganizationFactory
from common.djangoapps.student.tests.factories import UserFactory
@@ -174,6 +175,28 @@ def setUp(self):
tagging_api.add_tag_to_taxonomy(self.taxonomyB, "three")
tagging_api.add_tag_to_taxonomy(self.taxonomyB, "four")
+ # Create a collection:
+ self.learning_package = authoring_api.get_learning_package_by_key(self.library.key)
+ self.collection_dict = {
+ 'id': 1,
+ 'type': 'collection',
+ 'display_name': 'my_collection',
+ 'description': 'my collection description',
+ 'context_key': 'lib:org1:lib',
+ 'org': 'org1',
+ 'created': created_date.timestamp(),
+ 'modified': created_date.timestamp(),
+ "access_id": lib_access.id,
+ 'breadcrumbs': [{'display_name': 'Library'}]
+ }
+ with freeze_time(created_date):
+ self.collection = authoring_api.create_collection(
+ learning_package_id=self.learning_package.id,
+ title="my_collection",
+ created_by=None,
+ description="my collection description"
+ )
+
@override_settings(MEILISEARCH_ENABLED=False)
def test_reindex_meilisearch_disabled(self, mock_meilisearch):
with self.assertRaises(RuntimeError):
@@ -199,10 +222,27 @@ def test_reindex_meilisearch(self, mock_meilisearch):
[
call([doc_sequential, doc_vertical]),
call([doc_problem1, doc_problem2]),
+ call([self.collection_dict]),
],
any_order=True,
)
+ @override_settings(MEILISEARCH_ENABLED=True)
+ @patch(
+ "openedx.core.djangoapps.content.search.api.searchable_doc_for_collection",
+ Mock(side_effect=Exception("Failed to generate document")),
+ )
+ def test_reindex_meilisearch_collection_error(self, mock_meilisearch):
+
+ mock_logger = Mock()
+ api.rebuild_index(mock_logger)
+ assert call(
+ [self.collection_dict]
+ ) not in mock_meilisearch.return_value.index.return_value.add_documents.mock_calls
+ mock_logger.assert_any_call(
+ f"Error indexing collection {self.collection}: Failed to generate document"
+ )
+
@override_settings(MEILISEARCH_ENABLED=True)
def test_reindex_meilisearch_library_block_error(self, mock_meilisearch):
diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py
index e853fd425273..6140411705bb 100644
--- a/openedx/core/djangoapps/content/search/tests/test_documents.py
+++ b/openedx/core/djangoapps/content/search/tests/test_documents.py
@@ -1,8 +1,12 @@
"""
Tests for the Studio content search documents (what gets stored in the index)
"""
+from datetime import datetime, timezone
from organizations.models import Organization
+from freezegun import freeze_time
+from openedx_learning.api import authoring as authoring_api
+
from openedx.core.djangoapps.content_tagging import api as tagging_api
from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.modulestore.django import modulestore
@@ -11,10 +15,12 @@
try:
# This import errors in the lms because content.search is not an installed app there.
- from ..documents import searchable_doc_for_course_block, searchable_doc_tags
+ from ..documents import searchable_doc_for_course_block, searchable_doc_tags, searchable_doc_for_collection
from ..models import SearchAccess
except RuntimeError:
searchable_doc_for_course_block = lambda x: x
+ searchable_doc_tags = lambda x: x
+ searchable_doc_for_collection = lambda x: x
SearchAccess = {}
@@ -198,3 +204,30 @@ def test_video_block_untagged(self):
"content": {},
# This video has no tags.
}
+
+ def test_collection_with_no_library(self):
+ created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
+ with freeze_time(created_date):
+ learning_package = authoring_api.create_learning_package(
+ key="course-v1:edX+toy+2012_Fall",
+ title="some learning_package",
+ description="some description",
+ )
+ collection = authoring_api.create_collection(
+ learning_package_id=learning_package.id,
+ title="my_collection",
+ created_by=None,
+ description="my collection description"
+ )
+ doc = searchable_doc_for_collection(collection)
+ assert doc == {
+ "id": collection.id,
+ "type": "collection",
+ "display_name": collection.title,
+ "description": collection.description,
+ "context_key": learning_package.key,
+ "access_id": self.toy_course_access_id,
+ "breadcrumbs": [{"display_name": learning_package.title}],
+ "created": created_date.timestamp(),
+ "modified": created_date.timestamp(),
+ }
diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py
index 4f6a9b442daf..b9321c136383 100644
--- a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py
+++ b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py
@@ -6,6 +6,7 @@
import ddt
from openedx_learning.api.authoring_models import Collection
+from openedx_learning.api.authoring import create_collection
from opaque_keys.edx.locator import LibraryLocatorV2
from openedx.core.djangolib.testing.utils import skip_unless_cms
@@ -36,23 +37,23 @@ def setUp(self):
self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2")
# Create Content Library Collections
- self.col1 = Collection.objects.create(
+ self.col1 = create_collection(
learning_package_id=self.lib1.learning_package.id,
title="Collection 1",
+ created_by=self.user.id,
description="Description for Collection 1",
- created_by=self.user,
)
- self.col2 = Collection.objects.create(
+ self.col2 = create_collection(
learning_package_id=self.lib1.learning_package.id,
title="Collection 2",
+ created_by=self.user.id,
description="Description for Collection 2",
- created_by=self.user,
)
- self.col3 = Collection.objects.create(
+ self.col3 = create_collection(
learning_package_id=self.lib2.learning_package.id,
title="Collection 3",
+ created_by=self.user.id,
description="Description for Collection 3",
- created_by=self.user,
)
# Create some library blocks
@@ -111,6 +112,12 @@ def test_get_invalid_library_collection(self):
assert resp.status_code == 404
+ # Fetch collection with invalid library_key provided, it should fail
+ resp = self.client.get(
+ URL_LIB_COLLECTION.format(lib_key=123, collection_id=123)
+ )
+ assert resp.status_code == 404
+
def test_list_library_collections(self):
"""
Test listing Content Library Collections
@@ -142,6 +149,15 @@ def test_list_invalid_library_collections(self):
assert resp.status_code == 404
+ non_existing_key = LibraryLocatorV2.from_string("lib:DoesNotExist:NE1")
+ resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=non_existing_key))
+
+ assert resp.status_code == 404
+
+ # List collections with invalid library_key provided, it should fail
+ resp = resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=123))
+ assert resp.status_code == 404
+
def test_create_library_collection(self):
"""
Test creating a Content Library Collection
@@ -198,6 +214,14 @@ def test_create_invalid_library_collection(self):
assert resp.status_code == 400
+ # Create collection with invalid library_key provided, it should fail
+ resp = self.client.post(
+ URL_LIB_COLLECTIONS.format(lib_key=123),
+ {**post_data_missing_title, **post_data_missing_desc},
+ format="json"
+ )
+ assert resp.status_code == 404
+
def test_update_library_collection(self):
"""
Test updating a Content Library Collection
@@ -260,6 +284,14 @@ def test_update_invalid_library_collection(self):
assert resp.status_code == 404
+ # Update collection with invalid library_key provided, it should fail
+ resp = self.client.patch(
+ URL_LIB_COLLECTION.format(lib_key=123, collection_id=self.col3.id),
+ patch_data,
+ format="json"
+ )
+ assert resp.status_code == 404
+
def test_delete_library_collection(self):
"""
Test deleting a Content Library Collection
diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py
index cd4de80593f7..df2e2340155f 100644
--- a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py
+++ b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py
@@ -14,7 +14,6 @@
from openedx_learning.api.authoring_models import Collection
from openedx_learning.api import authoring as authoring_api
from opaque_keys.edx.locator import LibraryLocatorV2
-
from openedx_events.content_authoring.data import LibraryCollectionData
from openedx_events.content_authoring.signals import (
LIBRARY_COLLECTION_CREATED,
@@ -27,7 +26,7 @@
ContentLibraryCollectionComponentsUpdateSerializer,
ContentLibraryCollectionCreateOrUpdateSerializer,
)
-from openedx.core.djangoapps.content_libraries.views import convert_exceptions
+from openedx.core.djangoapps.content_libraries.utils import convert_exceptions
class LibraryCollectionsView(ModelViewSet):
@@ -44,11 +43,7 @@ def _verify_and_fetch_library_collection(self, library_key, collection_id, user,
"""
Verify that the collection belongs to the library and the user has the correct permissions
"""
- try:
- library_obj = api.require_permission_for_library_key(library_key, user, permission)
- except api.ContentLibraryNotFound as exc:
- raise Http404 from exc
-
+ library_obj = api.require_permission_for_library_key(library_key, user, permission)
collection = None
if library_obj.learning_package_id:
collection = authoring_api.get_collections(
@@ -56,6 +51,7 @@ def _verify_and_fetch_library_collection(self, library_key, collection_id, user,
).filter(id=collection_id).first()
return collection
+ @convert_exceptions
def retrieve(self, request, *args, **kwargs):
"""
Retrieve the Content Library Collection
@@ -79,6 +75,7 @@ def retrieve(self, request, *args, **kwargs):
serializer = self.get_serializer(collection)
return Response(serializer.data)
+ @convert_exceptions
def list(self, request, *args, **kwargs):
"""
List Collections that belong to Content Library
@@ -90,17 +87,15 @@ def list(self, request, *args, **kwargs):
# Check if user has permissions to view collections by checking if user
# has permission to view the Content Library they belong to
library_key = LibraryLocatorV2.from_string(lib_key_str)
- try:
- content_library = api.require_permission_for_library_key(
- library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY
- )
- except api.ContentLibraryNotFound as exc:
- raise Http404 from exc
+ content_library = api.require_permission_for_library_key(
+ library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY
+ )
collections = authoring_api.get_collections(content_library.learning_package.id)
serializer = self.get_serializer(collections, many=True)
return Response(serializer.data)
+ @convert_exceptions
def create(self, request, *args, **kwargs):
"""
Create a Collection that belongs to a Content Library
@@ -112,12 +107,9 @@ def create(self, request, *args, **kwargs):
# Check if user has permissions to create a collection in the Content Library
# by checking if user has permission to edit the Content Library
library_key = LibraryLocatorV2.from_string(lib_key_str)
- try:
- content_library = api.require_permission_for_library_key(
- library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY
- )
- except api.ContentLibraryNotFound as exc:
- raise Http404 from exc
+ content_library = api.require_permission_for_library_key(
+ library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY
+ )
create_serializer = ContentLibraryCollectionCreateOrUpdateSerializer(data=request.data)
create_serializer.is_valid(raise_exception=True)
@@ -139,6 +131,7 @@ def create(self, request, *args, **kwargs):
return Response(serializer.data)
+ @convert_exceptions
def partial_update(self, request, *args, **kwargs):
"""
Update a Collection that belongs to a Content Library
@@ -175,6 +168,7 @@ def partial_update(self, request, *args, **kwargs):
return Response(serializer.data)
+ @convert_exceptions
def destroy(self, request, *args, **kwargs):
"""
Deletes a Collection that belongs to a Content Library
diff --git a/openedx/core/djangoapps/content_libraries/utils.py b/openedx/core/djangoapps/content_libraries/utils.py
new file mode 100644
index 000000000000..da21d9057c92
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/utils.py
@@ -0,0 +1,48 @@
+""" Utils used for the content libraries. """
+
+from functools import wraps
+import logging
+
+from rest_framework.exceptions import NotFound, ValidationError
+
+from opaque_keys import InvalidKeyError
+
+from openedx.core.djangoapps.content_libraries import api
+
+
+log = logging.getLogger(__name__)
+
+
+def convert_exceptions(fn):
+ """
+ Catch any Content Library API exceptions that occur and convert them to
+ DRF exceptions so DRF will return an appropriate HTTP response
+ """
+
+ @wraps(fn)
+ def wrapped_fn(*args, **kwargs):
+ try:
+ return fn(*args, **kwargs)
+ except InvalidKeyError as exc:
+ log.exception(str(exc))
+ raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
+ except api.ContentLibraryNotFound:
+ log.exception("Content library not found")
+ raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
+ except api.ContentLibraryBlockNotFound:
+ log.exception("XBlock not found in content library")
+ raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
+ except api.ContentLibraryCollectionNotFound:
+ log.exception("Collection not found in content library")
+ raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
+
+ except api.LibraryBlockAlreadyExists as exc:
+ log.exception(str(exc))
+ raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from
+ except api.InvalidNameError as exc:
+ log.exception(str(exc))
+ raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from
+ except api.BlockLimitReachedError as exc:
+ log.exception(str(exc))
+ raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from
+ return wrapped_fn
diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py
index 820c30661c8b..11e5c9d23b2e 100644
--- a/openedx/core/djangoapps/content_libraries/views.py
+++ b/openedx/core/djangoapps/content_libraries/views.py
@@ -63,7 +63,6 @@
https://github.com/openedx/edx-platform/pull/30456
"""
-from functools import wraps
import itertools
import json
import logging
@@ -120,44 +119,13 @@
from openedx.core.djangoapps.xblock import api as xblock_api
from .models import ContentLibrary, LtiGradedResource, LtiProfile
+from .utils import convert_exceptions
User = get_user_model()
log = logging.getLogger(__name__)
-def convert_exceptions(fn):
- """
- Catch any Content Library API exceptions that occur and convert them to
- DRF exceptions so DRF will return an appropriate HTTP response
- """
-
- @wraps(fn)
- def wrapped_fn(*args, **kwargs):
- try:
- return fn(*args, **kwargs)
- except api.ContentLibraryNotFound:
- log.exception("Content library not found")
- raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
- except api.ContentLibraryBlockNotFound:
- log.exception("XBlock not found in content library")
- raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
- except api.ContentLibraryCollectionNotFound:
- log.exception("Collection not found in content library")
- raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
-
- except api.LibraryBlockAlreadyExists as exc:
- log.exception(str(exc))
- raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from
- except api.InvalidNameError as exc:
- log.exception(str(exc))
- raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from
- except api.BlockLimitReachedError as exc:
- log.exception(str(exc))
- raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from
- return wrapped_fn
-
-
class LibraryApiPaginationDocs:
"""
API docs for query params related to paginating ContentLibraryMetadata objects.
diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py
index da288750bb7d..e36c435e8ac0 100644
--- a/openedx/core/djangoapps/notifications/email/utils.py
+++ b/openedx/core/djangoapps/notifications/email/utils.py
@@ -19,6 +19,7 @@
)
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
+from openedx.core.djangoapps.notifications.events import notification_preference_unsubscribe_event
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
get_course_notification_preference_config_version
@@ -395,3 +396,4 @@ def get_updated_preference(pref):
if pref_value else EmailCadence.NEVER
type_prefs['email_cadence'] = cadence_value
preference.save()
+ notification_preference_unsubscribe_event(user)
diff --git a/openedx/core/djangoapps/notifications/events.py b/openedx/core/djangoapps/notifications/events.py
index fb50e134941b..91b12075a8a1 100644
--- a/openedx/core/djangoapps/notifications/events.py
+++ b/openedx/core/djangoapps/notifications/events.py
@@ -10,6 +10,7 @@
NOTIFICATION_APP_ALL_READ = 'edx.notifications.app_all_read'
NOTIFICATION_PREFERENCES_UPDATED = 'edx.notifications.preferences.updated'
NOTIFICATION_TRAY_OPENED = 'edx.notifications.tray_opened'
+NOTIFICATION_PREFERENCE_UNSUBSCRIBE = 'edx.notifications.preferences.one_click_unsubscribe'
def get_user_forums_roles(user, course_id):
@@ -155,3 +156,17 @@ def notification_tray_opened_event(user, unseen_notifications_count):
'unseen_notifications_count': unseen_notifications_count,
}
)
+
+
+def notification_preference_unsubscribe_event(user):
+ """
+ Emits an event when user clicks on one-click-unsubscribe url
+ """
+ event_data = {
+ 'user_id': user.id,
+ 'username': user.username,
+ 'event_type': 'email_digest_unsubscribe'
+ }
+ with tracker.get_tracker().context(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data):
+ tracker.emit(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data)
+ segment.track(user.id, NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data)
diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py
index 720b3ba96af7..103c5bf24f6c 100644
--- a/openedx/core/djangoapps/user_api/accounts/views.py
+++ b/openedx/core/djangoapps/user_api/accounts/views.py
@@ -21,7 +21,6 @@
from edx_ace import ace
from edx_ace.recipient import Recipient
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
-from openedx.core.lib.api.authentication import BearerAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser
from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit
@@ -50,9 +49,10 @@
get_retired_email_by_email,
get_retired_username_by_username,
is_email_retired,
- is_username_retired
+ is_username_retired,
)
from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_pending_name_change
+from lms.djangoapps.certificates.api import clear_pii_from_certificate_records_for_user
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments
@@ -64,9 +64,8 @@
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
-from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
+from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
-from lms.djangoapps.certificates.api import clear_pii_from_certificate_records_for_user
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
from ..message_types import DeletionNotificationMessage
@@ -75,7 +74,7 @@
RetirementStateError,
UserOrgTag,
UserRetirementPartnerReportingStatus,
- UserRetirementStatus
+ UserRetirementStatus,
)
from .api import get_account_settings, update_account_settings
from .permissions import (
@@ -83,13 +82,13 @@
CanDeactivateUser,
CanGetAccountInfo,
CanReplaceUsername,
- CanRetireUser
+ CanRetireUser,
)
from .serializers import (
PendingNameChangeSerializer,
UserRetirementPartnerReportSerializer,
UserRetirementStatusSerializer,
- UserSearchEmailSerializer
+ UserSearchEmailSerializer,
)
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator
@@ -97,16 +96,16 @@
log = logging.getLogger(__name__)
USER_PROFILE_PII = {
- 'name': '',
- 'meta': '',
- 'location': '',
- 'year_of_birth': None,
- 'gender': None,
- 'mailing_address': None,
- 'city': None,
- 'country': None,
- 'bio': None,
- 'phone_number': None,
+ "name": "",
+ "meta": "",
+ "location": "",
+ "year_of_birth": None,
+ "gender": None,
+ "mailing_address": None,
+ "city": None,
+ "country": None,
+ "bio": None,
+ "phone_number": None,
}
@@ -118,12 +117,9 @@ def request_requires_username(function):
@wraps(function)
def wrapper(self, request): # pylint: disable=missing-docstring
- username = request.data.get('username', None)
+ username = request.data.get("username", None)
if not username:
- return Response(
- status=status.HTTP_404_NOT_FOUND,
- data={'message': 'The user was not specified.'}
- )
+ return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "The user was not specified."})
return function(self, request)
return wrapper
@@ -131,177 +127,183 @@ def wrapper(self, request): # pylint: disable=missing-docstring
class AccountViewSet(ViewSet):
"""
- **Use Cases**
-
- Get or update a user's account information. Updates are supported
- only through merge patch.
-
- **Example Requests**
-
- GET /api/user/v1/me[?view=shared]
- GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared]
- GET /api/user/v1/accounts?email={user_email}
- GET /api/user/v1/accounts/{username}/[?view=shared]
-
- PATCH /api/user/v1/accounts/{username}/{"key":"value"} "application/merge-patch+json"
-
- POST /api/user/v1/accounts/search_emails "application/json"
-
- **Notes for PATCH requests to /accounts endpoints**
- * Requested updates to social_links are automatically merged with
- previously set links. That is, any newly introduced platforms are
- add to the previous list. Updated links to pre-existing platforms
- replace their values in the previous list. Pre-existing platforms
- can be removed by setting the value of the social_link to an
- empty string ("").
-
- **Response Values for GET requests to the /me endpoint**
- If the user is not logged in, an HTTP 401 "Not Authorized" response
- is returned.
-
- Otherwise, an HTTP 200 "OK" response is returned. The response
- contains the following value:
-
- * username: The username associated with the account.
-
- **Response Values for GET requests to /accounts endpoints**
-
- If no user exists with the specified username, or email, an HTTP 404 "Not
- Found" response is returned.
-
- If the user makes the request for her own account, or makes a
- request for another account and has "is_staff" access, an HTTP 200
- "OK" response is returned. The response contains the following
- values.
-
- * id: numerical lms user id in db
- * activation_key: auto-genrated activation key when signed up via email
- * bio: null or textual representation of user biographical
- information ("about me").
- * country: An ISO 3166 country code or null.
- * date_joined: The date the account was created, in the string
- format provided by datetime. For example, "2014-08-26T17:52:11Z".
- * last_login: The latest date the user logged in, in the string datetime format.
- * email: Email address for the user. New email addresses must be confirmed
- via a confirmation email, so GET does not reflect the change until
- the address has been confirmed.
- * secondary_email: A secondary email address for the user. Unlike
- the email field, GET will reflect the latest update to this field
- even if changes have yet to be confirmed.
- * verified_name: Approved verified name of the learner present in name affirmation plugin
- * gender: One of the following values:
-
- * null
- * "f"
- * "m"
- * "o"
-
- * goals: The textual representation of the user's goals, or null.
- * is_active: Boolean representation of whether a user is active.
- * language: The user's preferred language, or null.
- * language_proficiencies: Array of language preferences. Each
- preference is a JSON object with the following keys:
-
- * "code": string ISO 639-1 language code e.g. "en".
-
- * level_of_education: One of the following values:
-
- * "p": PhD or Doctorate
- * "m": Master's or professional degree
- * "b": Bachelor's degree
- * "a": Associate's degree
- * "hs": Secondary/high school
- * "jhs": Junior secondary/junior high/middle school
- * "el": Elementary/primary school
- * "none": None
- * "o": Other
- * null: The user did not enter a value
-
- * mailing_address: The textual representation of the user's mailing
- address, or null.
- * name: The full name of the user.
- * profile_image: A JSON representation of a user's profile image
- information. This representation has the following keys.
-
- * "has_image": Boolean indicating whether the user has a profile
- image.
- * "image_url_*": Absolute URL to various sizes of a user's
- profile image, where '*' matches a representation of the
- corresponding image size, such as 'small', 'medium', 'large',
- and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP.
-
- * requires_parental_consent: True if the user is a minor
- requiring parental consent.
- * social_links: Array of social links, sorted alphabetically by
- "platform". Each preference is a JSON object with the following keys:
-
- * "platform": A particular social platform, ex: 'facebook'
- * "social_link": The link to the user's profile on the particular platform
-
- * username: The username associated with the account.
- * year_of_birth: The year the user was born, as an integer, or null.
-
- * account_privacy: The user's setting for sharing her personal
- profile. Possible values are "all_users", "private", or "custom".
- If "custom", the user has selectively chosen a subset of shareable
- fields to make visible to others via the User Preferences API.
-
- * phone_number: The phone number for the user. String of numbers with
- an optional `+` sign at the start.
-
- * pending_name_change: If the user has an active name change request, returns the
- requested name.
-
- For all text fields, plain text instead of HTML is supported. The
- data is stored exactly as specified. Clients must HTML escape
- rendered values to avoid script injections.
-
- If a user who does not have "is_staff" access requests account
- information for a different user, only a subset of these fields is
- returned. The returned fields depend on the
- ACCOUNT_VISIBILITY_CONFIGURATION configuration setting and the
- visibility preference of the user for whom data is requested.
-
- Note that a user can view which account fields they have shared
- with other users by requesting their own username and providing
- the "view=shared" URL parameter.
-
- **Response Values for PATCH**
-
- Users can only modify their own account information. If the
- requesting user does not have the specified username and has staff
- access, the request returns an HTTP 403 "Forbidden" response. If
- the requesting user does not have staff access, the request
- returns an HTTP 404 "Not Found" response to avoid revealing the
- existence of the account.
-
- If no user exists with the specified username, an HTTP 404 "Not
- Found" response is returned.
-
- If "application/merge-patch+json" is not the specified content
- type, a 415 "Unsupported Media Type" response is returned.
-
- If validation errors prevent the update, this method returns a 400
- "Bad Request" response that includes a "field_errors" field that
- lists all error messages.
-
- If a failure at the time of the update prevents the update, a 400
- "Bad Request" error is returned. The JSON collection contains
- specific errors.
-
- If the update is successful, updated user account data is returned.
+ **Use Cases**
+
+ Get or update a user's account information. Updates are supported
+ only through merge patch.
+
+ **Example Requests**
+
+ GET /api/user/v1/me[?view=shared]
+ GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared]
+ GET /api/user/v1/accounts?email={user_email}
+ GET /api/user/v1/accounts/{username}/[?view=shared]
+
+ PATCH /api/user/v1/accounts/{username}/{"key":"value"} "application/merge-patch+json"
+
+ POST /api/user/v1/accounts/search_emails "application/json"
+
+ **Notes for PATCH requests to /accounts endpoints**
+ * Requested updates to social_links are automatically merged with
+ previously set links. That is, any newly introduced platforms are
+ add to the previous list. Updated links to pre-existing platforms
+ replace their values in the previous list. Pre-existing platforms
+ can be removed by setting the value of the social_link to an
+ empty string ("").
+
+ **Response Values for GET requests to the /me endpoint**
+ If the user is not logged in, an HTTP 401 "Not Authorized" response
+ is returned.
+
+ Otherwise, an HTTP 200 "OK" response is returned. The response
+ contains the following value:
+
+ * username: The username associated with the account.
+
+ **Response Values for GET requests to /accounts endpoints**
+
+ If no user exists with the specified username, or email, an HTTP 404 "Not
+ Found" response is returned.
+
+ If the user makes the request for her own account, or makes a
+ request for another account and has "is_staff" access, an HTTP 200
+ "OK" response is returned. The response contains the following
+ values.
+
+ * id: numerical lms user id in db
+ * activation_key: auto-genrated activation key when signed up via email
+ * bio: null or textual representation of user biographical
+ information ("about me").
+ * country: An ISO 3166 country code or null.
+ * date_joined: The date the account was created, in the string
+ format provided by datetime. For example, "2014-08-26T17:52:11Z".
+ * last_login: The latest date the user logged in, in the string datetime format.
+ * email: Email address for the user. New email addresses must be confirmed
+ via a confirmation email, so GET does not reflect the change until
+ the address has been confirmed.
+ * secondary_email: A secondary email address for the user. Unlike
+ the email field, GET will reflect the latest update to this field
+ even if changes have yet to be confirmed.
+ * verified_name: Approved verified name of the learner present in name affirmation plugin
+ * gender: One of the following values:
+
+ * null
+ * "f"
+ * "m"
+ * "o"
+
+ * goals: The textual representation of the user's goals, or null.
+ * is_active: Boolean representation of whether a user is active.
+ * language: The user's preferred language, or null.
+ * language_proficiencies: Array of language preferences. Each
+ preference is a JSON object with the following keys:
+
+ * "code": string ISO 639-1 language code e.g. "en".
+
+ * level_of_education: One of the following values:
+
+ * "p": PhD or Doctorate
+ * "m": Master's or professional degree
+ * "b": Bachelor's degree
+ * "a": Associate's degree
+ * "hs": Secondary/high school
+ * "jhs": Junior secondary/junior high/middle school
+ * "el": Elementary/primary school
+ * "none": None
+ * "o": Other
+ * null: The user did not enter a value
+
+ * mailing_address: The textual representation of the user's mailing
+ address, or null.
+ * name: The full name of the user.
+ * profile_image: A JSON representation of a user's profile image
+ information. This representation has the following keys.
+
+ * "has_image": Boolean indicating whether the user has a profile
+ image.
+ * "image_url_*": Absolute URL to various sizes of a user's
+ profile image, where '*' matches a representation of the
+ corresponding image size, such as 'small', 'medium', 'large',
+ and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP.
+
+ * requires_parental_consent: True if the user is a minor
+ requiring parental consent.
+ * social_links: Array of social links, sorted alphabetically by
+ "platform". Each preference is a JSON object with the following keys:
+
+ * "platform": A particular social platform, ex: 'facebook'
+ * "social_link": The link to the user's profile on the particular platform
+
+ * username: The username associated with the account.
+ * year_of_birth: The year the user was born, as an integer, or null.
+
+ * account_privacy: The user's setting for sharing her personal
+ profile. Possible values are "all_users", "private", or "custom".
+ If "custom", the user has selectively chosen a subset of shareable
+ fields to make visible to others via the User Preferences API.
+
+ * phone_number: The phone number for the user. String of numbers with
+ an optional `+` sign at the start.
+
+ * pending_name_change: If the user has an active name change request, returns the
+ requested name.
+
+ For all text fields, plain text instead of HTML is supported. The
+ data is stored exactly as specified. Clients must HTML escape
+ rendered values to avoid script injections.
+
+ If a user who does not have "is_staff" access requests account
+ information for a different user, only a subset of these fields is
+ returned. The returned fields depend on the
+ ACCOUNT_VISIBILITY_CONFIGURATION configuration setting and the
+ visibility preference of the user for whom data is requested.
+
+ Note that a user can view which account fields they have shared
+ with other users by requesting their own username and providing
+ the "view=shared" URL parameter.
+
+ **Response Values for PATCH**
+
+ Users can only modify their own account information. If the
+ requesting user does not have the specified username and has staff
+ access, the request returns an HTTP 403 "Forbidden" response. If
+ the requesting user does not have staff access, the request
+ returns an HTTP 404 "Not Found" response to avoid revealing the
+ existence of the account.
+
+ If no user exists with the specified username, an HTTP 404 "Not
+ Found" response is returned.
+
+ If "application/merge-patch+json" is not the specified content
+ type, a 415 "Unsupported Media Type" response is returned.
+
+ If validation errors prevent the update, this method returns a 400
+ "Bad Request" response that includes a "field_errors" field that
+ lists all error messages.
+
+ If a failure at the time of the update prevents the update, a 400
+ "Bad Request" error is returned. The JSON collection contains
+ specific errors.
+
+ If the update is successful, updated user account data is returned.
"""
+
authentication_classes = (
- JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser
+ JwtAuthentication,
+ BearerAuthenticationAllowInactiveUser,
+ SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.IsAuthenticated, CanGetAccountInfo)
- parser_classes = (JSONParser, MergePatchParser,)
+ parser_classes = (
+ JSONParser,
+ MergePatchParser,
+ )
def get(self, request):
"""
GET /api/user/v1/me
"""
- return Response({'username': request.user.username})
+ return Response({"username": request.user.username})
def list(self, request):
"""
@@ -309,13 +311,13 @@ def list(self, request):
GET /api/user/v1/accounts?email={user_email} (Staff Only)
GET /api/user/v1/accounts?lms_user_id={lms_user_id} (Staff Only)
"""
- usernames = request.GET.get('username')
- user_email = request.GET.get('email')
- lms_user_id = request.GET.get('lms_user_id')
+ usernames = request.GET.get("username")
+ user_email = request.GET.get("email")
+ lms_user_id = request.GET.get("lms_user_id")
search_usernames = []
if usernames:
- search_usernames = usernames.strip(',').split(',')
+ search_usernames = usernames.strip(",").split(",")
elif user_email:
if is_email_retired(user_email):
can_cancel_retirement = True
@@ -325,22 +327,20 @@ def list(self, request):
retirement_status = UserRetirementStatus.objects.get(
created__gt=earliest_datetime,
created__lt=datetime.datetime.now(pytz.UTC),
- original_email=user_email
+ original_email=user_email,
)
retirement_id = retirement_status.id
except UserRetirementStatus.DoesNotExist:
can_cancel_retirement = False
context = {
- 'error_msg': accounts.RETIRED_EMAIL_MSG,
- 'can_cancel_retirement': can_cancel_retirement,
- 'retirement_id': retirement_id
+ "error_msg": accounts.RETIRED_EMAIL_MSG,
+ "can_cancel_retirement": can_cancel_retirement,
+ "retirement_id": retirement_id,
}
- return Response(
- context, status=status.HTTP_404_NOT_FOUND
- )
- user_email = user_email.strip('')
+ return Response(context, status=status.HTTP_404_NOT_FOUND)
+ user_email = user_email.strip("")
try:
user = User.objects.get(email=user_email)
except (UserNotFound, User.DoesNotExist):
@@ -355,9 +355,7 @@ def list(self, request):
return Response(status=status.HTTP_400_BAD_REQUEST)
search_usernames = [user.username]
try:
- account_settings = get_account_settings(
- request, search_usernames, view=request.query_params.get('view')
- )
+ account_settings = get_account_settings(request, search_usernames, view=request.query_params.get("view"))
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
@@ -386,23 +384,15 @@ def search_emails(self, request):
"""
if not request.user.is_staff:
return Response(
- {
- 'developer_message': 'not_found',
- 'user_message': 'Not Found'
- },
- status=status.HTTP_404_NOT_FOUND
+ {"developer_message": "not_found", "user_message": "Not Found"}, status=status.HTTP_404_NOT_FOUND
)
try:
- user_emails = request.data['emails']
+ user_emails = request.data["emails"]
except KeyError as error:
- error_message = f'{error} field is required'
+ error_message = f"{error} field is required"
return Response(
- {
- 'developer_message': error_message,
- 'user_message': error_message
- },
- status=status.HTTP_400_BAD_REQUEST
+ {"developer_message": error_message, "user_message": error_message}, status=status.HTTP_400_BAD_REQUEST
)
users = User.objects.filter(email__in=user_emails)
data = UserSearchEmailSerializer(users, many=True).data
@@ -413,8 +403,7 @@ def retrieve(self, request, username):
GET /api/user/v1/accounts/{username}/
"""
try:
- account_settings = get_account_settings(
- request, [username], view=request.query_params.get('view'))
+ account_settings = get_account_settings(request, [username], view=request.query_params.get("view"))
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
@@ -443,11 +432,8 @@ def partial_update(self, request, username):
return Response({"field_errors": err.field_errors}, status=status.HTTP_400_BAD_REQUEST)
except AccountUpdateError as err:
return Response(
- {
- "developer_message": err.developer_message,
- "user_message": err.user_message
- },
- status=status.HTTP_400_BAD_REQUEST
+ {"developer_message": err.developer_message, "user_message": err.user_message},
+ status=status.HTTP_400_BAD_REQUEST,
)
return Response(account_settings)
@@ -457,6 +443,7 @@ class NameChangeView(ViewSet):
"""
Viewset to manage profile name change requests.
"""
+
permission_classes = (permissions.IsAuthenticated,)
def create(self, request):
@@ -472,10 +459,10 @@ def create(self, request):
}
"""
user = request.user
- new_name = request.data.get('name', None)
- rationale = f'Name change requested through account API by {user.username}'
+ new_name = request.data.get("name", None)
+ rationale = f"Name change requested through account API by {user.username}"
- serializer = PendingNameChangeSerializer(data={'new_name': new_name})
+ serializer = PendingNameChangeSerializer(data={"new_name": new_name})
if serializer.is_valid():
pending_name_change = do_name_change_request(user, new_name, rationale)[0]
@@ -483,8 +470,8 @@ def create(self, request):
return Response(status=status.HTTP_201_CREATED)
else:
return Response(
- {'new_name': 'The profile name given was identical to the current name.'},
- status=status.HTTP_400_BAD_REQUEST
+ {"new_name": "The profile name given was identical to the current name."},
+ status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_400_BAD_REQUEST, data=serializer.errors)
@@ -514,6 +501,7 @@ class AccountDeactivationView(APIView):
Account deactivation viewset. Currently only supports POST requests.
Only admins can deactivate accounts.
"""
+
permission_classes = (permissions.IsAuthenticated, CanDeactivateUser)
def post(self, request, username):
@@ -559,6 +547,7 @@ class DeactivateLogoutView(APIView):
- Log the user out
- Create a row in the retirement table for that user
"""
+
# BearerAuthentication is added here to support account deletion
# from the mobile app until it moves to JWT Auth.
# See mobile roadmap issue https://github.com/openedx/edx-platform/issues/33307.
@@ -575,7 +564,7 @@ def post(self, request):
# Ensure the account deletion is not disable
enable_account_deletion = configuration_helpers.get_value(
- 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False)
+ "ENABLE_ACCOUNT_DELETION", settings.FEATURES.get("ENABLE_ACCOUNT_DELETION", False)
)
if not enable_account_deletion:
@@ -595,11 +584,9 @@ def post(self, request):
# Send notification email to user
site = Site.objects.get_current()
notification_context = get_base_template_context(site)
- notification_context.update({'full_name': request.user.profile.name})
+ notification_context.update({"full_name": request.user.profile.name})
language_code = request.user.preferences.model.get_value(
- request.user,
- LANGUAGE_KEY,
- default=settings.LANGUAGE_CODE
+ request.user, LANGUAGE_KEY, default=settings.LANGUAGE_CODE
)
notification = DeletionNotificationMessage().personalize(
recipient=Recipient(lms_user_id=0, email_address=user_email),
@@ -608,22 +595,20 @@ def post(self, request):
)
ace.send(notification)
except Exception as exc:
- log.exception('Error sending out deletion notification email')
+ log.exception("Error sending out deletion notification email")
raise exc
# Log the user out.
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
except KeyError:
- log.exception(f'Username not specified {request.user}')
- return Response('Username not specified.', status=status.HTTP_404_NOT_FOUND)
+ log.exception(f"Username not specified {request.user}")
+ return Response("Username not specified.", status=status.HTTP_404_NOT_FOUND)
except user_model.DoesNotExist:
log.exception(f'The user "{request.user.username}" does not exist.')
- return Response(
- f'The user "{request.user.username}" does not exist.', status=status.HTTP_404_NOT_FOUND
- )
+ return Response(f'The user "{request.user.username}" does not exist.', status=status.HTTP_404_NOT_FOUND)
except Exception as exc: # pylint: disable=broad-except
- log.exception(f'500 error deactivating account {exc}')
+ log.exception(f"500 error deactivating account {exc}")
return Response(str(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _verify_user_password(self, request):
@@ -636,7 +621,7 @@ def _verify_user_password(self, request):
"""
try:
self._check_excessive_login_attempts(request.user)
- user = authenticate(username=request.user.username, password=request.POST['password'], request=request)
+ user = authenticate(username=request.user.username, password=request.POST["password"], request=request)
if user:
if LoginFailures.is_feature_enabled():
LoginFailures.clear_lockout_counter(user)
@@ -644,9 +629,7 @@ def _verify_user_password(self, request):
else:
self._handle_failed_authentication(request.user)
except AuthFailedError as err:
- log.exception(
- f"The user password to deactivate was incorrect. {request.user.username}"
- )
+ log.exception(f"The user password to deactivate was incorrect. {request.user.username}")
return Response(str(err), status=status.HTTP_403_FORBIDDEN)
except Exception as err: # pylint: disable=broad-except
return Response(f"Could not verify user password: {err}", status=status.HTTP_400_BAD_REQUEST)
@@ -657,8 +640,9 @@ def _check_excessive_login_attempts(self, user):
"""
if user and LoginFailures.is_feature_enabled():
if LoginFailures.is_user_locked_out(user):
- raise AuthFailedError(_('This account has been temporarily locked due '
- 'to excessive login failures. Try again later.'))
+ raise AuthFailedError(
+ _("This account has been temporarily locked due to excessive login failures. Try again later.")
+ )
def _handle_failed_authentication(self, user):
"""
@@ -667,7 +651,7 @@ def _handle_failed_authentication(self, user):
if user and LoginFailures.is_feature_enabled():
LoginFailures.increment_lockout_counter(user)
- raise AuthFailedError(_('Email or password is incorrect.'))
+ raise AuthFailedError(_("Email or password is incorrect."))
def _set_unusable_password(user):
@@ -684,15 +668,19 @@ class AccountRetirementPartnerReportView(ViewSet):
Provides API endpoints for managing partner reporting of retired
users.
"""
- DELETION_COMPLETED_KEY = 'deletion_completed'
- ORGS_CONFIG_KEY = 'orgs_config'
- ORGS_CONFIG_ORG_KEY = 'org'
- ORGS_CONFIG_FIELD_HEADINGS_KEY = 'field_headings'
- ORIGINAL_EMAIL_KEY = 'original_email'
- ORIGINAL_NAME_KEY = 'original_name'
- STUDENT_ID_KEY = 'student_id'
-
- permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
+
+ DELETION_COMPLETED_KEY = "deletion_completed"
+ ORGS_CONFIG_KEY = "orgs_config"
+ ORGS_CONFIG_ORG_KEY = "org"
+ ORGS_CONFIG_FIELD_HEADINGS_KEY = "field_headings"
+ ORIGINAL_EMAIL_KEY = "original_email"
+ ORIGINAL_NAME_KEY = "original_name"
+ STUDENT_ID_KEY = "student_id"
+
+ permission_classes = (
+ permissions.IsAuthenticated,
+ CanRetireUser,
+ )
parser_classes = (JSONParser,)
serializer_class = UserRetirementStatusSerializer
@@ -706,7 +694,7 @@ def _get_orgs_for_user(user):
org = enrollment.course_id.org
# Org can conceivably be blank or this bogus default value
- if org and org != 'outdated_entry':
+ if org and org != "outdated_entry":
orgs.add(org)
return orgs
@@ -718,9 +706,9 @@ def retirement_partner_report(self, request): # pylint: disable=unused-argument
that are not already being processed and updates their status
to indicate they are currently being processed.
"""
- retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter(
- is_being_processed=False
- ).order_by('id')
+ retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=False).order_by(
+ "id"
+ )
retirements = []
for retirement_status in retirement_statuses:
@@ -737,12 +725,12 @@ def _get_retirement_for_partner_report(self, retirement_status):
Get the retirement for this retirement_status. The retirement info will be included in the partner report.
"""
retirement = {
- 'user_id': retirement_status.user.pk,
- 'original_username': retirement_status.original_username,
+ "user_id": retirement_status.user.pk,
+ "original_username": retirement_status.original_username,
AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY: retirement_status.original_email,
AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY: retirement_status.original_name,
- 'orgs': self._get_orgs_for_user(retirement_status.user),
- 'created': retirement_status.created,
+ "orgs": self._get_orgs_for_user(retirement_status.user),
+ "created": retirement_status.created,
}
return retirement
@@ -761,7 +749,7 @@ def retirement_partner_status_create(self, request):
Creates a UserRetirementPartnerReportingStatus object for the given user
as part of the retirement pipeline.
"""
- username = request.data['username']
+ username = request.data["username"]
try:
retirement = UserRetirementStatus.get_retirement_for_retirement_action(username)
@@ -771,10 +759,10 @@ def retirement_partner_status_create(self, request):
UserRetirementPartnerReportingStatus.objects.get_or_create(
user=retirement.user,
defaults={
- 'original_username': retirement.original_username,
- 'original_email': retirement.original_email,
- 'original_name': retirement.original_name
- }
+ "original_username": retirement.original_username,
+ "original_email": retirement.original_email,
+ "original_name": retirement.original_name,
+ },
)
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -790,14 +778,13 @@ def retirement_partner_cleanup(self, request):
Deletes UserRetirementPartnerReportingStatus objects for a list of users
that have been reported on.
"""
- usernames = [u['original_username'] for u in request.data]
+ usernames = [u["original_username"] for u in request.data]
if not usernames:
- return Response('No original_usernames given.', status=status.HTTP_400_BAD_REQUEST)
+ return Response("No original_usernames given.", status=status.HTTP_400_BAD_REQUEST)
retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter(
- is_being_processed=True,
- original_username__in=usernames
+ is_being_processed=True, original_username__in=usernames
)
# Need to de-dupe usernames that differ only by case to find the exact right match
@@ -809,15 +796,15 @@ def retirement_partner_cleanup(self, request):
# to disambiguate them in Python, which will respect case in the comparison.
if len(usernames) != len(retirement_statuses_clean):
return Response(
- '{} original_usernames given, {} found!\n'
- 'Given usernames:\n{}\n'
- 'Found UserRetirementReportingStatuses:\n{}'.format(
+ "{} original_usernames given, {} found!\n"
+ "Given usernames:\n{}\n"
+ "Found UserRetirementReportingStatuses:\n{}".format(
len(usernames),
len(retirement_statuses_clean),
usernames,
- ', '.join([rs.original_username for rs in retirement_statuses_clean])
+ ", ".join([rs.original_username for rs in retirement_statuses_clean]),
),
- status=status.HTTP_400_BAD_REQUEST
+ status=status.HTTP_400_BAD_REQUEST,
)
retirement_statuses.delete()
@@ -829,7 +816,11 @@ class CancelAccountRetirementStatusView(ViewSet):
"""
Provides API endpoints for canceling retirement process for a user's account.
"""
- permission_classes = (permissions.IsAuthenticated, CanCancelUserRetirement,)
+
+ permission_classes = (
+ permissions.IsAuthenticated,
+ CanCancelUserRetirement,
+ )
def cancel_retirement(self, request):
"""
@@ -839,26 +830,23 @@ def cancel_retirement(self, request):
This also handles the top level error handling, and permissions.
"""
try:
- retirement_id = request.data['retirement_id']
+ retirement_id = request.data["retirement_id"]
except KeyError:
- return Response(
- status=status.HTTP_400_BAD_REQUEST,
- data={'message': 'retirement_id must be specified.'}
- )
+ return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": "retirement_id must be specified."})
try:
retirement = UserRetirementStatus.objects.get(id=retirement_id)
except UserRetirementStatus.DoesNotExist:
- return Response(data={"message": 'Retirement does not exist!'}, status=status.HTTP_400_BAD_REQUEST)
+ return Response(data={"message": "Retirement does not exist!"}, status=status.HTTP_400_BAD_REQUEST)
- if retirement.current_state.state_name != 'PENDING':
+ if retirement.current_state.state_name != "PENDING":
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"message": f"Retirement requests can only be cancelled for users in the PENDING state. Current "
- f"request state for '{retirement.original_username}': "
- f"{retirement.current_state.state_name}"
- }
+ f"request state for '{retirement.original_username}': "
+ f"{retirement.current_state.state_name}"
+ },
)
handle_retirement_cancellation(retirement)
@@ -870,7 +858,11 @@ class AccountRetirementStatusView(ViewSet):
"""
Provides API endpoints for managing the user retirement process.
"""
- permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
+
+ permission_classes = (
+ permissions.IsAuthenticated,
+ CanRetireUser,
+ )
parser_classes = (JSONParser,)
serializer_class = UserRetirementStatusSerializer
@@ -883,37 +875,35 @@ def retirement_queue(self, request):
created in the retirement queue at least `cool_off_days` ago.
"""
try:
- cool_off_days = int(request.GET['cool_off_days'])
+ cool_off_days = int(request.GET["cool_off_days"])
if cool_off_days < 0:
- raise RetirementStateError('Invalid argument for cool_off_days, must be greater than 0.')
+ raise RetirementStateError("Invalid argument for cool_off_days, must be greater than 0.")
- states = request.GET.getlist('states')
+ states = request.GET.getlist("states")
if not states:
raise RetirementStateError('Param "states" required with at least one state.')
state_objs = RetirementState.objects.filter(state_name__in=states)
if state_objs.count() != len(states):
found = [s.state_name for s in state_objs]
- raise RetirementStateError(f'Unknown state. Requested: {states} Found: {found}')
+ raise RetirementStateError(f"Unknown state. Requested: {states} Found: {found}")
- limit = request.GET.get('limit')
+ limit = request.GET.get("limit")
if limit:
try:
limit_count = int(limit)
except ValueError:
return Response(
- f'Limit could not be parsed: {limit}, please ensure this is an integer',
- status=status.HTTP_400_BAD_REQUEST
+ f"Limit could not be parsed: {limit}, please ensure this is an integer",
+ status=status.HTTP_400_BAD_REQUEST,
)
earliest_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=cool_off_days)
- retirements = UserRetirementStatus.objects.select_related(
- 'user', 'current_state', 'last_state'
- ).filter(
- current_state__in=state_objs, created__lt=earliest_datetime
- ).order_by(
- 'id'
+ retirements = (
+ UserRetirementStatus.objects.select_related("user", "current_state", "last_state")
+ .filter(current_state__in=state_objs, created__lt=earliest_datetime)
+ .order_by("id")
)
if limit:
retirements = retirements[:limit_count]
@@ -921,10 +911,9 @@ def retirement_queue(self, request):
return Response(serializer.data)
# This should only occur on the int() conversion of cool_off_days at this point
except ValueError:
- return Response('Invalid cool_off_days, should be integer.', status=status.HTTP_400_BAD_REQUEST)
+ return Response("Invalid cool_off_days, should be integer.", status=status.HTTP_400_BAD_REQUEST)
except KeyError as exc:
- return Response(f'Missing required parameter: {str(exc)}',
- status=status.HTTP_400_BAD_REQUEST)
+ return Response(f"Missing required parameter: {str(exc)}", status=status.HTTP_400_BAD_REQUEST)
except RetirementStateError as exc:
return Response(str(exc), status=status.HTTP_400_BAD_REQUEST)
@@ -939,36 +928,33 @@ def retirements_by_status_and_date(self, request):
so to get one day you would set both dates to that day.
"""
try:
- start_date = datetime.datetime.strptime(request.GET['start_date'], '%Y-%m-%d').replace(tzinfo=pytz.UTC)
- end_date = datetime.datetime.strptime(request.GET['end_date'], '%Y-%m-%d').replace(tzinfo=pytz.UTC)
+ start_date = datetime.datetime.strptime(request.GET["start_date"], "%Y-%m-%d").replace(tzinfo=pytz.UTC)
+ end_date = datetime.datetime.strptime(request.GET["end_date"], "%Y-%m-%d").replace(tzinfo=pytz.UTC)
now = datetime.datetime.now(pytz.UTC)
if start_date > now or end_date > now or start_date > end_date:
- raise RetirementStateError('Dates must be today or earlier, and start must be earlier than end.')
+ raise RetirementStateError("Dates must be today or earlier, and start must be earlier than end.")
# Add a day to make sure we get all the way to 23:59:59.999, this is compared "lt" in the query
# not "lte".
end_date += datetime.timedelta(days=1)
- state = request.GET['state']
+ state = request.GET["state"]
state_obj = RetirementState.objects.get(state_name=state)
- retirements = UserRetirementStatus.objects.select_related(
- 'user', 'current_state', 'last_state', 'user__profile'
- ).filter(
- current_state=state_obj, created__lt=end_date, created__gte=start_date
- ).order_by(
- 'id'
+ retirements = (
+ UserRetirementStatus.objects.select_related("user", "current_state", "last_state", "user__profile")
+ .filter(current_state=state_obj, created__lt=end_date, created__gte=start_date)
+ .order_by("id")
)
serializer = UserRetirementStatusSerializer(retirements, many=True)
return Response(serializer.data)
# This should only occur on the datetime conversion of the start / end dates.
except ValueError as exc:
- return Response(f'Invalid start or end date: {str(exc)}', status=status.HTTP_400_BAD_REQUEST)
+ return Response(f"Invalid start or end date: {str(exc)}", status=status.HTTP_400_BAD_REQUEST)
except KeyError as exc:
- return Response(f'Missing required parameter: {str(exc)}',
- status=status.HTTP_400_BAD_REQUEST)
+ return Response(f"Missing required parameter: {str(exc)}", status=status.HTTP_400_BAD_REQUEST)
except RetirementState.DoesNotExist:
- return Response('Unknown retirement state.', status=status.HTTP_400_BAD_REQUEST)
+ return Response("Unknown retirement state.", status=status.HTTP_400_BAD_REQUEST)
except RetirementStateError as exc:
return Response(str(exc), status=status.HTTP_400_BAD_REQUEST)
@@ -980,9 +966,9 @@ def retrieve(self, request, username): # pylint: disable=unused-argument
"""
try:
user = get_potentially_retired_user_by_username(username)
- retirement = UserRetirementStatus.objects.select_related(
- 'user', 'current_state', 'last_state'
- ).get(user=user)
+ retirement = UserRetirementStatus.objects.select_related("user", "current_state", "last_state").get(
+ user=user
+ )
serializer = UserRetirementStatusSerializer(instance=retirement)
return Response(serializer.data)
except (UserRetirementStatus.DoesNotExist, User.DoesNotExist):
@@ -1008,7 +994,7 @@ def partial_update(self, request):
The content type for this request is 'application/json'.
"""
try:
- username = request.data['username']
+ username = request.data["username"]
retirements = UserRetirementStatus.objects.filter(original_username=username)
# During a narrow window learners were able to re-use a username that had been retired if
@@ -1049,20 +1035,19 @@ def cleanup(self, request):
Deletes a batch of retirement requests by username.
"""
try:
- usernames = request.data['usernames']
+ usernames = request.data["usernames"]
if not isinstance(usernames, list):
- raise TypeError('Usernames should be an array.')
+ raise TypeError("Usernames should be an array.")
- complete_state = RetirementState.objects.get(state_name='COMPLETE')
+ complete_state = RetirementState.objects.get(state_name="COMPLETE")
retirements = UserRetirementStatus.objects.filter(
- original_username__in=usernames,
- current_state=complete_state
+ original_username__in=usernames, current_state=complete_state
)
# Sanity check that they're all valid usernames in the right state
if len(usernames) != len(retirements):
- raise UserRetirementStatus.DoesNotExist('Not all usernames exist in the COMPLETE state.')
+ raise UserRetirementStatus.DoesNotExist("Not all usernames exist in the COMPLETE state.")
retirements.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1076,7 +1061,11 @@ class LMSAccountRetirementView(ViewSet):
"""
Provides an API endpoint for retiring a user in the LMS.
"""
- permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
+
+ permission_classes = (
+ permissions.IsAuthenticated,
+ CanRetireUser,
+ )
parser_classes = (JSONParser,)
@request_requires_username
@@ -1093,13 +1082,13 @@ def post(self, request):
Retires the user with the given username in the LMS.
"""
- username = request.data['username']
+ username = request.data["username"]
try:
retirement = UserRetirementStatus.get_retirement_for_retirement_action(username)
RevisionPluginRevision.retire_user(retirement.user)
ArticleRevision.retire_user(retirement.user)
- PendingNameChange.delete_by_user_value(retirement.user, field='user')
+ PendingNameChange.delete_by_user_value(retirement.user, field="user")
ManualEnrollmentAudit.retire_manual_enrollments(retirement.user, retirement.retired_email)
CreditRequest.retire_user(retirement)
@@ -1115,7 +1104,7 @@ def post(self, request):
sender=self.__class__,
email=retirement.original_email,
new_email=retirement.retired_email,
- user=retirement.user
+ user=retirement.user,
)
except UserRetirementStatus.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
@@ -1131,7 +1120,11 @@ class AccountRetirementView(ViewSet):
"""
Provides API endpoint for retiring a user.
"""
- permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
+
+ permission_classes = (
+ permissions.IsAuthenticated,
+ CanRetireUser,
+ )
parser_classes = (JSONParser,)
@request_requires_username
@@ -1148,7 +1141,7 @@ def post(self, request):
Retires the user with the given username. This includes retiring this username, the associated email address,
and any other PII associated with this user.
"""
- username = request.data['username']
+ username = request.data["username"]
try:
retirement_status = UserRetirementStatus.get_retirement_for_retirement_action(username)
@@ -1173,18 +1166,18 @@ def post(self, request):
self.retire_entitlement_support_detail(user)
# Retire misc. models that may contain PII of this user
- PendingEmailChange.delete_by_user_value(user, field='user')
- UserOrgTag.delete_by_user_value(user, field='user')
+ PendingEmailChange.delete_by_user_value(user, field="user")
+ UserOrgTag.delete_by_user_value(user, field="user")
# Retire any objects linked to the user via their original email
- CourseEnrollmentAllowed.delete_by_user_value(original_email, field='email')
- UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field='email')
+ CourseEnrollmentAllowed.delete_by_user_value(original_email, field="email")
+ UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field="email")
# This signal allows code in higher points of LMS to retire the user as necessary
USER_RETIRE_LMS_CRITICAL.send(sender=self.__class__, user=user)
- user.first_name = ''
- user.last_name = ''
+ user.first_name = ""
+ user.last_name = ""
user.is_active = False
user.username = retired_username
user.save()
@@ -1227,24 +1220,20 @@ def retire_users_data_sharing_consent(username, retired_username):
@staticmethod
def retire_sapsf_data_transmission(user): # lint-amnesty, pylint: disable=missing-function-docstring
for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id):
- for enrollment in EnterpriseCourseEnrollment.objects.filter(
- enterprise_customer_user=ent_user
- ):
+ for enrollment in EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user=ent_user):
audits = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
enterprise_course_enrollment_id=enrollment.id
)
- audits.update(sapsf_user_id='')
+ audits.update(sapsf_user_id="")
@staticmethod
def retire_degreed_data_transmission(user): # lint-amnesty, pylint: disable=missing-function-docstring
for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id):
- for enrollment in EnterpriseCourseEnrollment.objects.filter(
- enterprise_customer_user=ent_user
- ):
+ for enrollment in EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user=ent_user):
audits = DegreedLearnerDataTransmissionAudit.objects.filter(
enterprise_course_enrollment_id=enrollment.id
)
- audits.update(degreed_user_email='')
+ audits.update(degreed_user_email="")
@staticmethod
def retire_user_from_pending_enterprise_customer_user(user, retired_email):
@@ -1256,7 +1245,7 @@ def retire_entitlement_support_detail(user):
Updates all CourseEntitleSupportDetail records for the given user to have an empty ``comments`` field.
"""
for entitlement in CourseEntitlement.objects.filter(user_id=user.id):
- entitlement.courseentitlementsupportdetail_set.all().update(comments='')
+ entitlement.courseentitlementsupportdetail_set.all().update(comments="")
@staticmethod
def clear_pii_from_certificate_records(user):
@@ -1279,6 +1268,7 @@ class UsernameReplacementView(APIView):
This API will be called first, before calling the APIs in other services as this
one handles the checks on the usernames provided.
"""
+
permission_classes = (permissions.IsAuthenticated, CanReplaceUsername)
def post(self, request):
@@ -1320,16 +1310,16 @@ def post(self, request):
# (model_name, column_name)
MODELS_WITH_USERNAME = (
- ('auth.user', 'username'),
- ('consent.DataSharingConsent', 'username'),
- ('consent.HistoricalDataSharingConsent', 'username'),
- ('credit.CreditEligibility', 'username'),
- ('credit.CreditRequest', 'username'),
- ('credit.CreditRequirementStatus', 'username'),
- ('user_api.UserRetirementPartnerReportingStatus', 'original_username'),
- ('user_api.UserRetirementStatus', 'original_username')
+ ("auth.user", "username"),
+ ("consent.DataSharingConsent", "username"),
+ ("consent.HistoricalDataSharingConsent", "username"),
+ ("credit.CreditEligibility", "username"),
+ ("credit.CreditRequest", "username"),
+ ("credit.CreditRequirementStatus", "username"),
+ ("user_api.UserRetirementPartnerReportingStatus", "original_username"),
+ ("user_api.UserRetirementStatus", "original_username"),
)
- UNIQUE_SUFFIX_LENGTH = getattr(settings, 'SOCIAL_AUTH_UUID_LENGTH', 4)
+ UNIQUE_SUFFIX_LENGTH = getattr(settings, "SOCIAL_AUTH_UUID_LENGTH", 4)
username_mappings = request.data.get("username_mappings")
replacement_locations = self._load_models(MODELS_WITH_USERNAME)
@@ -1344,9 +1334,7 @@ def post(self, request):
desired_username = list(username_pair.values())[0]
new_username = self._generate_unique_username(desired_username, suffix_length=UNIQUE_SUFFIX_LENGTH)
successfully_replaced = self._replace_username_for_all_models(
- current_username,
- new_username,
- replacement_locations
+ current_username, new_username, replacement_locations
)
if successfully_replaced:
successful_replacements.append({current_username: new_username})
@@ -1354,14 +1342,11 @@ def post(self, request):
failed_replacements.append({current_username: new_username})
return Response(
status=status.HTTP_200_OK,
- data={
- "successful_replacements": successful_replacements,
- "failed_replacements": failed_replacements
- }
+ data={"successful_replacements": successful_replacements, "failed_replacements": failed_replacements},
)
def _load_models(self, models_with_fields):
- """ Takes tuples that contain a model path and returns the list with a loaded version of the model """
+ """Takes tuples that contain a model path and returns the list with a loaded version of the model"""
try:
replacement_locations = [(apps.get_model(model), column) for (model, column) in models_with_fields]
except LookupError:
@@ -1370,7 +1355,7 @@ def _load_models(self, models_with_fields):
return replacement_locations
def _has_valid_schema(self, post_data):
- """ Verifies the data is a list of objects with a single key:value pair """
+ """Verifies the data is a list of objects with a single key:value pair"""
if not isinstance(post_data, list):
return False
for obj in post_data:
@@ -1389,7 +1374,7 @@ def _generate_unique_username(self, desired_username, suffix_length=4):
while True:
if User.objects.filter(username=new_username).exists():
# adding a dash between user-supplied and system-generated values to avoid weird combinations
- new_username = desired_username + '-' + username_suffix_generator(suffix_length)
+ new_username = desired_username + "-" + username_suffix_generator(suffix_length)
else:
break
return new_username
@@ -1404,10 +1389,8 @@ def _replace_username_for_all_models(self, current_username, new_username, repla
try:
with transaction.atomic():
num_rows_changed = 0
- for (model, column) in replacement_locations:
- num_rows_changed += model.objects.filter(
- **{column: current_username}
- ).update(
+ for model, column in replacement_locations:
+ num_rows_changed += model.objects.filter(**{column: current_username}).update(
**{column: new_username}
)
except Exception as exc: # pylint: disable=broad-except
@@ -1416,7 +1399,7 @@ def _replace_username_for_all_models(self, current_username, new_username, repla
current_username,
new_username,
model.__class__.__name__, # Retrieves the model name that it failed on
- exc
+ exc,
)
return False
if num_rows_changed == 0:
diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py
index 17ca1d4f2a91..ab57687d2cd4 100644
--- a/openedx/core/djangoapps/user_authn/views/register.py
+++ b/openedx/core/djangoapps/user_authn/views/register.py
@@ -390,7 +390,9 @@ def _track_user_registration(user, profile, params, third_party_provider, regist
'is_year_of_birth_selected': bool(profile.year_of_birth),
'is_education_selected': bool(profile.level_of_education_display),
'is_goal_set': bool(profile.goals),
- 'total_registration_time': round(float(params.get('totalRegistrationTime', '0'))),
+ 'total_registration_time': round(
+ float(params.get('total_registration_time') or params.get('totalRegistrationTime') or 0)
+ ),
'activation_key': registration.activation_key if registration else None,
'host': params.get('host', ''),
'app_name': params.get('app_name', ''),
diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py
index 8c2d16839a67..9972e7463b23 100644
--- a/openedx/core/djangoapps/xblock/rest_api/views.py
+++ b/openedx/core/djangoapps/xblock/rest_api/views.py
@@ -226,7 +226,9 @@ def post(self, request, usage_key_str):
old_metadata = block.get_explicitly_set_fields_by_scope(Scope.settings)
old_content = block.get_explicitly_set_fields_by_scope(Scope.content)
- block.data = data
+ # only update data if it was passed
+ if data is not None:
+ block.data = data
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'.
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index 0c088275fedb..c39f6730d0dd 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==4.23.9
+edx-enterprise==4.23.13
# Stay on LTS version, remove once this is added to common constraint
Django<5.0
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index ac8d7e421701..3231c22f0473 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.23.9
+edx-enterprise==4.23.13
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 97d51c13facc..9608a1be892f 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.23.9
+edx-enterprise==4.23.13
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index 2527d80833a6..059c0f81785e 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.23.9
+edx-enterprise==4.23.13
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 340909a950b4..dc3e37a12d6c 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.23.9
+edx-enterprise==4.23.13
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
diff --git a/themes/red-theme/lms/templates/ace_common/edx_ace/common/base_body.html b/themes/red-theme/lms/templates/ace_common/edx_ace/common/base_body.html
index 8d51b16498d7..9319217aa4cf 100644
--- a/themes/red-theme/lms/templates/ace_common/edx_ace/common/base_body.html
+++ b/themes/red-theme/lms/templates/ace_common/edx_ace/common/base_body.html
@@ -63,7 +63,7 @@
|
diff --git a/xmodule/js/src/tabs/tabs-aggregator.js b/xmodule/js/src/tabs/tabs-aggregator.js
index 83baca4cf6e9..da982b6afc9d 100644
--- a/xmodule/js/src/tabs/tabs-aggregator.js
+++ b/xmodule/js/src/tabs/tabs-aggregator.js
@@ -63,8 +63,8 @@
if ($.isFunction(onSwitchFunction)) {
onSwitchFunction();
}
- this.$tabs.removeClass('current');
- $currentTarget.addClass('current');
+ this.$tabs.attr('aria-current', 'false').removeClass('current');
+ $currentTarget.attr('aria-current', 'true').addClass('current');
/*
Tabs are implemeted like anchors. Therefore we can use hash to find
diff --git a/xmodule/js/src/video/09_video_caption.js b/xmodule/js/src/video/09_video_caption.js
index 37e3923067d5..f5db26514b64 100644
--- a/xmodule/js/src/video/09_video_caption.js
+++ b/xmodule/js/src/video/09_video_caption.js
@@ -1096,12 +1096,13 @@
if (typeof this.currentIndex !== 'undefined') {
this.subtitlesEl
.find('li.current')
+ .attr('aria-current', 'false')
.removeClass('current');
- }
-
+ }
this.subtitlesEl
.find("span[data-index='" + newIndex + "']")
.parent()
+ .attr('aria-current', 'true')
.addClass('current');
this.currentIndex = newIndex;
|