diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 4647e4fdcca7..9f6cfb7c430e 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -58,7 +58,6 @@ from common.djangoapps.util.string_utils import _has_non_ascii_characters from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements -from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json @@ -303,10 +302,6 @@ def course_handler(request, course_key_string=None): else: return HttpResponseBadRequest() elif request.method == 'GET': # assume html - # Update course discussion settings, sometimes the course discussion settings are not updated - # when the course is created, so we need to update them here. - course_key = CourseKey.from_string(course_key_string) - update_discussions_settings_from_course(course_key) if course_key_string is None: return redirect(reverse('home')) else: diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index 30e02214a1a8..c3dcfe5305b7 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -717,8 +717,8 @@ def test_number_of_calls_to_db(self): """ Test to check number of queries made to mysql and mongo """ - with self.assertNumQueries(32, table_ignorelist=WAFFLE_TABLES): - with check_mongo_calls(5): + with self.assertNumQueries(29, table_ignorelist=WAFFLE_TABLES): + with check_mongo_calls(3): self.client.get_html(reverse_course_url('course_handler', self.course.id)) diff --git a/cms/envs/common.py b/cms/envs/common.py index be837c518981..45a8e97f3e51 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1880,6 +1880,7 @@ 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", diff --git a/cms/static/sass/studio-main-v1.scss b/cms/static/sass/studio-main-v1.scss index ac649970d644..5d0cdda2ea5f 100644 --- a/cms/static/sass/studio-main-v1.scss +++ b/cms/static/sass/studio-main-v1.scss @@ -15,6 +15,8 @@ // +Libs and Resets - *do not edit* // ==================== + +@import '_builtin-block-variables'; @import 'bourbon/bourbon'; // lib - bourbon @import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages @import 'build-v1'; // shared app style assets/rendering diff --git a/common/static/sass/_builtin-block-variables.scss b/common/static/sass/_builtin-block-variables.scss new file mode 100644 index 000000000000..2c567c6fb1f4 --- /dev/null +++ b/common/static/sass/_builtin-block-variables.scss @@ -0,0 +1,73 @@ +/* + * In pursuit of decoupling the built-in XBlocks from edx-platform's Sass build + * and ensuring comprehensive theming support in the extracted XBlocks, + * we need to expose Sass variables as CSS variables. + * + * Ticket/Issue: https://github.com/openedx/edx-platform/issues/35173 + */ +@import 'bourbon/bourbon'; +@import 'lms/theme/variables'; +@import 'lms/theme/variables-v1'; +@import 'cms/static/sass/partials/cms/theme/_variables'; +@import 'cms/static/sass/partials/cms/theme/_variables-v1'; +@import 'bootstrap/scss/variables'; +@import 'vendor/bi-app/bi-app-ltr'; +@import 'edx-pattern-library-shims/base/_variables.scss'; + +:root { + --action-primary-active-bg: $action-primary-active-bg; + --all-text-inputs: $all-text-inputs; + --base-font-size: $base-font-size; + --base-line-height: $base-line-height; + --baseline: $baseline; + --black: $black; + --black-t2: $black-t2; + --blue: $blue; + --blue-d1: $blue-d1; + --blue-d2: $blue-d2; + --blue-d4: $blue-d4; + --body-color: $body-color; + --border-color: $border-color; + --bp-screen-lg: $bp-screen-lg; + --btn-brand-focus-background: $btn-brand-focus-background; + --correct: $correct; + --danger: $danger; + --darkGrey: $darkGrey; + --error-color: $error-color; + --font-bold: $font-bold; + --font-family-sans-serif: $font-family-sans-serif; + --general-color-accent: $general-color-accent; + --gray: $gray; + --gray-300: $gray-300; + --gray-d1: $gray-d1; + --gray-l2: $gray-l2; + --gray-l3: $gray-l3; + --gray-l4: $gray-l4; + --gray-l6: $gray-l6; + --incorrect: $incorrect; + --lightGrey: $lightGrey; + --lighter-base-font-color: $lighter-base-font-color; + --link-color: $link-color; + --medium-font-size: $medium-font-size; + --partially-correct: $partially-correct; + --primary: $primary; + --shadow: $shadow; + --shadow-l1: $shadow-l1; + --sidebar-color: $sidebar-color; + --small-font-size: $small-font-size; + --static-path: $static-path; + --submitted: $submitted; + --success: $success; + --tmg-f2: $tmg-f2; + --tmg-s2: $tmg-s2; + --transparent: $transparent; + --uxpl-gray-background: $uxpl-gray-background; + --uxpl-gray-base: $uxpl-gray-base; + --uxpl-gray-dark: $uxpl-gray-dark; + --very-light-text: $very-light-text; + --warning: $warning; + --warning-color: $warning-color; + --warning-color-accent: $warning-color-accent; + --white: $white; + --yellow: $yellow; +} 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: 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_api.py b/lms/djangoapps/instructor/tests/test_api.py index 2548cc4f411d..b0e533ee6f7f 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -642,6 +642,25 @@ def setUp(self): last_name='Student' ) + def test_api_without_login(self): + """ + verify in case of no authentication it returns 401. + """ + self.client.logout() + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + assert response.status_code == 401 + + def test_api_without_permission(self): + """ + verify in case of no authentication it returns 403. + """ + # removed role from course for instructor + CourseInstructorRole(self.course.id).remove_users(self.instructor) + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + assert response.status_code == 403 + @patch('lms.djangoapps.instructor.views.api.log.info') @ddt.data( b"test_student@example.com,test_student_1,tester1,USA", # Typical use case. diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index 59ccfac6caa1..741f57ef6d2b 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -23,6 +23,7 @@ from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, anonymous_id_for_user from common.djangoapps.student.roles import CourseCcxCoachRole from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from lms.djangoapps.branding.api import get_logo_url_for_email from lms.djangoapps.ccx.tests.factories import CcxFactory from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.courseware.models import StudentModule @@ -940,6 +941,7 @@ def setUpClass(cls): ) cls.course_about_url = cls.course_url + 'about' cls.registration_url = f'https://{site}/register' + cls.logo_url = get_logo_url_for_email() def test_normal_params(self): # For a normal site, what do we expect to get for the URLs? @@ -950,6 +952,7 @@ def test_normal_params(self): assert result['course_about_url'] == self.course_about_url assert result['registration_url'] == self.registration_url assert result['course_url'] == self.course_url + assert result['logo_url'] == self.logo_url def test_marketing_params(self): # For a site with a marketing front end, what do we expect to get for the URLs? @@ -962,6 +965,19 @@ def test_marketing_params(self): assert result['course_about_url'] is None assert result['registration_url'] == self.registration_url assert result['course_url'] == self.course_url + assert result['logo_url'] == self.logo_url + + @patch('lms.djangoapps.instructor.enrollment.get_logo_url_for_email', return_value='https://www.logo.png') + def test_logo_url_params(self, mock_get_logo_url_for_email): + # Verify that the logo_url is correctly set in the email params + result = get_email_params(self.course, False) + + assert result['auto_enroll'] is False + assert result['course_about_url'] == self.course_about_url + assert result['registration_url'] == self.registration_url + assert result['course_url'] == self.course_url + mock_get_logo_url_for_email.assert_called_once() + assert result['logo_url'] == 'https://www.logo.png' @ddt.ddt diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 0b3121abc94b..0255e26e2a65 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -106,7 +106,9 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError from lms.djangoapps.instructor_task.data import InstructorTaskTypes from lms.djangoapps.instructor_task.models import ReportStore -from lms.djangoapps.instructor.views.serializer import RoleNameSerializer, UserSerializer +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 @@ -284,299 +286,305 @@ def wrapped(request, course_id): return wrapped -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_ENROLL) -def register_and_enroll_students(request, course_id): # pylint: disable=too-many-statements +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class RegisterAndEnrollStudents(APIView): """ Create new account and Enroll students in this course. - Passing a csv file that contains a list of students. - Order in csv should be the following email = 0; username = 1; name = 2; country = 3. - If there are more than 4 columns in the csv: cohort = 4, course mode = 5. - Requires staff access. - - -If the email address and username already exists and the user is enrolled in the course, - do nothing (including no email gets sent out) - - -If the email address already exists, but the username is different, - match on the email address only and continue to enroll the user in the course using the email address - as the matching criteria. Note the change of username as a warning message (but not a failure). - Send a standard enrollment email which is the same as the existing manual enrollment - - -If the username already exists (but not the email), assume it is a different user and fail - to create the new account. - The failure will be messaged in a response in the browser. """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_ENROLL - if not configuration_helpers.get_value( - 'ALLOW_AUTOMATED_SIGNUPS', - settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False), - ): - return HttpResponseForbidden() + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): # pylint: disable=too-many-statements + """ + Create new account and Enroll students in this course. + Passing a csv file that contains a list of students. + Order in csv should be the following email = 0; username = 1; name = 2; country = 3. + If there are more than 4 columns in the csv: cohort = 4, course mode = 5. + Requires staff access. + + -If the email address and username already exists and the user is enrolled in the course, + do nothing (including no email gets sent out) + + -If the email address already exists, but the username is different, + match on the email address only and continue to enroll the user in the course using the email address + as the matching criteria. Note the change of username as a warning message (but not a failure). + Send a standard enrollment email which is the same as the existing manual enrollment + + -If the username already exists (but not the email), assume it is a different user and fail + to create the new account. + The failure will be messaged in a response in the browser. + """ + if not configuration_helpers.get_value( + 'ALLOW_AUTOMATED_SIGNUPS', + settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False), + ): + return HttpResponseForbidden() - course_id = CourseKey.from_string(course_id) - warnings = [] - row_errors = [] - general_errors = [] + course_id = CourseKey.from_string(course_id) + warnings = [] + row_errors = [] + general_errors = [] - # email-students is a checkbox input type; will be present in POST if checked, absent otherwise - notify_by_email = 'email-students' in request.POST + # email-students is a checkbox input type; will be present in POST if checked, absent otherwise + notify_by_email = 'email-students' in request.POST - # for white labels we use 'shopping cart' which uses CourseMode.HONOR as - # course mode for creating course enrollments. - if CourseMode.is_white_label(course_id): - default_course_mode = CourseMode.HONOR - else: - default_course_mode = None + # for white labels we use 'shopping cart' which uses CourseMode.HONOR as + # course mode for creating course enrollments. + if CourseMode.is_white_label(course_id): + default_course_mode = CourseMode.HONOR + else: + default_course_mode = None - # Allow bulk enrollments in all non-expired course modes including "credit" (which is non-selectable) - valid_course_modes = set(map(lambda x: x.slug, CourseMode.modes_for_course( - course_id=course_id, - only_selectable=False, - include_expired=False, - ))) + # Allow bulk enrollments in all non-expired course modes including "credit" (which is non-selectable) + valid_course_modes = set(map(lambda x: x.slug, CourseMode.modes_for_course( + course_id=course_id, + only_selectable=False, + include_expired=False, + ))) - if 'students_list' in request.FILES: # lint-amnesty, pylint: disable=too-many-nested-blocks - students = [] + if 'students_list' in request.FILES: # lint-amnesty, pylint: disable=too-many-nested-blocks + students = [] - try: - upload_file = request.FILES.get('students_list') - if upload_file.name.endswith('.csv'): - students = list(csv.reader(upload_file.read().decode('utf-8-sig').splitlines())) - course = get_course_by_id(course_id) - else: + try: + upload_file = request.FILES.get('students_list') + if upload_file.name.endswith('.csv'): + students = list(csv.reader(upload_file.read().decode('utf-8-sig').splitlines())) + course = get_course_by_id(course_id) + else: + general_errors.append({ + 'username': '', 'email': '', + 'response': _( + 'Make sure that the file you upload is in CSV format with no ' + 'extraneous characters or rows.') + }) + + except Exception: # pylint: disable=broad-except general_errors.append({ - 'username': '', 'email': '', - 'response': _( - 'Make sure that the file you upload is in CSV format with no extraneous characters or rows.') + 'username': '', 'email': '', 'response': _('Could not read uploaded file.') }) + finally: + upload_file.close() + + generated_passwords = [] + # To skip fetching cohorts from the DB while iterating on students, + # {: CourseUserGroup} + cohorts_cache = {} + already_warned_not_cohorted = False + extra_fields_is_enabled = configuration_helpers.get_value( + 'ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', + settings.FEATURES.get('ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', False), + ) - except Exception: # pylint: disable=broad-except - general_errors.append({ - 'username': '', 'email': '', 'response': _('Could not read uploaded file.') - }) - finally: - upload_file.close() - - generated_passwords = [] - # To skip fetching cohorts from the DB while iterating on students, - # {: CourseUserGroup} - cohorts_cache = {} - already_warned_not_cohorted = False - extra_fields_is_enabled = configuration_helpers.get_value( - 'ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', - settings.FEATURES.get('ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', False), - ) - - # Iterate each student in the uploaded csv file. - for row_num, student in enumerate(students, 1): + # Iterate each student in the uploaded csv file. + for row_num, student in enumerate(students, 1): - # Verify that we have the expected number of columns in every row - # but allow for blank lines. - if not student: - continue + # Verify that we have the expected number of columns in every row + # but allow for blank lines. + if not student: + continue - if extra_fields_is_enabled: - is_valid_csv = 4 <= len(student) <= 6 - error = _('Data in row #{row_num} must have between four and six columns: ' - 'email, username, full name, country, cohort, and course mode. ' - 'The last two columns are optional.').format(row_num=row_num) - else: - is_valid_csv = len(student) == 4 - error = _('Data in row #{row_num} must have exactly four columns: ' - 'email, username, full name, and country.').format(row_num=row_num) + if extra_fields_is_enabled: + is_valid_csv = 4 <= len(student) <= 6 + error = _('Data in row #{row_num} must have between four and six columns: ' + 'email, username, full name, country, cohort, and course mode. ' + 'The last two columns are optional.').format(row_num=row_num) + else: + is_valid_csv = len(student) == 4 + error = _('Data in row #{row_num} must have exactly four columns: ' + 'email, username, full name, and country.').format(row_num=row_num) + + if not is_valid_csv: + general_errors.append({ + 'username': '', + 'email': '', + 'response': error + }) + continue - if not is_valid_csv: - general_errors.append({ - 'username': '', - 'email': '', - 'response': error - }) - continue + # Extract each column, handle optional columns if they exist. + email, username, name, country, *optional_cols = student + if optional_cols: + optional_cols.append(default_course_mode) + cohort_name, course_mode, *_tail = optional_cols + else: + cohort_name = None + course_mode = None - # Extract each column, handle optional columns if they exist. - email, username, name, country, *optional_cols = student - if optional_cols: - optional_cols.append(default_course_mode) - cohort_name, course_mode, *_tail = optional_cols - else: - cohort_name = None - course_mode = None + # Validate cohort name, and get the cohort object. Skip if course + # is not cohorted. + cohort = None - # Validate cohort name, and get the cohort object. Skip if course - # is not cohorted. - cohort = None + if cohort_name and not already_warned_not_cohorted: + if not is_course_cohorted(course_id): + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Course is not cohorted but cohort provided. ' + 'Ignoring cohort assignment for all users.') + }) + already_warned_not_cohorted = True + elif cohort_name in cohorts_cache: + cohort = cohorts_cache[cohort_name] + else: + # Don't attempt to create cohort or assign student if cohort + # does not exist. + try: + cohort = get_cohort_by_name(course_id, cohort_name) + except CourseUserGroup.DoesNotExist: + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Cohort name not found: {cohort}. ' + 'Ignoring cohort assignment for ' + 'all users.').format(cohort=cohort_name) + }) + cohorts_cache[cohort_name] = cohort + + # Validate course mode. + if not course_mode: + course_mode = default_course_mode + + if (course_mode is not None + and course_mode not in valid_course_modes): + # If `default is None` and the user is already enrolled, + # `CourseEnrollment.change_mode()` will not update the mode, + # hence two error messages. + if default_course_mode is None: + err_msg = _( + 'Invalid course mode: {mode}. Falling back to the ' + 'default mode, or keeping the current mode in case the ' + 'user is already enrolled.' + ).format(mode=course_mode) + else: + err_msg = _( + 'Invalid course mode: {mode}. Failling back to ' + '{default_mode}, or resetting to {default_mode} in case ' + 'the user is already enrolled.' + ).format(mode=course_mode, default_mode=default_course_mode) + row_errors.append({ + 'username': username, + 'email': email, + 'response': err_msg, + }) + course_mode = default_course_mode - if cohort_name and not already_warned_not_cohorted: - if not is_course_cohorted(course_id): + email_params = get_email_params(course, True, secure=request.is_secure()) + try: + validate_email(email) # Raises ValidationError if invalid + except ValidationError: row_errors.append({ 'username': username, 'email': email, - 'response': _('Course is not cohorted but cohort provided. ' - 'Ignoring cohort assignment for all users.') + 'response': _('Invalid email {email_address}.').format(email_address=email) }) - already_warned_not_cohorted = True - elif cohort_name in cohorts_cache: - cohort = cohorts_cache[cohort_name] else: - # Don't attempt to create cohort or assign student if cohort - # does not exist. - try: - cohort = get_cohort_by_name(course_id, cohort_name) - except CourseUserGroup.DoesNotExist: + if User.objects.filter(email=email).exists(): + # Email address already exists. assume it is the correct user + # and just register the user in the course and send an enrollment email. + user = User.objects.get(email=email) + + # see if it is an exact match with email and username + # if it's not an exact match then just display a warning message, but continue onwards + if not User.objects.filter(email=email, username=username).exists(): + warning_message = _( + 'An account with email {email} exists but the provided username {username} ' + 'is different. Enrolling anyway with {email}.' + ).format(email=email, username=username) + + warnings.append({ + 'username': username, 'email': email, 'response': warning_message + }) + log.warning('email %s already exist', email) + else: + log.info( + "user already exists with username '%s' and email '%s'", + username, + email + ) + + # enroll a user if it is not already enrolled. + if not is_user_enrolled_in_course(user, course_id): + # Enroll user to the course and add manual enrollment audit trail + create_manual_course_enrollment( + user=user, + course_id=course_id, + mode=course_mode, + enrolled_by=request.user, + reason='Enrolling via csv upload', + state_transition=UNENROLLED_TO_ENROLLED, + ) + enroll_email(course_id=course_id, + student_email=email, + auto_enroll=True, + email_students=notify_by_email, + email_params=email_params) + else: + # update the course mode if already enrolled + existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) + if existing_enrollment.mode != course_mode: + existing_enrollment.change_mode(mode=course_mode) + if cohort: + try: + add_user_to_cohort(cohort, user) + except ValueError: + # user already in this cohort; ignore + pass + elif is_email_retired(email): + # We are either attempting to enroll a retired user or create a new user with an email which is + # already associated with a retired account. Simply block these attempts. row_errors.append({ 'username': username, 'email': email, - 'response': _('Cohort name not found: {cohort}. ' - 'Ignoring cohort assignment for ' - 'all users.').format(cohort=cohort_name) + 'response': _('Invalid email {email_address}.').format(email_address=email), }) - cohorts_cache[cohort_name] = cohort - - # Validate course mode. - if not course_mode: - course_mode = default_course_mode - - if (course_mode is not None - and course_mode not in valid_course_modes): - # If `default is None` and the user is already enrolled, - # `CourseEnrollment.change_mode()` will not update the mode, - # hence two error messages. - if default_course_mode is None: - err_msg = _( - 'Invalid course mode: {mode}. Falling back to the ' - 'default mode, or keeping the current mode in case the ' - 'user is already enrolled.' - ).format(mode=course_mode) - else: - err_msg = _( - 'Invalid course mode: {mode}. Failling back to ' - '{default_mode}, or resetting to {default_mode} in case ' - 'the user is already enrolled.' - ).format(mode=course_mode, default_mode=default_course_mode) - row_errors.append({ - 'username': username, - 'email': email, - 'response': err_msg, - }) - course_mode = default_course_mode - - email_params = get_email_params(course, True, secure=request.is_secure()) - try: - validate_email(email) # Raises ValidationError if invalid - except ValidationError: - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email) - }) - else: - if User.objects.filter(email=email).exists(): - # Email address already exists. assume it is the correct user - # and just register the user in the course and send an enrollment email. - user = User.objects.get(email=email) - - # see if it is an exact match with email and username - # if it's not an exact match then just display a warning message, but continue onwards - if not User.objects.filter(email=email, username=username).exists(): - warning_message = _( - 'An account with email {email} exists but the provided username {username} ' - 'is different. Enrolling anyway with {email}.' - ).format(email=email, username=username) - - warnings.append({ - 'username': username, 'email': email, 'response': warning_message - }) - log.warning('email %s already exist', email) + log.warning('Email address %s is associated with a retired user, so course enrollment was ' + # lint-amnesty, pylint: disable=logging-not-lazy + 'blocked.', email) else: - log.info( - "user already exists with username '%s' and email '%s'", + # This email does not yet exist, so we need to create a new account + # If username already exists in the database, then create_and_enroll_user + # will raise an IntegrityError exception. + password = generate_unique_password(generated_passwords) + errors = create_and_enroll_user( + email, username, - email - ) - - # enroll a user if it is not already enrolled. - if not is_user_enrolled_in_course(user, course_id): - # Enroll user to the course and add manual enrollment audit trail - create_manual_course_enrollment( - user=user, - course_id=course_id, - mode=course_mode, - enrolled_by=request.user, - reason='Enrolling via csv upload', - state_transition=UNENROLLED_TO_ENROLLED, + name, + country, + password, + course_id, + course_mode, + request.user, + email_params, + email_user=notify_by_email, ) - enroll_email(course_id=course_id, - student_email=email, - auto_enroll=True, - email_students=notify_by_email, - email_params=email_params) - else: - # update the course mode if already enrolled - existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) - if existing_enrollment.mode != course_mode: - existing_enrollment.change_mode(mode=course_mode) - if cohort: - try: - add_user_to_cohort(cohort, user) - except ValueError: - # user already in this cohort; ignore - pass - elif is_email_retired(email): - # We are either attempting to enroll a retired user or create a new user with an email which is - # already associated with a retired account. Simply block these attempts. - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email), - }) - log.warning('Email address %s is associated with a retired user, so course enrollment was ' + # lint-amnesty, pylint: disable=logging-not-lazy - 'blocked.', email) - else: - # This email does not yet exist, so we need to create a new account - # If username already exists in the database, then create_and_enroll_user - # will raise an IntegrityError exception. - password = generate_unique_password(generated_passwords) - errors = create_and_enroll_user( - email, - username, - name, - country, - password, - course_id, - course_mode, - request.user, - email_params, - email_user=notify_by_email, - ) - row_errors.extend(errors) - if cohort: - try: - add_user_to_cohort(cohort, email) - except ValueError: - # user already in this cohort; ignore - # NOTE: Checking this here may be unnecessary if we can prove that a new user will never be - # automatically assigned to a cohort from the above. - pass - except ValidationError: - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email), - }) + row_errors.extend(errors) + if cohort: + try: + add_user_to_cohort(cohort, email) + except ValueError: + # user already in this cohort; ignore + # NOTE: Checking this here may be unnecessary if we can prove that a + # new user will never be + # automatically assigned to a cohort from the above. + pass + except ValidationError: + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Invalid email {email_address}.').format(email_address=email), + }) - else: - general_errors.append({ - 'username': '', 'email': '', 'response': _('File is not attached.') - }) + else: + general_errors.append({ + 'username': '', 'email': '', 'response': _('File is not attached.') + }) - results = { - 'row_errors': row_errors, - 'general_errors': general_errors, - 'warnings': warnings - } - return JsonResponse(results) + results = { + 'row_errors': row_errors, + 'general_errors': general_errors, + 'warnings': warnings + } + return JsonResponse(results) def generate_random_string(length): @@ -982,17 +990,8 @@ def bulk_beta_modify_access(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EDIT_COURSE_ACCESS) -@require_post_params( - unique_student_identifier="email or username of user to change access", - rolename="'instructor', 'staff', 'beta', or 'ccx_coach'", - action="'allow' or 'revoke'" -) -@common_exceptions_400 -def modify_access(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ModifyAccess(APIView): """ Modify staff/instructor access of other user. Requires instructor access. @@ -1004,67 +1003,77 @@ def modify_access(request, course_id): rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] action is one of ['allow', 'revoke'] """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'instructor', course_id, depth=None - ) - unique_student_identifier = request.POST.get('unique_student_identifier') - try: - user = get_student_from_identifier(unique_student_identifier) - except User.DoesNotExist: - response_payload = { - 'unique_student_identifier': unique_student_identifier, - 'userDoesNotExist': True, - } - return JsonResponse(response_payload) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS + serializer_class = AccessSerializer - # Check that user is active, because add_users - # in common/djangoapps/student/roles.py fails - # silently when we try to add an inactive user. - if not user.is_active: - response_payload = { - 'unique_student_identifier': user.username, - 'inactiveUser': True, - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Modify staff/instructor access of other user. + Requires instructor access. + """ + course_id = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'instructor', course_id, depth=None + ) - rolename = request.POST.get('rolename') - action = request.POST.get('action') + serializer_data = AccessSerializer(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - if rolename not in ROLES: - error = strip_tags(f"unknown rolename '{rolename}'") - log.error(error) - return HttpResponseBadRequest(error) + user = serializer_data.validated_data.get('unique_student_identifier') + if not user: + response_payload = { + 'unique_student_identifier': request.data.get('unique_student_identifier'), + 'userDoesNotExist': True, + } + return JsonResponse(response_payload) + + if not user.is_active: + response_payload = { + 'unique_student_identifier': user.username, + 'inactiveUser': True, + } + return JsonResponse(response_payload) + + rolename = serializer_data.data['rolename'] + action = serializer_data.data['action'] + + if rolename not in ROLES: + error = strip_tags(f"unknown rolename '{rolename}'") + log.error(error) + return HttpResponseBadRequest(error) + + # disallow instructors from removing their own instructor access. + if rolename == 'instructor' and user == request.user and action != 'allow': + response_payload = { + 'unique_student_identifier': user.username, + 'rolename': rolename, + 'action': action, + 'removingSelfAsInstructor': True, + } + return JsonResponse(response_payload) + + if action == 'allow': + allow_access(course, user, rolename) + if not is_user_enrolled_in_course(user, course_id): + CourseEnrollment.enroll(user, course_id) + elif action == 'revoke': + revoke_access(course, user, rolename) + else: + return HttpResponseBadRequest(strip_tags( + f"unrecognized action u'{action}'" + )) - # disallow instructors from removing their own instructor access. - if rolename == 'instructor' and user == request.user and action != 'allow': response_payload = { 'unique_student_identifier': user.username, 'rolename': rolename, 'action': action, - 'removingSelfAsInstructor': True, + 'success': 'yes', } return JsonResponse(response_payload) - if action == 'allow': - allow_access(course, user, rolename) - if not is_user_enrolled_in_course(user, course_id): - CourseEnrollment.enroll(user, course_id) - elif action == 'revoke': - revoke_access(course, user, rolename) - else: - return HttpResponseBadRequest(strip_tags( - f"unrecognized action u'{action}'" - )) - - response_payload = { - 'unique_student_identifier': user.username, - 'rolename': rolename, - 'action': action, - 'success': 'yes', - } - return JsonResponse(response_payload) - @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') class ListCourseRoleMembersView(APIView): @@ -2176,23 +2185,35 @@ def list_background_email_tasks(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EMAIL) -def list_email_content(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListEmailContent(APIView): """ List the content of bulk emails sent """ - course_id = CourseKey.from_string(course_id) - task_type = InstructorTaskTypes.BULK_COURSE_EMAIL - # First get tasks list of bulk emails sent - emails = task_api.get_instructor_task_history(course_id, task_type=task_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EMAIL - response_payload = { - 'emails': list(map(extract_email_features, emails)), - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List the content of bulk emails sent for a specific course. + + Args: + request (HttpRequest): The HTTP request object. + course_id (str): The ID of the course for which to list the bulk emails. + + Returns: + HttpResponse: A response object containing the list of bulk email contents. + """ + course_id = CourseKey.from_string(course_id) + task_type = InstructorTaskTypes.BULK_COURSE_EMAIL + # First get tasks list of bulk emails sent + emails = task_api.get_instructor_task_history(course_id, task_type=task_type) + + response_payload = { + 'emails': list(map(extract_email_features, emails)), + } + return JsonResponse(response_payload) class InstructorTaskSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -2971,20 +2992,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 0c7c0e6bc378..f25ea56c2ea7 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -22,9 +22,9 @@ urlpatterns = [ path('students_update_enrollment', api.students_update_enrollment, name='students_update_enrollment'), - path('register_and_enroll_students', api.register_and_enroll_students, name='register_and_enroll_students'), + path('register_and_enroll_students', api.RegisterAndEnrollStudents.as_view(), name='register_and_enroll_students'), path('list_course_role_members', api.ListCourseRoleMembersView.as_view(), name='list_course_role_members'), - path('modify_access', api.modify_access, name='modify_access'), + path('modify_access', api.ModifyAccess.as_view(), name='modify_access'), path('bulk_beta_modify_access', api.bulk_beta_modify_access, name='bulk_beta_modify_access'), path('get_problem_responses', api.get_problem_responses, name='get_problem_responses'), path('get_grading_config', api.get_grading_config, name='get_grading_config'), @@ -46,14 +46,14 @@ name='mark_student_can_skip_entrance_exam'), path('list_instructor_tasks', api.list_instructor_tasks, name='list_instructor_tasks'), path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), - path('list_email_content', api.list_email_content, name='list_email_content'), + path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'), path('list_forum_members', api.list_forum_members, name='list_forum_members'), path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'), path('send_email', api.send_email, name='send_email'), path('change_due_date', api.change_due_date, name='change_due_date'), path('reset_due_date', api.reset_due_date, name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), - path('show_student_extensions', api.show_student_extensions, name='show_student_extensions'), + path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), # proctored exam downloads... path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index b4f6f7626013..0697bed6832d 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ from rest_framework import serializers +from .tools import get_student_from_identifier from lms.djangoapps.instructor.access import ROLES @@ -28,3 +29,51 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['username', 'email', 'first_name', 'last_name'] + + +class AccessSerializer(serializers.Serializer): + """ + Serializer for managing user access changes. + This serializer validates and processes the data required to modify + user access within a system. + """ + unique_student_identifier = serializers.CharField( + max_length=255, + help_text="Email or username of user to change access" + ) + rolename = serializers.CharField( + help_text="Role name to assign to the user" + ) + action = serializers.ChoiceField( + choices=['allow', 'revoke'], + help_text="Action to perform on the user's access" + ) + + def validate_unique_student_identifier(self, value): + """ + Validate that the unique_student_identifier corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user + + +class ShowStudentExtensionSerializer(serializers.Serializer): + """ + Serializer for validating and processing the student identifier. + """ + student = serializers.CharField(write_only=True, required=True) + + def validate_student(self, value): + """ + Validate that the student corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user diff --git a/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst new file mode 100644 index 000000000000..08735188fcdc --- /dev/null +++ b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst @@ -0,0 +1,65 @@ +0001. Extending Identity Verification +##################################### + +Status +****** + +**Accepted** *2024-08-26* + +Context +******* + +The backend implementation of identity verification (IDV) is in the `verify_student Django application`_. The +`verify_student Django application`_ also contains a frontend user experience for performing photo IDV via an +integration with Software Secure. There is also a `React-based implementation of this flow`_ in the +`frontend-app-account MFE`_, so the frontend user experience stored in the `verify_student Django application`_ is often +called the "legacy flow". + +The current architecture of the `verify_student Django application`_ requires that any additional implementations of IDV +are stored in the application. For example, the Software Secure integration is stored in this application even though +it is a custom integration that the Open edX community does not use. + +Different Open edX operators have different IDV needs. There is currently no way to add additional IDV implementations +to the platform without committing them to the core. The `verify_student Django application`_ needs enhanced +extensibility mechanisms to enable per-deployment integration of IDV implementations without modifying the core. + +Decision +******** + +* We will support the integration of additional implementations of IDV through the use of Python plugins into the + platform. +* We will add a ``VerificationAttempt`` model, which will store generic, implementation-agnostic information about an + IDV attempt. +* We will expose a simple Python API to write and update instances of the ``VerificationAttempt`` model. This will + enable plugins to publish information about their IDV attempts to the platform. +* The ``VerificationAttempt`` model will be integrated into the `verify_student Django application`_, particularly into + the `IDVerificationService`_. +* We will emit Open edX events for each status change of a ``VerificationAttempt``. +* We will add an Open edX filter hook to change the URL of the photo IDV frontend. + +Consequences +************ + +* It will become possible for Open edX operators to implement and integrate any additional forms of IDV necessary for + their deployment. +* The `verify_student Django application`_ will contain both concrete implementations of forms of IDV (i.e. manual, SSO, + Software Secure, etc.) and a generic, extensible implementation. The work to deprecate and remove the Software Secure + integration and to transition the other existing forms of IDV (i.e. manual and SSO) to Django plugins will occur + independently of the improvements to extensibility described in this decision. + +Rejected Alternatives +********************* + +We considered introducing a ``fetch_verification_attempts`` filter hook to allow plugins to expose additional +``VerificationAttempts`` to the platform in lieu of an additional model. However, doing database queries via filter +hooks can cause unpredictable performance problems, and this has been a pain point for Open edX. + +References +********** +`[Proposal] Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_ +`Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_ + +.. _frontend-app-account MFE: https://github.com/openedx/frontend-app-account +.. _IDVerificationService: https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/verify_student/services.py#L55 +.. _React-based implementation of this flow: https://github.com/openedx/frontend-app-account/tree/master/src/id-verification +.. _verify_student Django application: https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/verify_student diff --git a/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py new file mode 100644 index 000000000000..3f01047f9f51 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-08-26 14:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('verify_student', '0014_remove_softwaresecurephotoverification_expiry_date'), + ] + + operations = [ + migrations.CreateModel( + name='VerificationAttempt', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('name', models.CharField(blank=True, max_length=255)), + ('status', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('approved', 'approved'), ('denied', 'denied')], max_length=64)), + ('expiration_datetime', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index f7750a4cd662..903d80bf9245 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy from model_utils import Choices from model_utils.models import StatusModel, TimeStampedModel +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from opaque_keys.edx.django.models import CourseKeyField from lms.djangoapps.verify_student.ssencrypt import ( @@ -1189,3 +1190,27 @@ class Meta: def __str__(self): return str(self.arguments) + + +class VerificationAttempt(TimeStampedModel): + """ + The model represents impelementation-agnostic information about identity verification (IDV) attempts. + + Plugins that implement forms of IDV can store information about IDV attempts in this model for use across + the platform. + """ + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + name = models.CharField(blank=True, max_length=255) + + STATUS_CHOICES = [ + VerificationAttemptStatus.created, + VerificationAttemptStatus.pending, + VerificationAttemptStatus.approved, + VerificationAttemptStatus.denied, + ] + status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES]) + + expiration_datetime = models.DateTimeField( + null=True, + blank=True, + ) diff --git a/lms/djangoapps/verify_student/statuses.py b/lms/djangoapps/verify_student/statuses.py new file mode 100644 index 000000000000..b55a9042e0f6 --- /dev/null +++ b/lms/djangoapps/verify_student/statuses.py @@ -0,0 +1,21 @@ +""" +Status enums for verify_student. +""" + + +class VerificationAttemptStatus: + """This class describes valid statuses for a verification attempt to be in.""" + + # This is the initial state of a verification attempt, before a learner has started IDV. + created = "created" + + # A verification attempt is pending when it has been started but has not yet been completed. + pending = "pending" + + # A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual + # review, etc). + approved = "approved" + + # A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review, + # etc). + denied = "denied" diff --git a/lms/envs/common.py b/lms/envs/common.py index 18d07afd7161..04a1753838ed 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3394,6 +3394,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", diff --git a/lms/static/sass/_variables.scss b/lms/static/sass/_variables.scss index dff9826b94b7..e1ccc714266d 100644 --- a/lms/static/sass/_variables.scss +++ b/lms/static/sass/_variables.scss @@ -1,5 +1,7 @@ // LMS-specific variables +@import '_builtin-block-variables'; + $text-width-readability-max: 1080px; // LMS-only colors diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 9473dabbe427..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: @@ -467,6 +515,29 @@ def delete_index_doc(usage_key: UsageKey) -> None: _wait_for_meili_tasks(tasks) +def delete_all_draft_docs_for_library(library_key: LibraryLocatorV2) -> None: + """ + Deletes draft documents for the given XBlocks from the search index + """ + current_rebuild_index_name = _get_running_rebuild_index_name() + client = _get_meilisearch_client() + # Delete all documents where last_published is null i.e. never published before. + delete_filter = [ + f'{Fields.context_key}="{library_key}"', + # This field should only be NULL or have a value, but we're also checking IS EMPTY just in case. + # Inner arrays are connected by an OR + [f'{Fields.last_published} IS EMPTY', f'{Fields.last_published} IS NULL'], + ] + + tasks = [] + if current_rebuild_index_name: + # If there is a rebuild in progress, the documents will also be deleted from the new index. + tasks.append(client.index(current_rebuild_index_name).delete_documents(filter=delete_filter)) + tasks.append(client.index(STUDIO_INDEX_NAME).delete_documents(filter=delete_filter)) + + _wait_for_meili_tasks(tasks) + + def upsert_library_block_index_doc(usage_key: UsageKey) -> None: """ Creates or updates the document for the given Library Block in the search index 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/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index 06ea3e777c61..dfd603776981 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -9,9 +9,9 @@ from celery import shared_task from celery_utils.logged_task import LoggedTask from edx_django_utils.monitoring import set_code_owner_attribute +from meilisearch.errors import MeilisearchError from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 -from meilisearch.errors import MeilisearchError from . import api @@ -81,3 +81,6 @@ def update_content_library_index_docs(library_key_str: str) -> None: log.info("Updating content index documents for library with id: %s", library_key) api.upsert_content_library_index_docs(library_key) + # Delete all documents in this library that were not published by above function + # as this task is also triggered on discard event. + api.delete_all_draft_docs_for_library(library_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 9dcdfb76b4a6..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): @@ -388,3 +428,18 @@ def test_index_content_library_metadata(self, mock_meilisearch): mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with( [self.doc_problem1, self.doc_problem2] ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_delete_all_drafts(self, mock_meilisearch): + """ + Test deleting all draft documents from the index. + """ + api.delete_all_draft_docs_for_library(self.library.key) + + delete_filter = [ + f'context_key="{self.library.key}"', + ['last_published IS EMPTY', 'last_published IS NULL'], + ] + mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( + filter=delete_filter + ) 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/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index 1e0f4c81c743..e36c435e8ac0 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -7,7 +7,6 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 -from django.urls import reverse from pytz import utc from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import @@ -20,7 +19,11 @@ ) 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.models import CourseNotificationPreference +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 +) from xmodule.modulestore.django import modulestore from .notification_icons import NotificationTypeIcons @@ -71,15 +74,7 @@ def get_unsubscribe_link(username, patch): """ encrypted_username = encrypt_string(username) encrypted_patch = encrypt_object(patch) - kwargs = { - 'username': encrypted_username, - 'patch': encrypted_patch - } - relative_url = reverse('preference_update_from_encrypted_username_view', kwargs=kwargs) - protocol = 'https://' - if settings.DEBUG: - protocol = 'http://' - return f"{protocol}{settings.LMS_BASE}{relative_url}" + return f"{settings.LEARNING_MICROFRONTEND_URL}/preferences-unsubscribe/{encrypted_username}/{encrypted_patch}" def create_email_template_context(username): @@ -363,6 +358,14 @@ def get_default_cadence_value(app_name, notification_type): return COURSE_NOTIFICATION_APPS[app_name]['core_email_cadence'] return COURSE_NOTIFICATION_TYPES[notification_type]['email_cadence'] + def get_updated_preference(pref): + """ + Update preference if config version doesn't match + """ + if pref.config_version != get_course_notification_preference_config_version(): + pref = pref.get_user_course_preference(pref.user_id, pref.course_id) + return pref + course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True) CourseNotificationPreference.objects.bulk_create( [ @@ -375,6 +378,7 @@ def get_default_cadence_value(app_name, notification_type): # pylint: disable=too-many-nested-blocks for preference in preferences: + preference = get_updated_preference(preference) preference_json = preference.notification_preference_config for app_name, app_prefs in preference_json.items(): if not is_name_match(app_name, app_value): @@ -392,3 +396,4 @@ def get_default_cadence_value(app_name, notification_type): 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/notifications/templates/notifications/digest_footer.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html index 0419b256656a..4fa903d127ac 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html @@ -38,7 +38,7 @@ Notification Settings - Unsubscribe + Unsubscribe from email digest for learning activity

diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html index 7957524e8ae2..1f22ced20049 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html @@ -5,6 +5,13 @@ style="background: #00262b; color: white; width: 100%; padding: 1.5rem" > + + + + Unsubscribe + + + logo_url diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index d2968749efab..e40e52078989 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -28,9 +28,13 @@ ) from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS from openedx.core.djangoapps.notifications.email_notifications import EmailCadence -from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification +from openedx.core.djangoapps.notifications.models import ( + CourseNotificationPreference, + Notification, + get_course_notification_preference_config_version +) from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer -from openedx.core.djangoapps.notifications.email.utils import get_unsubscribe_link +from openedx.core.djangoapps.notifications.email.utils import encrypt_object, encrypt_string from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -910,7 +914,13 @@ def test_if_preference_is_updated(self, request_type): """ Tests if preference is updated when url is hit """ - url = get_unsubscribe_link(self.user.username, {'channel': 'email', 'value': False}) + user_hash = encrypt_string(self.user.username) + patch_hash = encrypt_object({'channel': 'email', 'value': False}) + url_params = { + "username": user_hash, + "patch": patch_hash + } + url = reverse("preference_update_from_encrypted_username_view", kwargs=url_params) func = getattr(self.client, request_type) response = func(url) assert response.status_code == status.HTTP_200_OK @@ -921,6 +931,24 @@ def test_if_preference_is_updated(self, request_type): assert type_prefs['email'] is False assert type_prefs['email_cadence'] == EmailCadence.NEVER + def test_if_config_version_is_updated(self): + """ + Tests if preference version is updated before applying patch data + """ + preference = CourseNotificationPreference.objects.get(user=self.user, course_id=self.course.id) + preference.config_version -= 1 + preference.save() + user_hash = encrypt_string(self.user.username) + patch_hash = encrypt_object({'channel': 'email', 'value': False}) + url_params = { + "username": user_hash, + "patch": patch_hash + } + url = reverse("preference_update_from_encrypted_username_view", kwargs=url_params) + self.client.get(url) + preference = CourseNotificationPreference.objects.get(user=self.user, course_id=self.course.id) + assert preference.config_version == get_course_notification_preference_config_version() + def remove_notifications_with_visibility_settings(expected_response): """ diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index ee5e282d905e..fdc91c12a9e0 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -5,7 +5,7 @@ from django.conf import settings from django.db.models import Count -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from opaque_keys.edx.keys import CourseKey from pytz import UTC @@ -441,7 +441,4 @@ def preference_update_from_encrypted_username_view(request, username, patch): username and patch must be string """ update_user_preferences_from_patch(username, patch) - context = { - "notification_preferences_url": f"{settings.ACCOUNT_MICROFRONTEND_URL}/notifications" - } - return render(request, "notifications/email_digest_preference_update.html", context=context) + return Response({"result": "success"}, status=status.HTTP_200_OK) 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_api/views.py b/openedx/core/djangoapps/user_api/views.py index b4fcc68db649..d52493556a19 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -1,6 +1,5 @@ """HTTP end-points for the User API. """ - from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.http import HttpResponse from django.utils.decorators import method_decorator @@ -16,21 +15,22 @@ from rest_framework.views import APIView from openedx.core.djangoapps.django_comment_common.models import Role -from openedx.core.lib.api.view_utils import require_post_params from openedx.core.djangoapps.user_api.models import UserPreference from openedx.core.djangoapps.user_api.preferences.api import get_country_time_zones, update_email_opt_in from openedx.core.djangoapps.user_api.serializers import ( CountryTimeZoneSerializer, UserPreferenceSerializer, - UserSerializer + UserSerializer, ) from openedx.core.lib.api.permissions import ApiKeyHeaderPermission +from openedx.core.lib.api.view_utils import require_post_params class UserViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the User ORM object """ + permission_classes = (ApiKeyHeaderPermission,) queryset = User.objects.all().prefetch_related("preferences").select_related("profile") serializer_class = UserSerializer @@ -42,6 +42,7 @@ class ForumRoleUsersListView(generics.ListAPIView): """ Forum roles are represented by a list of user dicts """ + permission_classes = (ApiKeyHeaderPermission,) serializer_class = UserSerializer paginate_by = 10 @@ -51,10 +52,10 @@ def get_queryset(self): """ Return a list of users with the specified role/course pair """ - name = self.kwargs['name'] - course_id_string = self.request.query_params.get('course_id') + name = self.kwargs["name"] + course_id_string = self.request.query_params.get("course_id") if not course_id_string: - raise ParseError('course_id must be specified') + raise ParseError("course_id must be specified") course_id = CourseKey.from_string(course_id_string) role = Role.objects.get_or_create(course_id=course_id, name=name)[0] users = role.users.prefetch_related("preferences").select_related("profile").all() @@ -65,6 +66,7 @@ class UserPreferenceViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the UserPreference ORM """ + permission_classes = (ApiKeyHeaderPermission,) queryset = UserPreference.objects.all() filter_backends = (DjangoFilterBackend,) @@ -78,26 +80,30 @@ class PreferenceUsersListView(generics.ListAPIView): """ DRF class for listing a user's preferences """ + permission_classes = (ApiKeyHeaderPermission,) serializer_class = UserSerializer paginate_by = 10 paginate_by_param = "page_size" def get_queryset(self): - return User.objects.filter( - preferences__key=self.kwargs["pref_key"] - ).prefetch_related("preferences").select_related("profile") + return ( + User.objects.filter(preferences__key=self.kwargs["pref_key"]) + .prefetch_related("preferences") + .select_related("profile") + ) class UpdateEmailOptInPreference(APIView): - """View for updating the email opt in preference. """ + """View for updating the email opt in preference.""" + authentication_classes = (SessionAuthenticationAllowInactiveUser,) permission_classes = (IsAuthenticated,) @method_decorator(require_post_params(["course_id", "email_opt_in"])) @method_decorator(ensure_csrf_cookie) def post(self, request): - """ Post function for updating the email opt in preference. + """Post function for updating the email opt in preference. Allows the modification or creation of the email opt in preference at an organizational level. @@ -111,17 +117,13 @@ def post(self, request): assume False. """ - course_id = request.data['course_id'] + course_id = request.data["course_id"] try: org = locator.CourseLocator.from_string(course_id).org except InvalidKeyError: - return HttpResponse( - status=400, - content=f"No course '{course_id}' found", - content_type="text/plain" - ) + return HttpResponse(status=400, content=f"No course '{course_id}' found", content_type="text/plain") # Only check for true. All other values are False. - email_opt_in = request.data['email_opt_in'].lower() == 'true' + email_opt_in = request.data["email_opt_in"].lower() == "true" update_email_opt_in(request.user, org, email_opt_in) return HttpResponse(status=status.HTTP_200_OK) @@ -152,9 +154,10 @@ class CountryTimeZoneListView(generics.ListAPIView): * time_zone: The name of the time zone. * description: The display version of the time zone """ + serializer_class = CountryTimeZoneSerializer paginator = None def get_queryset(self): - country_code = self.request.GET.get('country_code', None) + country_code = self.request.GET.get("country_code", None) return get_country_time_zones(country_code) 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 0a95e5c8a4cb..8c7e19a18a86 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -12,6 +12,12 @@ # This file contains all common constraints for edx-repos -c common_constraints.txt +# Date: 2024-08-21 +# Description: This is the major upgrade of algoliasearch python client and it will +# break one of the edX' platform plugin, so we need to make that compatible first. +# Ticket: https://github.com/openedx/edx-platform/issues/35334 +algoliasearch<4.0.0 + # As it is not clarified what exact breaking changes will be introduced as per # the next major release, ensure the installed version is within boundaries. celery>=5.2.2,<6.0.0 @@ -20,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.8 +edx-enterprise==4.23.13 # Stay on LTS version, remove once this is added to common constraint Django<5.0 @@ -87,7 +93,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.10.1 +openedx-learning==0.11.2 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 @@ -136,7 +142,3 @@ django-storages<1.14.4 # We are pinning this until after all the smaller migrations get handled and then we can migrate this all at once. # Ticket to unpin: https://github.com/edx/edx-arch-experiments/issues/760 social-auth-app-django<=5.4.1 - -# Xblock==5.0.0 changed how entrypoints were loaded, breaking a workaround for overriding blocks. -# See ticket: https://github.com/openedx/XBlock/issues/777 -xblock[django]==4.0.1 diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index 8942bc7dd9cb..4a7b0c0a7d35 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -35,13 +35,13 @@ markupsafe==2.1.5 # via # chem # openedx-calc -matplotlib==3.9.0 +matplotlib==3.9.2 # via -r requirements/edx-sandbox/base.in mpmath==1.3.0 # via sympy networkx==3.3 # via -r requirements/edx-sandbox/base.in -nltk==3.8.1 +nltk==3.9.1 # via # -r requirements/edx-sandbox/base.in # chem @@ -82,7 +82,7 @@ six==1.16.0 # via # codejail-includes # python-dateutil -sympy==1.13.1 +sympy==1.13.2 # via # -r requirements/edx-sandbox/base.in # openedx-calc diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6a15216943f6..640ccbe982e7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -8,16 +8,18 @@ # via -r requirements/edx/github.in acid-xblock==0.3.1 # via -r requirements/edx/kernel.in -aiohappyeyeballs==2.3.4 +aiohappyeyeballs==2.4.0 # via aiohttp -aiohttp==3.10.1 +aiohttp==3.10.5 # via # geoip2 # openai aiosignal==1.3.1 # via aiohttp algoliasearch==3.0.0 - # via -r requirements/edx/bundled.in + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/bundled.in amqp==5.2.0 # via kombu analytics-python==1.4.post1 @@ -45,7 +47,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.15.0 +babel==2.16.0 # via # -r requirements/edx/kernel.in # enmerkar @@ -68,13 +70,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.34.154 +boto3==1.35.1 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.34.154 +botocore==1.35.1 # via # -r requirements/edx/kernel.in # boto3 @@ -83,7 +85,7 @@ bridgekeeper==0.9 # via -r requirements/edx/kernel.in cachecontrol==0.14.0 # via firebase-admin -cachetools==5.4.0 +cachetools==5.5.0 # via google-auth camel-converter[pydantic]==3.1.2 # via meilisearch @@ -465,7 +467,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.23.8 +edx-enterprise==4.23.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -515,7 +517,7 @@ edx-search==4.0.0 # via -r requirements/edx/kernel.in edx-sga==0.25.0 # via -r requirements/edx/bundled.in -edx-submissions==3.7.6 +edx-submissions==3.7.7 # via # -r requirements/edx/kernel.in # ora2 @@ -589,9 +591,9 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.139.0 +google-api-python-client==2.141.0 # via firebase-admin -google-auth==2.32.0 +google-auth==2.34.0 # via # google-api-core # google-api-python-client @@ -605,27 +607,27 @@ google-cloud-core==2.4.1 # via # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.0 +google-cloud-firestore==2.17.2 # via firebase-admin -google-cloud-storage==2.18.0 +google-cloud-storage==2.18.2 # via firebase-admin google-crc32c==1.5.0 # via # google-cloud-storage # google-resumable-media -google-resumable-media==2.7.1 +google-resumable-media==2.7.2 # via google-cloud-storage googleapis-common-protos==1.63.2 # via # google-api-core # grpcio-status -grpcio==1.65.4 +grpcio==1.65.5 # via # google-api-core # grpcio-status -grpcio-status==1.62.3 +grpcio-status==1.65.5 # via google-api-core -gunicorn==22.0.0 +gunicorn==23.0.0 # via -r requirements/edx/kernel.in help-tokens==2.4.0 # via -r requirements/edx/kernel.in @@ -646,7 +648,7 @@ idna==3.7 # requests # snowflake-connector-python # yarl -importlib-metadata==6.11.0 +importlib-metadata==8.3.0 # via -r requirements/edx/kernel.in inflection==0.5.1 # via @@ -755,7 +757,7 @@ monotonic==1.6 # via # analytics-python # py2neo -more-itertools==10.3.0 +more-itertools==10.4.0 # via cssutils mpmath==1.3.0 # via sympy @@ -767,13 +769,13 @@ multidict==6.0.5 # yarl mysqlclient==2.2.4 # via -r requirements/edx/kernel.in -newrelic==9.12.0 +newrelic==9.13.0 # via # -r requirements/edx/bundled.in # edx-django-utils nh3==0.2.18 # via -r requirements/edx/kernel.in -nltk==3.8.1 +nltk==3.9.1 # via chem nodeenv==1.9.1 # via -r requirements/edx/kernel.in @@ -821,7 +823,7 @@ openedx-filters==1.9.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -841,7 +843,7 @@ packaging==24.1 # snowflake-connector-python pansi==2020.7.3 # via py2neo -paramiko==3.4.0 +paramiko==3.4.1 # via edx-enterprise path==16.11.0 # via @@ -881,7 +883,7 @@ proto-plus==1.24.0 # via # google-api-core # google-cloud-firestore -protobuf==4.25.4 +protobuf==5.27.3 # via # google-api-core # google-cloud-firestore @@ -1022,7 +1024,7 @@ pytz==2024.1 # xblock pyuca==1.2 # via -r requirements/edx/kernel.in -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/edx/kernel.in # code-annotations @@ -1099,9 +1101,9 @@ scipy==1.14.0 # openedx-calc semantic-version==2.10.0 # via edx-drf-extensions -shapely==2.0.5 +shapely==2.0.6 # via -r requirements/edx/kernel.in -simplejson==3.19.2 +simplejson==3.19.3 # via # -r requirements/edx/kernel.in # sailthru-client @@ -1162,7 +1164,7 @@ sortedcontainers==2.4.0 # via # -r requirements/edx/kernel.in # snowflake-connector-python -soupsieve==2.5 +soupsieve==2.6 # via beautifulsoup4 sqlparse==0.5.1 # via django @@ -1179,7 +1181,7 @@ stevedore==5.2.0 # edx-opaque-keys super-csv==3.2.0 # via edx-bulk-grades -sympy==1.13.1 +sympy==1.13.2 # via openedx-calc testfixtures==8.3.0 # via edx-enterprise @@ -1187,7 +1189,7 @@ text-unidecode==1.3 # via python-slugify tinycss2==1.2.1 # via bleach -tomlkit==0.13.0 +tomlkit==0.13.2 # via snowflake-connector-python tqdm==4.66.5 # via @@ -1233,7 +1235,7 @@ voluptuous==0.15.2 # via ora2 walrus==0.9.4 # via edx-event-bus-redis -watchdog==4.0.1 +watchdog==4.0.2 # via -r requirements/edx/paver.txt wcwidth==0.2.13 # via prompt-toolkit @@ -1250,15 +1252,14 @@ webencodings==0.5.1 # bleach # html5lib # tinycss2 -webob==1.8.7 +webob==1.8.8 # via # -r requirements/edx/kernel.in # xblock wrapt==1.16.0 # via -r requirements/edx/paver.txt -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # acid-xblock # crowdsourcehinter-xblock @@ -1290,7 +1291,7 @@ xss-utils==0.6.0 # via -r requirements/edx/kernel.in yarl==1.9.4 # via aiohttp -zipp==3.19.2 +zipp==3.20.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 8155169e33d3..9ae45b7ff72c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -16,12 +16,12 @@ acid-xblock==0.3.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -aiohappyeyeballs==2.3.4 +aiohappyeyeballs==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp -aiohttp==3.10.1 +aiohttp==3.10.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -38,6 +38,7 @@ alabaster==1.0.0 # sphinx algoliasearch==3.0.0 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt amqp==5.2.0 @@ -96,7 +97,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.15.0 +babel==2.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -139,14 +140,14 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.34.154 +boto3==1.35.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.34.154 +botocore==1.35.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -165,7 +166,7 @@ cachecontrol==0.14.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -cachetools==5.4.0 +cachetools==5.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -740,7 +741,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.23.8 +edx-enterprise==4.23.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -813,7 +814,7 @@ edx-sga==0.25.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-submissions==3.7.6 +edx-submissions==3.7.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -876,13 +877,13 @@ execnet==2.1.1 # via # -r requirements/edx/testing.txt # pytest-xdist -factory-boy==3.3.0 +factory-boy==3.3.1 # via -r requirements/edx/testing.txt -faker==26.2.0 +faker==27.0.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.112.0 +fastapi==0.112.1 # via # -r requirements/edx/testing.txt # pact-python @@ -951,12 +952,12 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.139.0 +google-api-python-client==2.141.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-auth==2.32.0 +google-auth==2.34.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -977,12 +978,12 @@ google-cloud-core==2.4.1 # -r requirements/edx/testing.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.0 +google-cloud-firestore==2.17.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-cloud-storage==2.18.0 +google-cloud-storage==2.18.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -993,7 +994,7 @@ google-crc32c==1.5.0 # -r requirements/edx/testing.txt # google-cloud-storage # google-resumable-media -google-resumable-media==2.7.1 +google-resumable-media==2.7.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1008,18 +1009,18 @@ grimp==3.4.1 # via # -r requirements/edx/testing.txt # import-linter -grpcio==1.65.4 +grpcio==1.65.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grpcio-status==1.62.3 +grpcio-status==1.65.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core -gunicorn==22.0.0 +gunicorn==23.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1063,7 +1064,7 @@ imagesize==1.4.1 # sphinx import-linter==2.0 # via -r requirements/edx/testing.txt -importlib-metadata==6.11.0 +importlib-metadata==8.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1257,7 +1258,7 @@ monotonic==1.6 # -r requirements/edx/testing.txt # analytics-python # py2neo -more-itertools==10.3.0 +more-itertools==10.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1289,7 +1290,7 @@ mysqlclient==2.2.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -newrelic==9.12.0 +newrelic==9.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1298,7 +1299,7 @@ nh3==0.2.18 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -nltk==3.8.1 +nltk==3.9.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1371,7 +1372,7 @@ openedx-filters==1.9.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1411,7 +1412,7 @@ pansi==2020.7.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # py2neo -paramiko==3.4.0 +paramiko==3.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1491,7 +1492,7 @@ proto-plus==1.24.0 # -r requirements/edx/testing.txt # google-api-core # google-cloud-firestore -protobuf==4.25.4 +protobuf==5.27.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1777,7 +1778,7 @@ pyuca==1.2 # -r requirements/edx/testing.txt pywatchman==2.0.0 # via -r requirements/edx/development.in -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1888,11 +1889,11 @@ semantic-version==2.10.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-drf-extensions -shapely==2.0.5 +shapely==2.0.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -simplejson==3.19.2 +simplejson==3.19.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1980,7 +1981,7 @@ sortedcontainers==2.4.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # snowflake-connector-python -soupsieve==2.5 +soupsieve==2.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2048,7 +2049,7 @@ staff-graded-xblock==2.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -starlette==0.37.2 +starlette==0.38.2 # via # -r requirements/edx/testing.txt # fastapi @@ -2066,7 +2067,7 @@ super-csv==3.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-bulk-grades -sympy==1.13.1 +sympy==1.13.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2088,13 +2089,13 @@ tinycss2==1.2.1 # bleach tomli==2.0.1 # via django-stubs -tomlkit==0.13.0 +tomlkit==0.13.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pylint # snowflake-connector-python -tox==4.17.0 +tox==4.18.0 # via -r requirements/edx/testing.txt tqdm==4.66.5 # via @@ -2104,7 +2105,7 @@ tqdm==4.66.5 # openai types-pytz==2024.1.0.20240417 # via django-stubs -types-pyyaml==6.0.12.20240724 +types-pyyaml==6.0.12.20240808 # via # django-stubs # djangorestframework-stubs @@ -2163,7 +2164,7 @@ user-util==1.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -uvicorn==0.30.5 +uvicorn==0.30.6 # via # -r requirements/edx/testing.txt # pact-python @@ -2190,7 +2191,7 @@ walrus==0.9.4 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-event-bus-redis -watchdog==4.0.1 +watchdog==4.0.2 # via # -r requirements/edx/development.in # -r requirements/edx/doc.txt @@ -2216,7 +2217,7 @@ webencodings==0.5.1 # bleach # html5lib # tinycss2 -webob==1.8.7 +webob==1.8.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2230,9 +2231,8 @@ wrapt==1.16.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # astroid -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # acid-xblock @@ -2281,7 +2281,7 @@ yarl==1.9.4 # -r requirements/edx/testing.txt # aiohttp # pact-python -zipp==3.19.2 +zipp==3.20.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index bb81b7b71ac3..d315955694e7 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -10,11 +10,11 @@ accessible-pygments==0.0.5 # via pydata-sphinx-theme acid-xblock==0.3.1 # via -r requirements/edx/base.txt -aiohappyeyeballs==2.3.4 +aiohappyeyeballs==2.4.0 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.1 +aiohttp==3.10.5 # via # -r requirements/edx/base.txt # geoip2 @@ -26,7 +26,9 @@ aiosignal==1.3.1 alabaster==1.0.0 # via sphinx algoliasearch==3.0.0 - # via -r requirements/edx/base.txt + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/base.txt amqp==5.2.0 # via # -r requirements/edx/base.txt @@ -65,7 +67,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.15.0 +babel==2.16.0 # via # -r requirements/edx/base.txt # enmerkar @@ -100,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.34.154 +boto3==1.35.1 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.34.154 +botocore==1.35.1 # via # -r requirements/edx/base.txt # boto3 @@ -117,7 +119,7 @@ cachecontrol==0.14.0 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.4.0 +cachetools==5.5.0 # via # -r requirements/edx/base.txt # google-auth @@ -545,7 +547,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.23.8 +edx-enterprise==4.23.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -596,7 +598,7 @@ edx-search==4.0.0 # via -r requirements/edx/base.txt edx-sga==0.25.0 # via -r requirements/edx/base.txt -edx-submissions==3.7.6 +edx-submissions==3.7.7 # via # -r requirements/edx/base.txt # ora2 @@ -689,11 +691,11 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.139.0 +google-api-python-client==2.141.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.32.0 +google-auth==2.34.0 # via # -r requirements/edx/base.txt # google-api-core @@ -711,11 +713,11 @@ google-cloud-core==2.4.1 # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.0 +google-cloud-firestore==2.17.2 # via # -r requirements/edx/base.txt # firebase-admin -google-cloud-storage==2.18.0 +google-cloud-storage==2.18.2 # via # -r requirements/edx/base.txt # firebase-admin @@ -724,7 +726,7 @@ google-crc32c==1.5.0 # -r requirements/edx/base.txt # google-cloud-storage # google-resumable-media -google-resumable-media==2.7.1 +google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -733,16 +735,16 @@ googleapis-common-protos==1.63.2 # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio==1.65.4 +grpcio==1.65.5 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.62.3 +grpcio-status==1.65.5 # via # -r requirements/edx/base.txt # google-api-core -gunicorn==22.0.0 +gunicorn==23.0.0 # via -r requirements/edx/base.txt help-tokens==2.4.0 # via -r requirements/edx/base.txt @@ -766,7 +768,7 @@ idna==3.7 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==6.11.0 +importlib-metadata==8.3.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via @@ -902,7 +904,7 @@ monotonic==1.6 # -r requirements/edx/base.txt # analytics-python # py2neo -more-itertools==10.3.0 +more-itertools==10.4.0 # via # -r requirements/edx/base.txt # cssutils @@ -921,13 +923,13 @@ multidict==6.0.5 # yarl mysqlclient==2.2.4 # via -r requirements/edx/base.txt -newrelic==9.12.0 +newrelic==9.13.0 # via # -r requirements/edx/base.txt # edx-django-utils nh3==0.2.18 # via -r requirements/edx/base.txt -nltk==3.8.1 +nltk==3.9.1 # via # -r requirements/edx/base.txt # chem @@ -980,7 +982,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1005,7 +1007,7 @@ pansi==2020.7.3 # via # -r requirements/edx/base.txt # py2neo -paramiko==3.4.0 +paramiko==3.4.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1058,7 +1060,7 @@ proto-plus==1.24.0 # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==4.25.4 +protobuf==5.27.3 # via # -r requirements/edx/base.txt # google-api-core @@ -1227,7 +1229,7 @@ pytz==2024.1 # xblock pyuca==1.2 # via -r requirements/edx/base.txt -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/edx/base.txt # code-annotations @@ -1319,9 +1321,9 @@ semantic-version==2.10.0 # via # -r requirements/edx/base.txt # edx-drf-extensions -shapely==2.0.5 +shapely==2.0.6 # via -r requirements/edx/base.txt -simplejson==3.19.2 +simplejson==3.19.3 # via # -r requirements/edx/base.txt # sailthru-client @@ -1388,7 +1390,7 @@ sortedcontainers==2.4.0 # via # -r requirements/edx/base.txt # snowflake-connector-python -soupsieve==2.5 +soupsieve==2.6 # via # -r requirements/edx/base.txt # beautifulsoup4 @@ -1447,7 +1449,7 @@ super-csv==3.2.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.1 +sympy==1.13.2 # via # -r requirements/edx/base.txt # openedx-calc @@ -1463,7 +1465,7 @@ tinycss2==1.2.1 # via # -r requirements/edx/base.txt # bleach -tomlkit==0.13.0 +tomlkit==0.13.2 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -1521,7 +1523,7 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==4.0.1 +watchdog==4.0.2 # via -r requirements/edx/base.txt wcwidth==0.2.13 # via @@ -1541,15 +1543,14 @@ webencodings==0.5.1 # bleach # html5lib # tinycss2 -webob==1.8.7 +webob==1.8.8 # via # -r requirements/edx/base.txt # xblock wrapt==1.16.0 # via -r requirements/edx/base.txt -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # acid-xblock # crowdsourcehinter-xblock @@ -1585,7 +1586,7 @@ yarl==1.9.4 # via # -r requirements/edx/base.txt # aiohttp -zipp==3.19.2 +zipp==3.20.0 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt index 0b82d71c91e6..faa0085f1631 100644 --- a/requirements/edx/paver.txt +++ b/requirements/edx/paver.txt @@ -61,7 +61,7 @@ urllib3==1.26.19 # via # -c requirements/edx/../constraints.txt # requests -watchdog==4.0.1 +watchdog==4.0.2 # via -r requirements/edx/paver.in wrapt==1.16.0 # via -r requirements/edx/paver.in diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 18c988dfb622..fcb7db05a5ef 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -8,11 +8,11 @@ # via -r requirements/edx/base.txt acid-xblock==0.3.1 # via -r requirements/edx/base.txt -aiohappyeyeballs==2.3.4 +aiohappyeyeballs==2.4.0 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.1 +aiohttp==3.10.5 # via # -r requirements/edx/base.txt # geoip2 @@ -22,7 +22,9 @@ aiosignal==1.3.1 # -r requirements/edx/base.txt # aiohttp algoliasearch==3.0.0 - # via -r requirements/edx/base.txt + # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/base.txt amqp==5.2.0 # via # -r requirements/edx/base.txt @@ -67,7 +69,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.15.0 +babel==2.16.0 # via # -r requirements/edx/base.txt # enmerkar @@ -100,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.34.154 +boto3==1.35.1 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.34.154 +botocore==1.35.1 # via # -r requirements/edx/base.txt # boto3 @@ -117,7 +119,7 @@ cachecontrol==0.14.0 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.4.0 +cachetools==5.5.0 # via # -r requirements/edx/base.txt # google-auth @@ -569,7 +571,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.23.8 +edx-enterprise==4.23.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -622,7 +624,7 @@ edx-search==4.0.0 # via -r requirements/edx/base.txt edx-sga==0.25.0 # via -r requirements/edx/base.txt -edx-submissions==3.7.6 +edx-submissions==3.7.7 # via # -r requirements/edx/base.txt # ora2 @@ -670,11 +672,11 @@ event-tracking==3.0.0 # edx-search execnet==2.1.1 # via pytest-xdist -factory-boy==3.3.0 +factory-boy==3.3.1 # via -r requirements/edx/testing.in -faker==26.2.0 +faker==27.0.0 # via factory-boy -fastapi==0.112.0 +fastapi==0.112.1 # via pact-python fastavro==1.9.5 # via @@ -723,11 +725,11 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.139.0 +google-api-python-client==2.141.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.32.0 +google-auth==2.34.0 # via # -r requirements/edx/base.txt # google-api-core @@ -745,11 +747,11 @@ google-cloud-core==2.4.1 # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.0 +google-cloud-firestore==2.17.2 # via # -r requirements/edx/base.txt # firebase-admin -google-cloud-storage==2.18.0 +google-cloud-storage==2.18.2 # via # -r requirements/edx/base.txt # firebase-admin @@ -758,7 +760,7 @@ google-crc32c==1.5.0 # -r requirements/edx/base.txt # google-cloud-storage # google-resumable-media -google-resumable-media==2.7.1 +google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -769,16 +771,16 @@ googleapis-common-protos==1.63.2 # grpcio-status grimp==3.4.1 # via import-linter -grpcio==1.65.4 +grpcio==1.65.5 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.62.3 +grpcio-status==1.65.5 # via # -r requirements/edx/base.txt # google-api-core -gunicorn==22.0.0 +gunicorn==23.0.0 # via -r requirements/edx/base.txt h11==0.14.0 # via uvicorn @@ -807,7 +809,7 @@ idna==3.7 # yarl import-linter==2.0 # via -r requirements/edx/testing.in -importlib-metadata==6.11.0 +importlib-metadata==8.3.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via @@ -953,7 +955,7 @@ monotonic==1.6 # -r requirements/edx/base.txt # analytics-python # py2neo -more-itertools==10.3.0 +more-itertools==10.4.0 # via # -r requirements/edx/base.txt # cssutils @@ -972,13 +974,13 @@ multidict==6.0.5 # yarl mysqlclient==2.2.4 # via -r requirements/edx/base.txt -newrelic==9.12.0 +newrelic==9.13.0 # via # -r requirements/edx/base.txt # edx-django-utils nh3==0.2.18 # via -r requirements/edx/base.txt -nltk==3.8.1 +nltk==3.9.1 # via # -r requirements/edx/base.txt # chem @@ -1031,7 +1033,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.10.1 +openedx-learning==0.11.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1059,7 +1061,7 @@ pansi==2020.7.3 # via # -r requirements/edx/base.txt # py2neo -paramiko==3.4.0 +paramiko==3.4.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1120,7 +1122,7 @@ proto-plus==1.24.0 # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==4.25.4 +protobuf==5.27.3 # via # -r requirements/edx/base.txt # google-api-core @@ -1345,7 +1347,7 @@ pytz==2024.1 # xblock pyuca==1.2 # via -r requirements/edx/base.txt -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r requirements/edx/base.txt # code-annotations @@ -1436,9 +1438,9 @@ semantic-version==2.10.0 # via # -r requirements/edx/base.txt # edx-drf-extensions -shapely==2.0.5 +shapely==2.0.6 # via -r requirements/edx/base.txt -simplejson==3.19.2 +simplejson==3.19.3 # via # -r requirements/edx/base.txt # sailthru-client @@ -1506,7 +1508,7 @@ sortedcontainers==2.4.0 # via # -r requirements/edx/base.txt # snowflake-connector-python -soupsieve==2.5 +soupsieve==2.6 # via # -r requirements/edx/base.txt # beautifulsoup4 @@ -1516,7 +1518,7 @@ sqlparse==0.5.1 # django staff-graded-xblock==2.3.0 # via -r requirements/edx/base.txt -starlette==0.37.2 +starlette==0.38.2 # via fastapi stevedore==5.2.0 # via @@ -1530,7 +1532,7 @@ super-csv==3.2.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.1 +sympy==1.13.2 # via # -r requirements/edx/base.txt # openedx-calc @@ -1547,12 +1549,12 @@ tinycss2==1.2.1 # via # -r requirements/edx/base.txt # bleach -tomlkit==0.13.0 +tomlkit==0.13.2 # via # -r requirements/edx/base.txt # pylint # snowflake-connector-python -tox==4.17.0 +tox==4.18.0 # via -r requirements/edx/testing.in tqdm==4.66.5 # via @@ -1598,7 +1600,7 @@ urllib3==1.26.19 # requests user-util==1.1.0 # via -r requirements/edx/base.txt -uvicorn==0.30.5 +uvicorn==0.30.6 # via pact-python vine==5.1.0 # via @@ -1616,7 +1618,7 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==4.0.1 +watchdog==4.0.2 # via -r requirements/edx/base.txt wcwidth==0.2.13 # via @@ -1636,7 +1638,7 @@ webencodings==0.5.1 # bleach # html5lib # tinycss2 -webob==1.8.7 +webob==1.8.8 # via # -r requirements/edx/base.txt # xblock @@ -1644,9 +1646,8 @@ wrapt==1.16.0 # via # -r requirements/edx/base.txt # astroid -xblock[django]==4.0.1 +xblock[django]==5.1.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # acid-xblock # crowdsourcehinter-xblock @@ -1683,7 +1684,7 @@ yarl==1.9.4 # -r requirements/edx/base.txt # aiohttp # pact-python -zipp==3.19.2 +zipp==3.20.0 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/pip.txt b/requirements/pip.txt index 7a6ada8e0a92..f0cf3d109992 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -10,5 +10,5 @@ wheel==0.44.0 # The following packages are considered to be unsafe in a requirements file: pip==24.2 # via -r requirements/pip.in -setuptools==72.1.0 +setuptools==73.0.0 # via -r requirements/pip.in diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 47e6e79c2240..3e5ed4738070 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -10,13 +10,13 @@ attrs==24.2.0 # via zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.in -boto3==1.34.154 +boto3==1.35.1 # via -r scripts/user_retirement/requirements/base.in -botocore==1.34.154 +botocore==1.35.1 # via # boto3 # s3transfer -cachetools==5.4.0 +cachetools==5.5.0 # via google-auth certifi==2024.7.4 # via requests @@ -52,9 +52,9 @@ edx-rest-api-client==5.7.1 # via -r scripts/user_retirement/requirements/base.in google-api-core==2.19.1 # via google-api-python-client -google-api-python-client==2.139.0 +google-api-python-client==2.141.0 # via -r scripts/user_retirement/requirements/base.in -google-auth==2.32.0 +google-auth==2.34.0 # via # google-api-core # google-api-python-client @@ -81,9 +81,9 @@ lxml==4.9.4 # via # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # zeep -more-itertools==10.3.0 +more-itertools==10.4.0 # via simple-salesforce -newrelic==9.12.0 +newrelic==9.13.0 # via edx-django-utils pbr==6.0.0 # via stevedore @@ -120,7 +120,7 @@ pytz==2024.1 # via # jenkinsapi # zeep -pyyaml==6.0.1 +pyyaml==6.0.2 # via -r scripts/user_retirement/requirements/base.in requests==2.32.3 # via @@ -143,7 +143,7 @@ s3transfer==0.10.2 # via boto3 simple-salesforce==1.12.6 # via -r scripts/user_retirement/requirements/base.in -simplejson==3.19.2 +simplejson==3.19.3 # via -r scripts/user_retirement/requirements/base.in six==1.16.0 # via diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index 006eabeef436..f7464dfa0602 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -14,17 +14,17 @@ attrs==24.2.0 # zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.txt -boto3==1.34.154 +boto3==1.35.1 # via # -r scripts/user_retirement/requirements/base.txt # moto -botocore==1.34.154 +botocore==1.35.1 # via # -r scripts/user_retirement/requirements/base.txt # boto3 # moto # s3transfer -cachetools==5.4.0 +cachetools==5.5.0 # via # -r scripts/user_retirement/requirements/base.txt # google-auth @@ -76,9 +76,9 @@ google-api-core==2.19.1 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -google-api-python-client==2.139.0 +google-api-python-client==2.141.0 # via -r scripts/user_retirement/requirements/base.txt -google-auth==2.32.0 +google-auth==2.34.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -126,13 +126,13 @@ markupsafe==2.1.5 # werkzeug mock==5.1.0 # via -r scripts/user_retirement/requirements/testing.in -more-itertools==10.3.0 +more-itertools==10.4.0 # via # -r scripts/user_retirement/requirements/base.txt # simple-salesforce moto==4.2.14 # via -r scripts/user_retirement/requirements/testing.in -newrelic==9.12.0 +newrelic==9.13.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils @@ -200,7 +200,7 @@ pytz==2024.1 # -r scripts/user_retirement/requirements/base.txt # jenkinsapi # zeep -pyyaml==6.0.1 +pyyaml==6.0.2 # via # -r scripts/user_retirement/requirements/base.txt # responses @@ -242,7 +242,7 @@ s3transfer==0.10.2 # boto3 simple-salesforce==1.12.6 # via -r scripts/user_retirement/requirements/base.txt -simplejson==3.19.2 +simplejson==3.19.3 # via -r scripts/user_retirement/requirements/base.txt six==1.16.0 # via 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 @@ {% filter force_escape %} {% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %} {% endfilter %} diff --git a/webpack.builtinblocks.config.js b/webpack.builtinblocks.config.js index 53ce30a9c0e2..d86f891dc6ce 100644 --- a/webpack.builtinblocks.config.js +++ b/webpack.builtinblocks.config.js @@ -1,17 +1,5 @@ module.exports = { entry: { - AboutBlockDisplay: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/html/display.js', - './xmodule/js/src/javascript_loader.js', - './xmodule/js/src/collapsible.js', - './xmodule/js/src/html/imageModal.js', - './xmodule/js/common_static/js/vendor/draggabilly.js' - ], - AboutBlockEditor: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/html/edit.js' - ], AnnotatableBlockDisplay: [ './xmodule/js/src/xmodule.js', './xmodule/js/src/html/display.js', @@ -33,18 +21,6 @@ module.exports = { './xmodule/js/src/xmodule.js', './xmodule/js/src/sequence/edit.js' ], - CourseInfoBlockDisplay: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/html/display.js', - './xmodule/js/src/javascript_loader.js', - './xmodule/js/src/collapsible.js', - './xmodule/js/src/html/imageModal.js', - './xmodule/js/common_static/js/vendor/draggabilly.js' - ], - CourseInfoBlockEditor: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/html/edit.js' - ], CustomTagBlockDisplay: './xmodule/js/src/xmodule.js', CustomTagBlockEditor: [ './xmodule/js/src/xmodule.js', @@ -104,18 +80,6 @@ module.exports = { './xmodule/js/src/xmodule.js', './xmodule/js/src/sequence/edit.js' ], - StaticTabBlockDisplay: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/html/display.js', - './xmodule/js/src/javascript_loader.js', - './xmodule/js/src/collapsible.js', - './xmodule/js/src/html/imageModal.js', - './xmodule/js/common_static/js/vendor/draggabilly.js' - ], - StaticTabBlockEditor: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/html/edit.js' - ], VideoBlockDisplay: [ './xmodule/js/src/xmodule.js', './xmodule/js/src/video/10_main.js' 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; diff --git a/xmodule/mongo_utils.py b/xmodule/mongo_utils.py index b86abd28b466..5aecbfc405df 100644 --- a/xmodule/mongo_utils.py +++ b/xmodule/mongo_utils.py @@ -30,8 +30,11 @@ def connect_to_mongodb( handles AutoReconnect errors by retrying read operations, since these exceptions typically indicate a temporary step-down condition for MongoDB. """ - # If the MongoDB server uses a separate authentication database that should be specified here - auth_source = kwargs.get('authsource', '') or None + # If the MongoDB server uses a separate authentication database that should be specified here. + # Convert the lowercased authsource parameter to the camel-cased authSource expected by MongoClient. + auth_source = db + if auth_source_key := {'authSource', 'authsource'}.intersection(set(kwargs.keys())): + auth_source = kwargs.pop(auth_source_key.pop()) or db # sanitize a kwarg which may be present and is no longer expected # AED 2020-03-02 TODO: Remove this when 'auth_source' will no longer exist in kwargs @@ -63,7 +66,7 @@ def connect_to_mongodb( } if user is not None and password is not None and not db.startswith('test_'): - connection_params.update({'username': user, 'password': password, 'authSource': db}) + connection_params.update({'username': user, 'password': password, 'authSource': auth_source}) mongo_conn = pymongo.MongoClient(**connection_params)