From 28eb406f8daea86226f630d98f9fc5882fef447f Mon Sep 17 00:00:00 2001 From: Ivan Niedielnitsev <81557788+niedielnitsevivan@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:42:04 +0200 Subject: [PATCH 01/67] feat: [FC-0047] add settings for edx-ace push notifications feat: [FC-0047] Add push notifications for user enroll feat: [FC-0047] Add push notifications for user unenroll feat: [FC-0047] Add push notifications for add course beta testers feat: [FC-0047] Add push notifications for remove course beta testers feat: [FC-0047] Add push notification event to discussions --- lms/djangoapps/discussion/signals/handlers.py | 2 + lms/djangoapps/discussion/tasks.py | 71 ++++++++++++++++--- .../edx_ace/commentnotification/push/body.txt | 3 + .../commentnotification/push/subject.txt | 3 + .../responsenotification/push/body.txt | 2 + .../responsenotification/push/subject.txt | 2 + lms/djangoapps/discussion/tests/test_tasks.py | 22 +++++- lms/djangoapps/instructor/enrollment.py | 20 ++++++ .../mobile_api/notifications/__init__.py | 0 .../mobile_api/notifications/urls.py | 10 +++ .../mobile_api/notifications/views.py | 50 +++++++++++++ lms/djangoapps/mobile_api/urls.py | 1 + .../edx_ace/addbetatester/push/body.txt | 5 ++ .../edx_ace/addbetatester/push/subject.txt | 4 ++ .../edx_ace/allowedenroll/push/body.txt | 5 ++ .../edx_ace/allowedenroll/push/subject.txt | 4 ++ .../edx_ace/allowedunenroll/push/body.txt | 5 ++ .../edx_ace/allowedunenroll/push/subject.txt | 4 ++ .../edx_ace/enrolledunenroll/push/body.txt | 5 ++ .../edx_ace/enrolledunenroll/push/subject.txt | 4 ++ .../edx_ace/enrollenrolled/push/body.txt | 5 ++ .../edx_ace/enrollenrolled/push/subject.txt | 4 ++ .../edx_ace/removebetatester/push/body.txt | 5 ++ .../edx_ace/removebetatester/push/subject.txt | 4 ++ .../djangoapps/ace_common/settings/common.py | 30 ++++++++ .../ace_common/settings/production.py | 24 +++++++ openedx/core/djangoapps/ace_common/utils.py | 25 +++++++ .../comment_client/comment.py | 10 ++- .../core/djangoapps/notifications/policies.py | 41 +++++++++++ requirements/edx/base.txt | 2 + requirements/edx/development.txt | 4 ++ requirements/edx/doc.txt | 2 + requirements/edx/github.in | 2 + requirements/edx/testing.txt | 2 + setup.py | 3 +- 35 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt create mode 100644 lms/djangoapps/mobile_api/notifications/__init__.py create mode 100644 lms/djangoapps/mobile_api/notifications/urls.py create mode 100644 lms/djangoapps/mobile_api/notifications/views.py create mode 100644 lms/templates/instructor/edx_ace/addbetatester/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/addbetatester/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/allowedenroll/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/removebetatester/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/removebetatester/push/subject.txt create mode 100644 openedx/core/djangoapps/ace_common/utils.py create mode 100644 openedx/core/djangoapps/notifications/policies.py diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 2aa7d36456c4..73c19d27858c 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -109,8 +109,10 @@ def create_message_context(comment, site): 'course_id': str(thread.course_id), 'comment_id': comment.id, 'comment_body': comment.body, + 'comment_body_text': comment.body_text, 'comment_author_id': comment.user_id, 'comment_created_at': comment.created_at, # comment_client models dates are already serialized + 'comment_parent_id': comment.parent_id, 'thread_id': thread.id, 'thread_title': thread.title, 'thread_author_id': thread.user_id, diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index d483a82dbd66..23a641fae958 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.sites.models import Site from edx_ace import ace +from edx_ace.channel import ChannelType from edx_ace.recipient import Recipient from edx_ace.utils import date from edx_django_utils.monitoring import set_code_owner_attribute @@ -74,6 +75,12 @@ def __init__(self, *args, **kwargs): self.options['transactional'] = True +class CommentNotification(BaseMessageType): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.options['transactional'] = True + + @shared_task(base=LoggedTask) @set_code_owner_attribute def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring @@ -82,17 +89,40 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function if _should_send_message(context): context['site'] = Site.objects.get(id=context['site_id']) thread_author = User.objects.get(id=context['thread_author_id']) - with emulate_http_request(site=context['site'], user=thread_author): - message_context = _build_message_context(context) + comment_author = User.objects.get(id=context['comment_author_id']) + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context, notification_type='forum_response') message = ResponseNotification().personalize( Recipient(thread_author.id, thread_author.email), _get_course_language(context['course_id']), message_context ) - log.info('Sending forum comment email notification with context %s', message_context) - ace.send(message) + log.info('Sending forum comment notification with context %s', message_context) + if _is_first_comment(context['comment_id'], context['thread_id']): + limit_to_channels = None + else: + limit_to_channels = [ChannelType.PUSH] + ace.send(message, limit_to_channels=limit_to_channels) _track_notification_sent(message, context) + elif _should_send_subcomment_message(context): + context['site'] = Site.objects.get(id=context['site_id']) + comment_author = User.objects.get(id=context['comment_author_id']) + thread_author = User.objects.get(id=context['thread_author_id']) + + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context) + message = CommentNotification().personalize( + Recipient(thread_author.id, thread_author.email), + _get_course_language(context['course_id']), + message_context + ) + log.info('Sending forum comment notification with context %s', message_context) + ace.send(message, limit_to_channels=[ChannelType.PUSH]) + _track_notification_sent(message, context) + else: + return + @shared_task(base=LoggedTask) @set_code_owner_attribute @@ -154,19 +184,36 @@ def _should_send_message(context): return ( _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and _is_not_subcomment(context['comment_id']) and - _is_first_comment(context['comment_id'], context['thread_id']) + not _comment_author_is_thread_author(context) ) +def _should_send_subcomment_message(context): + cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id']) + return ( + _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and + _is_subcomment(context['comment_id']) and + not _comment_author_is_thread_author(context) + ) + + +def _comment_author_is_thread_author(context): + return context.get('comment_author_id', '') == context['thread_author_id'] + + def _is_content_still_reported(context): if context.get('comment_id') is not None: return len(cc.Comment.find(context['comment_id']).abuse_flaggers) > 0 return len(cc.Thread.find(context['thread_id']).abuse_flaggers) > 0 -def _is_not_subcomment(comment_id): +def _is_subcomment(comment_id): comment = cc.Comment.find(id=comment_id).retrieve() - return not getattr(comment, 'parent_id', None) + return getattr(comment, 'parent_id', None) + + +def _is_not_subcomment(comment_id): + return not _is_subcomment(comment_id) def _is_first_comment(comment_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring @@ -204,7 +251,7 @@ def _get_course_language(course_id): return language -def _build_message_context(context): # lint-amnesty, pylint: disable=missing-function-docstring +def _build_message_context(context, notification_type='forum_comment'): # lint-amnesty, pylint: disable=missing-function-docstring message_context = get_base_template_context(context['site']) message_context.update(context) thread_author = User.objects.get(id=context['thread_author_id']) @@ -218,6 +265,14 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu 'thread_username': thread_author.username, 'comment_username': comment_author.username, 'post_link': post_link, + 'push_notification_extra_context': { + 'course_id': str(context['course_id']), + 'parent_id': str(context['comment_parent_id']), + 'notification_type': notification_type, + 'topic_id': str(context['thread_commentable_id']), + 'thread_id': context['thread_id'], + 'comment_id': context['comment_id'], + }, 'comment_created_at': date.deserialize(context['comment_created_at']), 'thread_created_at': date.deserialize(context['thread_created_at']) }) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt new file mode 100644 index 000000000000..391e3d8ef4d7 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt @@ -0,0 +1,3 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %} +{{ comment_body_text }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt new file mode 100644 index 000000000000..d2298a812990 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt new file mode 100644 index 000000000000..c1fe3ba35b7f --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt new file mode 100644 index 000000000000..03caca997346 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index f6cce4437546..950950210d47 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -19,7 +19,7 @@ import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY -from lms.djangoapps.discussion.tasks import _should_send_message, _track_notification_sent +from lms.djangoapps.discussion.tasks import _is_first_comment, _should_send_message, _track_notification_sent from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ForumsConfig @@ -222,6 +222,8 @@ def setUp(self): self.ace_send_patcher = mock.patch('edx_ace.ace.send') self.mock_ace_send = self.ace_send_patcher.start() + self.mock_message_patcher = mock.patch('lms.djangoapps.discussion.tasks.ResponseNotification') + self.mock_message = self.mock_message_patcher.start() thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) @@ -231,10 +233,12 @@ def tearDown(self): super().tearDown() self.request_patcher.stop() self.ace_send_patcher.stop() + self.mock_message_patcher.stop() self.permalink_patcher.stop() @ddt.data(True, False) def test_send_discussion_email_notification(self, user_subscribed): + self.mock_message_patcher.stop() if user_subscribed: non_matching_id = 'not-a-match' # with per_page left with a default value of 1, this ensures @@ -271,8 +275,10 @@ def test_send_discussion_email_notification(self, user_subscribed): expected_message_context.update({ 'comment_author_id': self.comment_author.id, 'comment_body': comment['body'], + 'comment_body_text': comment.body_text, 'comment_created_at': ONE_HOUR_AGO, 'comment_id': comment['id'], + 'comment_parent_id': comment['parent_id'], 'comment_username': self.comment_author.username, 'course_id': self.course.id, 'thread_author_id': self.thread_author.id, @@ -283,7 +289,15 @@ def test_send_discussion_email_notification(self, user_subscribed): 'thread_commentable_id': thread['commentable_id'], 'post_link': f'https://{site.domain}{self.mock_permalink.return_value}', 'site': site, - 'site_id': site.id + 'site_id': site.id, + 'push_notification_extra_context': { + 'notification_type': 'forum_response', + 'topic_id': thread['commentable_id'], + 'course_id': comment['course_id'], + 'parent_id': str(comment['parent_id']), + 'thread_id': thread['id'], + 'comment_id': comment['id'], + }, }) expected_recipient = Recipient(self.thread_author.id, self.thread_author.email) actual_message = self.mock_ace_send.call_args_list[0][0][0] @@ -326,7 +340,9 @@ def run_should_not_send_email_test(self, thread, comment_dict): 'comment_id': comment_dict['id'], 'thread_id': thread['id'], }) - assert actual_result is False + + should_email_send = _is_first_comment(comment_dict['id'], thread['id']) + assert should_email_send is False assert not self.mock_ace_send.called def test_subcomment_should_not_send_email(self): diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 896d0deadcd9..2c4294cad98b 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -142,6 +142,14 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal """ previous_state = EmailEnrollmentState(course_id, student_email) enrollment_obj = None + if email_params: + email_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'enroll', + 'course_id': str(course_id), + }, + }) if previous_state.user and previous_state.user.is_active: # if the student is currently unenrolled, don't enroll them in their # previous mode @@ -195,6 +203,13 @@ def unenroll_email(course_id, student_email, email_students=False, email_params= representing state before and after the action. """ previous_state = EmailEnrollmentState(course_id, student_email) + if email_params: + email_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'unenroll', + }, + }) if previous_state.enrollment: CourseEnrollment.unenroll_by_email(student_email, course_id) if email_students: @@ -233,6 +248,11 @@ def send_beta_role_email(action, user, email_params): email_params['email_address'] = user.email email_params['user_id'] = user.id email_params['full_name'] = user.profile.name + email_params['app_label'] = 'instructor' + email_params['push_notification_extra_context'] = { + 'notification_type': email_params['message_type'], + 'course_id': str(getattr(email_params.get('course'), 'id', '')), + } else: raise ValueError(f"Unexpected action received '{action}' - expected 'add' or 'remove'") trying_to_add_inactive_user = not user.is_active and action == 'add' diff --git a/lms/djangoapps/mobile_api/notifications/__init__.py b/lms/djangoapps/mobile_api/notifications/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/notifications/urls.py b/lms/djangoapps/mobile_api/notifications/urls.py new file mode 100644 index 000000000000..17b970916a47 --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import GCMDeviceViewSet + + +CREATE_GCM_DEVICE = GCMDeviceViewSet.as_view({'post': 'create'}) + + +urlpatterns = [ + path('create-token/', CREATE_GCM_DEVICE, name='gcmdevice-list'), +] diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py new file mode 100644 index 000000000000..4c94ae576e76 --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -0,0 +1,50 @@ +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from edx_ace.push_notifications.views import GCMDeviceViewSet as GCMDeviceViewSetBase + +from ..decorators import mobile_view + + +@mobile_view() +class GCMDeviceViewSet(GCMDeviceViewSetBase): + """ + **Use Case** + This endpoint allows clients to register a device for push notifications. + + If the device is already registered, the existing registration will be updated. + If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error. + + **Example Request** + POST /api/mobile/{version}/notifications/create-token/ + **POST Parameters** + The body of the POST request can include the following parameters. + * name (optional) - A name of the device. + * registration_id (required) - The device token of the device. + * device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex) + * active (optional) - Whether the device is active, default is True. + If False, the device will not receive notifications. + * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported. + * application_id (optional) - Opaque application identity, should be filled in for multiple + key/certificate access. + **Example Response** + ```json + { + "id": 1, + "name": "My Device", + "registration_id": "fj3j4", + "device_id": 1234, + "active": true, + "date_created": "2024-04-18T07:39:37.132787Z", + "cloud_message_type": "FCM", + "application_id": "my_app_id" + } + ``` + """ + + def create(self, request, *args, **kwargs): + if not getattr(settings, 'PUSH_NOTIFICATIONS_SETTINGS', None): + return Response('Push notifications are not configured.', status.HTTP_501_NOT_IMPLEMENTED) + + return super().create(request, *args, **kwargs) diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py index 1ad34ced5de9..c7aacc0b669a 100644 --- a/lms/djangoapps/mobile_api/urls.py +++ b/lms/djangoapps/mobile_api/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('users/', include('lms.djangoapps.mobile_api.users.urls')), path('my_user_info', my_user_info, name='user-info'), + path('notifications/', include('lms.djangoapps.mobile_api.notifications.urls')), path('course_info/', include('lms.djangoapps.mobile_api.course_info.urls')), ] diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/body.txt b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt new file mode 100644 index 000000000000..8373638fb41f --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been invited to be a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt b/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt new file mode 100644 index 000000000000..f1c4c6826cfa --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to a beta test for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt new file mode 100644 index 000000000000..14e4915f86e2 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear student,{% endblocktrans %} +{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }}. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt new file mode 100644 index 000000000000..865657f1fcb1 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to register for {{ course_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt new file mode 100644 index 000000000000..b825ce1d4d18 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear Student,{% endblocktrans %} +{% blocktrans %}You have been unenrolled from the course {{ course_name }}. Please disregard the invitation previously sent.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt new file mode 100644 index 000000000000..2bc61a840b48 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been unenrolled from {{ course_name }} at {{ site_name }}. This course will no longer appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt new file mode 100644 index 000000000000..e5ef12dc5f75 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been invited to join {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt new file mode 100644 index 000000000000..ebe884b30f08 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been enrolled in {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/body.txt b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt new file mode 100644 index 000000000000..89573aa4be1d --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}. This course will remain on your dashboard, but you will no longer be part of the beta testing group.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt b/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt new file mode 100644 index 000000000000..c09febbb455c --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index 11bfbce5c59f..993412f392bb 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -1,11 +1,14 @@ """ Settings for ace_common app. """ +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app ACE_ROUTING_KEY = 'edx.lms.core.default' def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function-docstring, missing-module-docstring + if 'push_notifications' not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append('push_notifications') settings.ACE_ENABLED_CHANNELS = [ 'django_email' ] @@ -22,3 +25,30 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function settings.ACE_ROUTING_KEY = ACE_ROUTING_KEY settings.FEATURES['test_django_plugin'] = True + settings.FCM_APP_NAME = 'fcm-edx-platform' + + settings.ACE_CHANNEL_DEFAULT_PUSH = 'push_notification' + # Note: To local development with Firebase, you must set FIREBASE_CREDENTIALS_PATH + # (path to json file with FIREBASE_CREDENTIALS) + # or FIREBASE_CREDENTIALS dictionary. + settings.FIREBASE_CREDENTIALS_PATH = None + settings.FIREBASE_CREDENTIALS = None + + settings.FIREBASE_APP = setup_firebase_app( + settings.FIREBASE_CREDENTIALS_PATH or settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME + ) + + if getattr(settings, 'FIREBASE_APP', None): + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('bulk_push_notification_optout') + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index cc4da91c18db..48da799f00b4 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -1,4 +1,5 @@ """Common environment variables unique to the ace_common plugin.""" +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app def plugin_settings(settings): @@ -26,3 +27,26 @@ def plugin_settings(settings): settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL = settings.ENV_TOKENS.get( 'ACE_CHANNEL_TRANSACTIONAL_EMAIL', settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL ) + settings.FCM_APP_NAME = settings.ENV_TOKENS.get('FCM_APP_NAME', settings.FCM_APP_NAME) + settings.FIREBASE_CREDENTIALS_PATH = settings.ENV_TOKENS.get( + 'FIREBASE_CREDENTIALS_PATH', settings.FIREBASE_CREDENTIALS_PATH + ) + settings.FIREBASE_CREDENTIALS = settings.ENV_TOKENS.get('FIREBASE_CREDENTIALS', settings.FIREBASE_CREDENTIALS) + + settings.FIREBASE_APP = setup_firebase_app( + settings.FIREBASE_CREDENTIALS_PATH or settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME + ) + if settings.FIREBASE_APP: + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('bulk_push_notification_optout') + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py new file mode 100644 index 000000000000..508ac4033cd1 --- /dev/null +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -0,0 +1,25 @@ +""" +Utility functions for edx-ace. +""" +import logging + +log = logging.getLogger(__name__) + + +def setup_firebase_app(firebase_credentials, app_name='fcm-app'): + """ + Returns a Firebase app instance if the Firebase credentials are provided. + """ + try: + import firebase_admin # pylint: disable=import-outside-toplevel + except ImportError: + log.error('Could not import firebase_admin package.') + return + + if firebase_credentials: + try: + app = firebase_admin.get_app(app_name) + except ValueError: + certificate = firebase_admin.credentials.Certificate(firebase_credentials) + app = firebase_admin.initialize_app(certificate, name=app_name) + return app diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 0b7a695a1c3e..c86f7eb40515 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -1,5 +1,5 @@ # pylint: disable=missing-docstring,protected-access - +from bs4 import BeautifulSoup from openedx.core.djangoapps.django_comment_common.comment_client import models, settings @@ -99,6 +99,14 @@ def unFlagAbuse(self, user, voteable, removeAll): ) voteable._update_from_response(response) + @property + def body_text(self): + """ + Return the text content of the comment html body. + """ + soup = BeautifulSoup(self.body, 'html.parser') + return soup.get_text() + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/notifications/policies.py b/openedx/core/djangoapps/notifications/policies.py new file mode 100644 index 000000000000..8467eda9324e --- /dev/null +++ b/openedx/core/djangoapps/notifications/policies.py @@ -0,0 +1,41 @@ +"""Policies for the notifications app.""" + +from edx_ace.channel import ChannelType +from edx_ace.policy import Policy, PolicyResult +from opaque_keys.edx.keys import CourseKey + +from .models import CourseNotificationPreference + + +class CoursePushNotificationOptout(Policy): + """ + Course Push Notification optOut Policy. + """ + + def check(self, message): + """ + Check if the user has opted out of push notifications for the given course. + :param message: + :return: + """ + course_ids = message.context.get('course_ids', []) + app_label = message.context.get('app_label') + + if not (app_label or message.context.get('push_notification_extra_context', {})): + return PolicyResult(deny={ChannelType.PUSH}) + + course_keys = [CourseKey.from_string(course_id) for course_id in course_ids] + for course_key in course_keys: + course_notification_preference = CourseNotificationPreference.get_user_course_preference( + message.recipient.lms_user_id, + course_key + ) + push_notification_preference = course_notification_preference.get_notification_type_config( + app_label, + notification_type='push', + ).get('push', False) + + if not push_notification_preference: + return PolicyResult(deny={ChannelType.PUSH}) + + return PolicyResult(deny=frozenset()) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 85167e0d36ca..755e51d26fc8 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,6 +4,8 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications + # via -r requirements/edx/github.in -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/github.in acid-xblock==0.3.1 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 876bc4fcc4a2..9c6bbbc2bbf2 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -4,6 +4,10 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index a222e78518ed..971dc13b06cf 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -4,6 +4,8 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications + # via -r requirements/edx/base.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/base.txt accessible-pygments==0.0.5 diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 6ec36d3a0681..f4549c90c1ee 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -90,3 +90,5 @@ # django42 support PR merged but new release is pending. # https://github.com/openedx/edx-platform/issues/33431 -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index ac468974eb6c..0d694458d1b8 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -4,6 +4,8 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications + # via -r requirements/edx/base.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/base.txt acid-xblock==0.3.1 diff --git a/setup.py b/setup.py index bf662b563c7f..55863c5d07fb 100644 --- a/setup.py +++ b/setup.py @@ -129,7 +129,8 @@ 'discussions_link = openedx.core.djangoapps.discussions.transformers:DiscussionsTopicLinkTransformer', ], "openedx.ace.policy": [ - "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout" + "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout", + "bulk_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long ], "openedx.call_to_action": [ "personalized_learner_schedules = openedx.features.personalized_learner_schedules.call_to_action:PersonalizedLearnerScheduleCallToAction" # lint-amnesty, pylint: disable=line-too-long From ce21022fe26ed9485e6be03cb0e66d8b2bf1d12d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Wed, 3 Jul 2024 10:12:32 +0300 Subject: [PATCH 02/67] refactor: [FC-0047] rename subject files to title --- .../edx_ace/commentnotification/push/{subject.txt => title.txt} | 1 - .../edx_ace/responsenotification/push/{subject.txt => title.txt} | 0 .../edx_ace/addbetatester/push/{subject.txt => title.txt} | 0 .../edx_ace/allowedenroll/push/{subject.txt => title.txt} | 0 .../edx_ace/allowedunenroll/push/{subject.txt => title.txt} | 0 .../edx_ace/enrolledunenroll/push/{subject.txt => title.txt} | 0 .../edx_ace/enrollenrolled/push/{subject.txt => title.txt} | 0 .../edx_ace/removebetatester/push/{subject.txt => title.txt} | 0 8 files changed, 1 deletion(-) rename lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/{subject.txt => title.txt} (98%) rename lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/{subject.txt => title.txt} (100%) rename lms/templates/instructor/edx_ace/addbetatester/push/{subject.txt => title.txt} (100%) rename lms/templates/instructor/edx_ace/allowedenroll/push/{subject.txt => title.txt} (100%) rename lms/templates/instructor/edx_ace/allowedunenroll/push/{subject.txt => title.txt} (100%) rename lms/templates/instructor/edx_ace/enrolledunenroll/push/{subject.txt => title.txt} (100%) rename lms/templates/instructor/edx_ace/enrollenrolled/push/{subject.txt => title.txt} (100%) rename lms/templates/instructor/edx_ace/removebetatester/push/{subject.txt => title.txt} (100%) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt similarity index 98% rename from lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt rename to lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt index d2298a812990..a9ea6f298c03 100644 --- a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt @@ -1,3 +1,2 @@ {% load i18n %} - {% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt similarity index 100% rename from lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt rename to lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt b/lms/templates/instructor/edx_ace/addbetatester/push/title.txt similarity index 100% rename from lms/templates/instructor/edx_ace/addbetatester/push/subject.txt rename to lms/templates/instructor/edx_ace/addbetatester/push/title.txt diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt similarity index 100% rename from lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt rename to lms/templates/instructor/edx_ace/allowedenroll/push/title.txt diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt similarity index 100% rename from lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt rename to lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt similarity index 100% rename from lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt rename to lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt similarity index 100% rename from lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt rename to lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt b/lms/templates/instructor/edx_ace/removebetatester/push/title.txt similarity index 100% rename from lms/templates/instructor/edx_ace/removebetatester/push/subject.txt rename to lms/templates/instructor/edx_ace/removebetatester/push/title.txt From 742a8786980976296ed3118ff5bb8157474d8616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Wed, 3 Jul 2024 10:23:41 +0300 Subject: [PATCH 03/67] docs: [FC-0047] add docs for setting up mobile push notifications --- .../docs/push_notifications_configuration.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md diff --git a/openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md b/openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md new file mode 100644 index 000000000000..4455ce9f3c55 --- /dev/null +++ b/openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md @@ -0,0 +1,63 @@ +# Configure mobile push notifications in edx-platform + + +### 1. Create a new Firebase project + +All push notifications in Open edX are sent via FCM service to start with it you need to create +a new Firebase project in Firebase console https://console.firebase.google.com/ + +### 2. Provide service account credentials to initialize an FCM admin application in edx-platform + +To configure sending push notifications via FCM from edx-platform, you need to generate private +key for Firebase admin SDK in Project settings > Service accounts section. + +After downloading .json key, you should mount it to LMS/CMS containers and specify a path to +the mounted file using FIREBASE_CREDENTIALS_PATH settings +[variable](https://github.com/openedx/edx-platform/pull/34971/files#diff-f694c479e5c9b133241a799e1ddf33d5d5133bfdec91e3f7d371e094c9999e74R31). There is also an alternative option, +which is to add the value from the .json key to the FIREBASE_CREDENTIALS environment +[variable](https://github.com/openedx/edx-platform/pull/34971/files#diff-f694c479e5c9b133241a799e1ddf33d5d5133bfdec91e3f7d371e094c9999e74R34), +like a python dictionary. + +https://github.com/openedx/edx-ace/blob/master/docs/decisions/0002-push-notifications.rst?plain=1#L108 + + +### 3. Configure and build mobile applications + +Use the supported Open edX mobile applications: + +https://github.com/openedx/openedx-app-android/ + +https://github.com/openedx/openedx-app-ios + +#### 3.1 Configure oauth2 + +First you need to configure Oauth applications for each mobile client in edx-platform. You should create separate +entries for Android and IOS applications in the Django OAuth Toolkit > Applications. + +Fill in all required fields in the form: + - Client ID: . + - Client type: Public + - Authorization grant type: Resource owner password-based + - Public Client secret: + +Specify generated Client ID in mobile config.yaml file + +https://github.com/openedx/openedx-app-android/blob/main/Documentation/ConfigurationManagement.md#configuration-files + +https://github.com/openedx/openedx-app-ios/blob/main/Documentation/CONFIGURATION_MANAGEMENT.md#examples-of-config-files + +#### 3.2 Provide FCM credentials to the app + +Create new apps in Firebase Console for Android and IOS in Project settings > General section. + +Download credentials file, google-services.json for Android, or GoogleService-Info.plist for IOS. + +Copy/paste values from configuration file into config.yaml as shown in example configurations. + +https://github.com/openedx/openedx-app-android/blob/main/Documentation/ConfigurationManagement.md#configuration-files + +https://github.com/openedx/openedx-app-ios/blob/main/Documentation/CONFIGURATION_MANAGEMENT.md#examples-of-config-files + +Build applications and you’re ready to go! + From 84d0b2a1a315e118fc0030d7d62c81fd5de5437b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Tue, 30 Jul 2024 20:23:35 +0300 Subject: [PATCH 04/67] chore: [FC-0047] upgrade requirements --- requirements/edx/github.in | 2 -- requirements/edx/kernel.in | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements/edx/github.in b/requirements/edx/github.in index f4549c90c1ee..6ec36d3a0681 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -90,5 +90,3 @@ # django42 support PR merged but new release is pending. # https://github.com/openedx/edx-platform/issues/33431 -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack - --e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index a5b510742ac7..fcb740c8f323 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -47,6 +47,7 @@ django-mptt django-mysql django-oauth-toolkit # Provides oAuth2 capabilities for Django django-pipeline +django-push-notifications django-ratelimit django-sekizai django-simple-history From 9a0734b28004f11555b33c746db4b2ea3422105e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Thu, 1 Aug 2024 17:36:46 +0300 Subject: [PATCH 05/67] style: [FC-0047] add module docstrings --- lms/djangoapps/mobile_api/notifications/urls.py | 3 +++ lms/djangoapps/mobile_api/notifications/views.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lms/djangoapps/mobile_api/notifications/urls.py b/lms/djangoapps/mobile_api/notifications/urls.py index 17b970916a47..b0fe46a86046 100644 --- a/lms/djangoapps/mobile_api/notifications/urls.py +++ b/lms/djangoapps/mobile_api/notifications/urls.py @@ -1,3 +1,6 @@ +""" +URLs for the mobile_api.notifications APIs. +""" from django.urls import path from .views import GCMDeviceViewSet diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py index 4c94ae576e76..4778dc83c99c 100644 --- a/lms/djangoapps/mobile_api/notifications/views.py +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -1,3 +1,6 @@ +""" +This module contains the view for registering a device for push notifications. +""" from django.conf import settings from rest_framework import status from rest_framework.response import Response From 9d0c45680e25fe91796d3a8a818d4fa6638f10b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Fri, 9 Aug 2024 12:50:46 +0300 Subject: [PATCH 06/67] refactor: [FC-0047] fix review issues --- lms/djangoapps/discussion/tasks.py | 4 +-- lms/djangoapps/discussion/tests/test_tasks.py | 8 ++++-- lms/djangoapps/instructor/enrollment.py | 26 +++++++++---------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index 23a641fae958..65a03c687a49 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -76,9 +76,7 @@ def __init__(self, *args, **kwargs): class CommentNotification(BaseMessageType): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.options['transactional'] = True + pass @shared_task(base=LoggedTask) diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index 950950210d47..92dadac9d9ee 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -19,7 +19,11 @@ import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY -from lms.djangoapps.discussion.tasks import _is_first_comment, _should_send_message, _track_notification_sent +from lms.djangoapps.discussion.tasks import ( + _is_first_comment, + _should_send_message, + _track_notification_sent, +) from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ForumsConfig @@ -342,7 +346,7 @@ def run_should_not_send_email_test(self, thread, comment_dict): }) should_email_send = _is_first_comment(comment_dict['id'], thread['id']) - assert should_email_send is False + assert not should_email_send assert not self.mock_ace_send.called def test_subcomment_should_not_send_email(self): diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 2c4294cad98b..4abf68dec060 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -125,7 +125,7 @@ def get_user_email_language(user): return UserPreference.get_value(user, LANGUAGE_KEY) -def enroll_email(course_id, student_email, auto_enroll=False, email_students=False, email_params=None, language=None): +def enroll_email(course_id, student_email, auto_enroll=False, email_students=False, message_params=None, language=None): """ Enroll a student by email. @@ -134,7 +134,7 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal if auto_enroll is set, then when the email registers, they will be enrolled in the course automatically. `email_students` determines if student should be notified of action by email. - `email_params` parameters used while parsing email templates (a `dict`). + `message_params` parameters used while parsing message templates (a `dict`). `language` is the language used to render the email. returns two EmailEnrollmentState's @@ -142,8 +142,8 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal """ previous_state = EmailEnrollmentState(course_id, student_email) enrollment_obj = None - if email_params: - email_params.update({ + if message_params: + message_params.update({ 'app_label': 'instructor', 'push_notification_extra_context': { 'notification_type': 'enroll', @@ -168,22 +168,22 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal enrollment_obj = CourseEnrollment.enroll_by_email(student_email, course_id, course_mode) if email_students: - email_params['message_type'] = 'enrolled_enroll' - email_params['email_address'] = student_email - email_params['user_id'] = previous_state.user.id - email_params['full_name'] = previous_state.full_name - send_mail_to_student(student_email, email_params, language=language) + message_params['message_type'] = 'enrolled_enroll' + message_params['email_address'] = student_email + message_params['user_id'] = previous_state.user.id + message_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, message_params, language=language) elif not is_email_retired(student_email): cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email) cea.auto_enroll = auto_enroll cea.save() if email_students: - email_params['message_type'] = 'allowed_enroll' - email_params['email_address'] = student_email + message_params['message_type'] = 'allowed_enroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id - send_mail_to_student(student_email, email_params, language=language) + message_params['user_id'] = previous_state.user.id + send_mail_to_student(student_email, message_params, language=language) after_state = EmailEnrollmentState(course_id, student_email) From 69078f3eed5903c9be465f0bf72d634fb7dbaa4f Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Sat, 10 Aug 2024 16:11:53 +0300 Subject: [PATCH 07/67] refactor: change name of the policy to course push optout --- openedx/core/djangoapps/ace_common/settings/common.py | 2 +- openedx/core/djangoapps/ace_common/settings/production.py | 2 +- openedx/core/djangoapps/notifications/policies.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index 993412f392bb..634ab328ba6b 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -40,7 +40,7 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function if getattr(settings, 'FIREBASE_APP', None): settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.ACE_ENABLED_POLICIES.append('bulk_push_notification_optout') + settings.ACE_ENABLED_POLICIES.append('course_push_notification_optout') settings.PUSH_NOTIFICATIONS_SETTINGS = { 'CONFIG': 'push_notifications.conf.AppConfig', diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index 48da799f00b4..9ff56292012e 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -38,7 +38,7 @@ def plugin_settings(settings): ) if settings.FIREBASE_APP: settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.ACE_ENABLED_POLICIES.append('bulk_push_notification_optout') + settings.ACE_ENABLED_POLICIES.append('course_push_notification_optout') settings.PUSH_NOTIFICATIONS_SETTINGS = { 'CONFIG': 'push_notifications.conf.AppConfig', diff --git a/openedx/core/djangoapps/notifications/policies.py b/openedx/core/djangoapps/notifications/policies.py index 8467eda9324e..8d0a2d8d43a5 100644 --- a/openedx/core/djangoapps/notifications/policies.py +++ b/openedx/core/djangoapps/notifications/policies.py @@ -16,7 +16,7 @@ def check(self, message): """ Check if the user has opted out of push notifications for the given course. :param message: - :return: + :return: PolicyResult """ course_ids = message.context.get('course_ids', []) app_label = message.context.get('app_label') diff --git a/setup.py b/setup.py index 55863c5d07fb..28a25cc91476 100644 --- a/setup.py +++ b/setup.py @@ -130,7 +130,7 @@ ], "openedx.ace.policy": [ "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout", - "bulk_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long + "course_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long ], "openedx.call_to_action": [ "personalized_learner_schedules = openedx.features.personalized_learner_schedules.call_to_action:PersonalizedLearnerScheduleCallToAction" # lint-amnesty, pylint: disable=line-too-long From c7235e34e25f118760fd4209f76027d9ce528ae0 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Sat, 10 Aug 2024 16:22:51 +0300 Subject: [PATCH 08/67] build: remove diff for requirements --- requirements/edx/kernel.in | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index fcb740c8f323..a5b510742ac7 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -47,7 +47,6 @@ django-mptt django-mysql django-oauth-toolkit # Provides oAuth2 capabilities for Django django-pipeline -django-push-notifications django-ratelimit django-sekizai django-simple-history From 8a7054429af51f06ec9bcceda32ab503d7f49d51 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Sat, 10 Aug 2024 18:42:49 +0300 Subject: [PATCH 09/67] fix: rename email to message params --- lms/djangoapps/ccx/api/v0/tests/test_views.py | 4 +- lms/djangoapps/ccx/api/v0/views.py | 8 +-- lms/djangoapps/ccx/utils.py | 20 +++--- lms/djangoapps/ccx/views.py | 4 +- lms/djangoapps/instructor/access.py | 4 +- lms/djangoapps/instructor/enrollment.py | 66 ++++++++++--------- 6 files changed, 55 insertions(+), 51 deletions(-) diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py index 7279b9426347..a8c9070df038 100644 --- a/lms/djangoapps/ccx/api/v0/tests/test_views.py +++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py @@ -730,8 +730,8 @@ def make_ccx(self, max_students_allowed=200): course_id=ccx_course_key, student_email=self.coach.email, auto_enroll=True, - email_students=False, - email_params=email_params, + message_students=False, + message_params=email_params, ) return ccx diff --git a/lms/djangoapps/ccx/api/v0/views.py b/lms/djangoapps/ccx/api/v0/views.py index b3e345a77022..8ca15e065006 100644 --- a/lms/djangoapps/ccx/api/v0/views.py +++ b/lms/djangoapps/ccx/api/v0/views.py @@ -505,8 +505,8 @@ def post(self, request): course_id=ccx_course_key, student_email=coach.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) # assign staff role for the coach to the newly created ccx assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) @@ -768,8 +768,8 @@ def patch(self, request, ccx_course_id=None): course_id=ccx_course_key, student_email=coach.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) # make the new coach staff on the CCX assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) diff --git a/lms/djangoapps/ccx/utils.py b/lms/djangoapps/ccx/utils.py index 9f7c0eff3963..2afed619ddd2 100644 --- a/lms/djangoapps/ccx/utils.py +++ b/lms/djangoapps/ccx/utils.py @@ -269,7 +269,7 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", error) errors.append(error) break - enroll_email(course_key, email, auto_enroll=True, email_students=email_students, email_params=email_params) + enroll_email(course_key, email, auto_enroll=True, message_students=email_students, message_params=email_params) elif action == 'Unenroll' or action == 'revoke': # lint-amnesty, pylint: disable=consider-using-in for identifier in identifiers: try: @@ -278,7 +278,7 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", exp) errors.append(f"{exp}") continue - unenroll_email(course_key, email, email_students=email_students, email_params=email_params) + unenroll_email(course_key, email, message_students=email_students, message_params=email_params) return errors @@ -348,8 +348,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em course_id=ccx_key, student_email=staff.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) # allow 'staff' access on ccx to staff of master course @@ -373,8 +373,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em course_id=ccx_key, student_email=instructor.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) # allow 'instructor' access on ccx to instructor of master course @@ -417,8 +417,8 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se unenroll_email( course_id=ccx_key, student_email=staff.email, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) for instructor in list_instructor: @@ -430,6 +430,6 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se unenroll_email( course_id=ccx_key, student_email=instructor.email, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py index 3c5f3130a195..7c6a75aaf6d4 100644 --- a/lms/djangoapps/ccx/views.py +++ b/lms/djangoapps/ccx/views.py @@ -223,8 +223,8 @@ def create_ccx(request, course, ccx=None): course_id=ccx_id, student_email=request.user.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) assign_staff_role_to_ccx(ccx_id, request.user, course.id) diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 9255d113f038..a5d25769ca10 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -86,8 +86,8 @@ def _change_access(course, user, level, action, send_email=True): course_id=course.id, student_email=user.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) role.add_users(user) elif action == 'revoke': diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 4abf68dec060..55b4b8cf1957 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -125,7 +125,7 @@ def get_user_email_language(user): return UserPreference.get_value(user, LANGUAGE_KEY) -def enroll_email(course_id, student_email, auto_enroll=False, email_students=False, message_params=None, language=None): +def enroll_email(course_id, student_email, auto_enroll=False, message_students=False, message_params=None, language=None): """ Enroll a student by email. @@ -133,7 +133,7 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal `auto_enroll` determines what is put in CourseEnrollmentAllowed.auto_enroll if auto_enroll is set, then when the email registers, they will be enrolled in the course automatically. - `email_students` determines if student should be notified of action by email. + `message_students` determines if student should be notified of action by email or push message. `message_params` parameters used while parsing message templates (a `dict`). `language` is the language used to render the email. @@ -150,6 +150,8 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal 'course_id': str(course_id), }, }) + else: + message_params = {} if previous_state.user and previous_state.user.is_active: # if the student is currently unenrolled, don't enroll them in their # previous mode @@ -167,7 +169,7 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal course_mode = previous_state.mode enrollment_obj = CourseEnrollment.enroll_by_email(student_email, course_id, course_mode) - if email_students: + if message_students: message_params['message_type'] = 'enrolled_enroll' message_params['email_address'] = student_email message_params['user_id'] = previous_state.user.id @@ -178,7 +180,7 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email) cea.auto_enroll = auto_enroll cea.save() - if email_students: + if message_students: message_params['message_type'] = 'allowed_enroll' message_params['email_address'] = student_email if previous_state.user: @@ -190,74 +192,76 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal return previous_state, after_state, enrollment_obj -def unenroll_email(course_id, student_email, email_students=False, email_params=None, language=None): +def unenroll_email(course_id, student_email, message_students=False, message_params=None, language=None): """ Unenroll a student by email. `student_email` is student's emails e.g. "foo@bar.com" - `email_students` determines if student should be notified of action by email. - `email_params` parameters used while parsing email templates (a `dict`). + `message_students` determines if student should be notified of action by email or push message. + `message_params` parameters used while parsing email templates (a `dict`). `language` is the language used to render the email. returns two EmailEnrollmentState's representing state before and after the action. """ previous_state = EmailEnrollmentState(course_id, student_email) - if email_params: - email_params.update({ + if message_params: + message_params.update({ 'app_label': 'instructor', 'push_notification_extra_context': { 'notification_type': 'unenroll', }, }) + else: + message_params = {} if previous_state.enrollment: CourseEnrollment.unenroll_by_email(student_email, course_id) - if email_students: - email_params['message_type'] = 'enrolled_unenroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'enrolled_unenroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id - email_params['full_name'] = previous_state.full_name - send_mail_to_student(student_email, email_params, language=language) + message_params['user_id'] = previous_state.user.id + message_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, message_params, language=language) if previous_state.allowed: CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete() - if email_students: - email_params['message_type'] = 'allowed_unenroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'allowed_unenroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id + message_params['user_id'] = previous_state.user.id # Since no User object exists for this student there is no "full_name" available. - send_mail_to_student(student_email, email_params, language=language) + send_mail_to_student(student_email, message_params, language=language) after_state = EmailEnrollmentState(course_id, student_email) return previous_state, after_state -def send_beta_role_email(action, user, email_params): +def send_beta_role_email(action, user, message_params): """ Send an email to a user added or removed as a beta tester. `action` is one of 'add' or 'remove' `user` is the User affected - `email_params` parameters used while parsing email templates (a `dict`). + `message_params` parameters used while parsing email templates (a `dict`). """ if action in ('add', 'remove'): - email_params['message_type'] = '%s_beta_tester' % action - email_params['email_address'] = user.email - email_params['user_id'] = user.id - email_params['full_name'] = user.profile.name - email_params['app_label'] = 'instructor' - email_params['push_notification_extra_context'] = { - 'notification_type': email_params['message_type'], - 'course_id': str(getattr(email_params.get('course'), 'id', '')), + message_params['message_type'] = '%s_beta_tester' % action + message_params['email_address'] = user.email + message_params['user_id'] = user.id + message_params['full_name'] = user.profile.name + message_params['app_label'] = 'instructor' + message_params['push_notification_extra_context'] = { + 'notification_type': message_params['message_type'], + 'course_id': str(getattr(message_params.get('course'), 'id', '')), } else: raise ValueError(f"Unexpected action received '{action}' - expected 'add' or 'remove'") trying_to_add_inactive_user = not user.is_active and action == 'add' if not trying_to_add_inactive_user: - send_mail_to_student(user.email, email_params, language=get_user_email_language(user)) + send_mail_to_student(user.email, message_params, language=get_user_email_language(user)) @contextmanager From c70a6e74cbc0757015c6634a59274bd09fdc288a Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Sat, 10 Aug 2024 19:37:48 +0300 Subject: [PATCH 10/67] fix: fix linter warning --- lms/djangoapps/instructor/enrollment.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 55b4b8cf1957..ed344876eb42 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -125,7 +125,14 @@ def get_user_email_language(user): return UserPreference.get_value(user, LANGUAGE_KEY) -def enroll_email(course_id, student_email, auto_enroll=False, message_students=False, message_params=None, language=None): +def enroll_email( + course_id, + student_email, + auto_enroll=False, + message_students=False, + message_params=None, + language=None +): """ Enroll a student by email. From c733dc85849477706fe83a55aabb808bc0cee21b Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Sun, 11 Aug 2024 16:59:48 +0300 Subject: [PATCH 11/67] fix: remove docs from app level --- lms/djangoapps/ccx/utils.py | 8 ++- .../docs/push_notifications_configuration.md | 63 ------------------- 2 files changed, 7 insertions(+), 64 deletions(-) delete mode 100644 openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md diff --git a/lms/djangoapps/ccx/utils.py b/lms/djangoapps/ccx/utils.py index 2afed619ddd2..28ecbba34947 100644 --- a/lms/djangoapps/ccx/utils.py +++ b/lms/djangoapps/ccx/utils.py @@ -269,7 +269,13 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", error) errors.append(error) break - enroll_email(course_key, email, auto_enroll=True, message_students=email_students, message_params=email_params) + enroll_email( + course_key, + email, + auto_enroll=True, + message_students=email_students, + message_params=email_params + ) elif action == 'Unenroll' or action == 'revoke': # lint-amnesty, pylint: disable=consider-using-in for identifier in identifiers: try: diff --git a/openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md b/openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md deleted file mode 100644 index 4455ce9f3c55..000000000000 --- a/openedx/core/djangoapps/ace_common/docs/push_notifications_configuration.md +++ /dev/null @@ -1,63 +0,0 @@ -# Configure mobile push notifications in edx-platform - - -### 1. Create a new Firebase project - -All push notifications in Open edX are sent via FCM service to start with it you need to create -a new Firebase project in Firebase console https://console.firebase.google.com/ - -### 2. Provide service account credentials to initialize an FCM admin application in edx-platform - -To configure sending push notifications via FCM from edx-platform, you need to generate private -key for Firebase admin SDK in Project settings > Service accounts section. - -After downloading .json key, you should mount it to LMS/CMS containers and specify a path to -the mounted file using FIREBASE_CREDENTIALS_PATH settings -[variable](https://github.com/openedx/edx-platform/pull/34971/files#diff-f694c479e5c9b133241a799e1ddf33d5d5133bfdec91e3f7d371e094c9999e74R31). There is also an alternative option, -which is to add the value from the .json key to the FIREBASE_CREDENTIALS environment -[variable](https://github.com/openedx/edx-platform/pull/34971/files#diff-f694c479e5c9b133241a799e1ddf33d5d5133bfdec91e3f7d371e094c9999e74R34), -like a python dictionary. - -https://github.com/openedx/edx-ace/blob/master/docs/decisions/0002-push-notifications.rst?plain=1#L108 - - -### 3. Configure and build mobile applications - -Use the supported Open edX mobile applications: - -https://github.com/openedx/openedx-app-android/ - -https://github.com/openedx/openedx-app-ios - -#### 3.1 Configure oauth2 - -First you need to configure Oauth applications for each mobile client in edx-platform. You should create separate -entries for Android and IOS applications in the Django OAuth Toolkit > Applications. - -Fill in all required fields in the form: - - Client ID: . - - Client type: Public - - Authorization grant type: Resource owner password-based - - Public Client secret: - -Specify generated Client ID in mobile config.yaml file - -https://github.com/openedx/openedx-app-android/blob/main/Documentation/ConfigurationManagement.md#configuration-files - -https://github.com/openedx/openedx-app-ios/blob/main/Documentation/CONFIGURATION_MANAGEMENT.md#examples-of-config-files - -#### 3.2 Provide FCM credentials to the app - -Create new apps in Firebase Console for Android and IOS in Project settings > General section. - -Download credentials file, google-services.json for Android, or GoogleService-Info.plist for IOS. - -Copy/paste values from configuration file into config.yaml as shown in example configurations. - -https://github.com/openedx/openedx-app-android/blob/main/Documentation/ConfigurationManagement.md#configuration-files - -https://github.com/openedx/openedx-app-ios/blob/main/Documentation/CONFIGURATION_MANAGEMENT.md#examples-of-config-files - -Build applications and you’re ready to go! - From 13e3024ae3e62ed8c4f6ffb516822482f520870f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Fri, 6 Sep 2024 17:40:26 +0300 Subject: [PATCH 12/67] style: [FC-0047] fix code style issues --- lms/djangoapps/discussion/tasks.py | 3 +++ lms/djangoapps/instructor/views/api.py | 12 +++++++----- lms/djangoapps/mobile_api/notifications/urls.py | 5 ++--- lms/djangoapps/mobile_api/notifications/views.py | 2 +- openedx/core/djangoapps/ace_common/utils.py | 6 +----- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index 65a03c687a49..da5a324ea77f 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -76,6 +76,9 @@ def __init__(self, *args, **kwargs): class CommentNotification(BaseMessageType): + """ + Notify discussion participants of new comments. + """ pass diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index d9a301b07e7f..990170d7b452 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -514,11 +514,13 @@ def post(self, request, course_id): # pylint: disable=too-many-statements 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) + enroll_email( + course_id=course_id, + student_email=email, + auto_enroll=True, + message_students=notify_by_email, + message_params=email_params, + ) else: # update the course mode if already enrolled existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) diff --git a/lms/djangoapps/mobile_api/notifications/urls.py b/lms/djangoapps/mobile_api/notifications/urls.py index b0fe46a86046..120fa39a975a 100644 --- a/lms/djangoapps/mobile_api/notifications/urls.py +++ b/lms/djangoapps/mobile_api/notifications/urls.py @@ -5,9 +5,8 @@ from .views import GCMDeviceViewSet -CREATE_GCM_DEVICE = GCMDeviceViewSet.as_view({'post': 'create'}) - +create_gcm_device_post_view = GCMDeviceViewSet.as_view({'post': 'create'}) urlpatterns = [ - path('create-token/', CREATE_GCM_DEVICE, name='gcmdevice-list'), + path('create-token/', create_gcm_device_post_view, name='gcmdevice-list'), ] diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py index 4778dc83c99c..2621c2a3a2fb 100644 --- a/lms/djangoapps/mobile_api/notifications/views.py +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -30,7 +30,7 @@ class GCMDeviceViewSet(GCMDeviceViewSetBase): If False, the device will not receive notifications. * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported. * application_id (optional) - Opaque application identity, should be filled in for multiple - key/certificate access. + key/certificate access. Should be equal settings.FCM_APP_NAME. **Example Response** ```json { diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py index 508ac4033cd1..7cf38c821976 100644 --- a/openedx/core/djangoapps/ace_common/utils.py +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -10,11 +10,7 @@ def setup_firebase_app(firebase_credentials, app_name='fcm-app'): """ Returns a Firebase app instance if the Firebase credentials are provided. """ - try: - import firebase_admin # pylint: disable=import-outside-toplevel - except ImportError: - log.error('Could not import firebase_admin package.') - return + import firebase_admin # pylint: disable=import-outside-toplevel if firebase_credentials: try: From eef99a6acc5547bd946c513fdb2a045aea7c51ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Fri, 6 Sep 2024 17:40:55 +0300 Subject: [PATCH 13/67] chore: [FC-0047] change push notifications texts --- .../discussion/edx_ace/responsenotification/push/body.txt | 2 +- lms/templates/instructor/edx_ace/allowedenroll/push/body.txt | 1 - .../instructor/edx_ace/allowedunenroll/push/body.txt | 1 - requirements/edx/base.txt | 2 -- requirements/edx/development.txt | 4 ---- requirements/edx/doc.txt | 2 -- requirements/edx/testing.txt | 2 -- 7 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt index c1fe3ba35b7f..ee97a6e329f5 100644 --- a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt @@ -1,2 +1,2 @@ {% load i18n %} -{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}{% endblocktrans %} +{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}: {{ comment_body|truncatechars:200 }}{% endblocktrans %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt index 14e4915f86e2..41ff994310e3 100644 --- a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt @@ -1,5 +1,4 @@ {% load i18n %} {% autoescape off %} -{% blocktrans %}Dear student,{% endblocktrans %} {% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }}. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} {% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt index b825ce1d4d18..c7342b6830b5 100644 --- a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt @@ -1,5 +1,4 @@ {% load i18n %} {% autoescape off %} -{% blocktrans %}Dear Student,{% endblocktrans %} {% blocktrans %}You have been unenrolled from the course {{ course_name }}. Please disregard the invitation previously sent.{% endblocktrans %} {% endautoescape %} diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 755e51d26fc8..85167e0d36ca 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,8 +4,6 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications - # via -r requirements/edx/github.in -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/github.in acid-xblock==0.3.1 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 9c6bbbc2bbf2..876bc4fcc4a2 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -4,10 +4,6 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 971dc13b06cf..a222e78518ed 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -4,8 +4,6 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications - # via -r requirements/edx/base.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/base.txt accessible-pygments==0.0.5 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 0d694458d1b8..ac468974eb6c 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -4,8 +4,6 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications - # via -r requirements/edx/base.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/base.txt acid-xblock==0.3.1 From 73364712ba5e424c845e5d724b9e2c2c4e6f5733 Mon Sep 17 00:00:00 2001 From: cef Date: Wed, 4 Sep 2024 09:48:22 -0500 Subject: [PATCH 14/67] feat: set links for CourseAuthoring discussion alert --- .../rest_api/v1/views/tests/test_course_index.py | 5 +++-- cms/envs/common.py | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index eafc2b37aa0c..189f2496a427 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -1,6 +1,7 @@ """ Unit tests for course index outline. """ +from django.conf import settings from django.test import RequestFactory from django.urls import reverse from rest_framework import status @@ -62,7 +63,7 @@ def test_course_index_response(self): "advance_settings_url": f"/settings/advanced/{self.course.id}" }, "discussions_incontext_feedback_url": "", - "discussions_incontext_learnmore_url": "", + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, "is_custom_relative_dates_active": True, "initial_state": None, "initial_user_clipboard": { @@ -103,7 +104,7 @@ def test_course_index_response_with_show_locators(self): "advance_settings_url": f"/settings/advanced/{self.course.id}" }, "discussions_incontext_feedback_url": "", - "discussions_incontext_learnmore_url": "", + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, "is_custom_relative_dates_active": False, "initial_state": { "expanded_locators": [ diff --git a/cms/envs/common.py b/cms/envs/common.py index 8daa08aeb1c8..46f7d2a3d2a5 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2814,8 +2814,14 @@ BRAZE_COURSE_ENROLLMENT_CANVAS_ID = '' +######################## Discussion Forum settings ######################## + +# Feedback link in upgraded discussion notification alert DISCUSSIONS_INCONTEXT_FEEDBACK_URL = '' -DISCUSSIONS_INCONTEXT_LEARNMORE_URL = '' + +# Learn More link in upgraded discussion notification alert +# pylint: disable=line-too-long +DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_discussions/discussions.html" #### django-simple-history## # disable indexing on date field its coming django-simple-history. From 069a52a27b16d44a42efa8f8fbf799f9222af496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Wed, 11 Sep 2024 15:39:37 +0300 Subject: [PATCH 15/67] style: [FC-0047] remove unnecessary pass --- lms/djangoapps/discussion/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index da5a324ea77f..3fef4f5f7cef 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -79,7 +79,6 @@ class CommentNotification(BaseMessageType): """ Notify discussion participants of new comments. """ - pass @shared_task(base=LoggedTask) From c7bc41faa8b846561f4a0b4b0925574da5aecd51 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 <83753341+hamzawaleed01@users.noreply.github.com> Date: Fri, 13 Sep 2024 08:03:59 +0000 Subject: [PATCH 16/67] feat: Upgrade Python dependency edx-enterprise fix: add id in pending_enterprise_customer_admin_user serializer Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 8c95f7fcc200..713cb849a58d 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.10 +edx-enterprise==4.25.12 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9c85f60fc3a8..85b2fa2ab3c7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.10 +edx-enterprise==4.25.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 15e078ae0dd3..67769be1cdb8 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.10 +edx-enterprise==4.25.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index defc09a23deb..ed6090766367 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.10 +edx-enterprise==4.25.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 49be07ecec71..2e5cceadaf01 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.10 +edx-enterprise==4.25.12 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From b0bf58c27e95e90ef1869556d21f8c00d5a0010b Mon Sep 17 00:00:00 2001 From: sameeramin <35958006+sameeramin@users.noreply.github.com> Date: Mon, 16 Sep 2024 07:02:02 +0000 Subject: [PATCH 17/67] feat: Upgrade Python dependency edx-enterprise adds logging to debug SAP transmitter Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index a87d41292189..a16dfa66d04c 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.11 +edx-enterprise==4.25.13 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7f9f822de94d..e542379a5f1b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 0979f70d509c..ce71c9a0ed1f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 8b2302ebe319..cda7fdf7d4b8 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 231fe7618867..11125ba57290 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From e66940094714f26d0e7da81bb2e9eabbf96a89f2 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 16 Sep 2024 13:01:17 -0400 Subject: [PATCH 18/67] docs: upstream block ADR, take 2 (#35421) --- docs/decisions/0020-upstream-downstream.rst | 402 ++++++++++++++++++ .../0003-library-content-block-schema.rst | 4 +- 2 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 docs/decisions/0020-upstream-downstream.rst diff --git a/docs/decisions/0020-upstream-downstream.rst b/docs/decisions/0020-upstream-downstream.rst new file mode 100644 index 000000000000..8ceb9e775274 --- /dev/null +++ b/docs/decisions/0020-upstream-downstream.rst @@ -0,0 +1,402 @@ +4. Upstream and downstream content +################################## + +Status +****** + +Accepted. + +Implementation in progress as of 2024-09-03. + +Context +******* + +We are replacing the existing Legacy ("V1") Content Libraries system, based on +ModuleStore, with a Relaunched ("V2") Content Libraries system, based on +Learning Core. V1 and V2 libraries will coexist for at least one release to +allow for migration; eventually, V1 libraries will be removed entirely. + +Content from V1 libraries can only be included into courses using the +LibraryContentBlock (called "Randomized Content Module" in Studio), which works +like this: + +* Course authors add a LibraryContentBlock to a Unit and configure it with a + library key and a count of N library blocks to select (or `-1` for "all + blocks"). + +* For each block in the chosen library, its *content definition* is copied into + the course as a child of the LibraryContentBlock, whereas its *settings* are + copied into a special "default" settings dictionary in the course's structure + document--this distinction will matter later. The usage key of each copied + block is derived from a hash of the original library block's usage key plus + the LibraryContentBlock's own usage key--this will also matter + later. + +* The course author is free to override the content and settings of the + course-local copies of each library block. + +* When any update is made to the library, the course author is prompted to + update the LibraryContentBlock. This involves re-copying the library blocks' + content definitions and default settings, which clobbers any overrides they + have made to content, but preserves any overrides they have made to settings. + Furthermore, any blocks that were added to the library are newly copied into + the course, and any blocks that were removed from the library are deleted + from the course. For all blocks, usage keys are recalculated using the same + hash derivation described above; for existing blocks, it is important that + this recalculation yields the same usage key so that student state is not + lost. + +* Over in the LMS, when a learner loads LibraryContentBlock, they are shown a + list of N randomly-picked blocks from the library. Subsequent visits show + them the same list, *unless* children were added, children were removed, or N + changed. In those cases, the LibraryContentBlock tries to make the smallest + possible adjustment to their personal list of blocks while respecting N and + the updated list of children. + +This system has several issues: + +#. **Missing defaults after import:** When a course with a LibraryContentBlock + is imported into an Open edX instance *without* the referenced library, the + blocks' *content* will remain intact as will course-local *settings + overrides*. However, any *default settings* defined in the library will be + missing. This can result in content that is completely broken, especially + since critical fields like video URLs and LTI URLs are considered + "settings". For a detailed scenario, see `LibraryContentBlock Curveball 1`_. + +#. **Strange behavior when duplicating content:** Typically, when a + block is duplicated or copy-pasted, the new block's usage key and its + children's usage keys are randomly generated. However, recall that when a + LibraryContentBlock is updated, its children's usage keys are rederived + using a hash function. That would cause the children's usage keys to change, + thus destroying any student state. So, we must work around this with a hack: + upon duplicating or pasting a LibraryContentBlock, we immediately update the + LibraryContentBlock, thus discarding the problematic randomly-generated keys + in favor of hash-derived keys. This works, but: + + * it involves weird code hacks, + * it unexpectedly discards any content overrides the course author made to + the copied LibraryContentBlock's children, + * it unexpectedly uses the latest version of library content, regardless of + which version the copied LibraryContentBlock was using, and + * it fails if the library does not exist on the Open edX instance, which + can happen if the course was imported from another instance. + +#. **Conflation of reference and randomization:** The LibraryContentBlock does + two things: it connects courses to library content, and it shows users a + random subset of content. There is no reason that those two features need to + be coupled together. A course author may want to randomize course-defined + content, or they may want to randomize content from multiple different + libraries. Or, they may want to use content from libraries without + randomizing it at all. While it is feasible to support all these things in a + single XBlock, trying to do so led to a `very complicated XBlock concept`_ + which difficult to explain to product managers and other engineers. + +#. **Unpredictable preservation of overrides:** Recall that *content + definitions* and *settings* are handled differently. This distinction is + defined in the code: every authorable XBlock field is either defined with + `Scope.content` or `Scope.settings`. In theory, XBlock developers would use + the content scope for fields that are core to the meaning of piece of + content, and they would only use the settings scope for fields that would be + reasonable to configure in a local copy of the piece of content. In + practice, though, XBlock developers almost always use `Scope.settings`. The + result of this is that customizations to blocks *almost always* survive + through library updates, except when they don't. Course authors have no way + to know (or even guess) when their customizations they will and won't + survive updates. + +#. **General pain and suffering:** The relationship between courses and V1 + libraries is confusing to content authors, site admins, and developers + alike. The behaviors above toe the line between "quirks" and "known bugs", + and they are not all documented. Past attempts to improve the system have + `triggered series of bugs`_, some of which led to permanent loss of learner + state. In other cases, past Content Libraries improvement efforts have + slowed or completely stalled out in code review due to the overwhelming + amount of context and edge cases that must be understood to safely make any + changes. + +.. _LibraryContentBlock Curveball 1: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-1%3A-Import%2FExport +.. _LibraryContentBlock Curveball 2: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-2:-Duplication +.. _very complicated XBlock concept: https://github.com/openedx/edx-platform/blob/master/xmodule/docs/decisions/0003-library-content-block-schema.rst +.. _triggered series of bugs: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3858661405/Bugs+from+Content+Libraries+V1 + +We are keen to use the Library Relaunch project to address all of these +problems. So, V2 libraries will interop with courses using a completely +different data model. + + +Decision +******** + +We will create a framework where a *downstream* piece of content (e.g. a course +block) can be *linked* to an *upstream* piece of content (e.g., a library +block) with the following properties: + +* **Portable:** Links can refer to certain content on the current Open edX + instance, and in the future they may be able to refer to content on other + Open edX instances or sites. Links will never include information that is + internal to a particular Open edX instance, such as foreign keys. + +* **Flat:** The *link* is a not a wrapper (like the LibraryContentBlock), + but simply a piece of metadata directly on the downstream content which + points to the upstream content. We will no longer rely on precarious + hash-derived usage keys to establish connection to upstream blocks; + like any other block, an upstream-linked blocks can be granted whatever block + ID that the authoring environment assigns it, whether random or + human-readable. + +* **Forwards-compatible:** If downstream content is created in a course on + an Open edX site that supports upstream and downstreams (e.g., a Teak + instance), and then it is exported and imported into a site that doesn't + (e.g., a Quince instance), the downstream content will simply act like + regular course content. + +* **Independent:** Upstream content and downstream content exist separately + from one another: + + * Modifying upstream content does not affect any downstream content (unless a + sync happens, more on that later). + * Deleting upstream content does not impact its downstream content. By + corollary, pieces of downstream content can completely and correctly render + on Open edX instances that are missing their linked upstream content. + * (Preserving a positive feature of the V1 LibraryContentBlock) The link + persists through export-import and copy-paste, regardless of whether the + upstream content actually exists. A "broken" link to upstream content is + seamlessly "repaired" if the upstream content becomes available again. + +* **Customizable:** On an OLX level, authors can still override the value + of any field for a piece of downstream content. However, we will empower + Studio to be more prescriptive about what authors *can* override versus what + they *should* override: + + * We define a set of *customizable* fields, with platform-level defaults + like display_name and a max_attempts, plus the ability for external + XBlocks to opt their own fields into customizability. + * Studio may use this list to provide an interface for customizing + downstream blocks, separate from the usual "Edit" interface that would + permit them to make unsafe overrides. + * Furthermore, downstream content will record which fields the user has + customized... + + * even if the customization is to simply clear the value of the fields... + * and even if the customization is made redundant in a future version of + the upstream content. For example, if max_attempts is customized from 3 + to 5 in the downstream content, but the next version of the upstream + content also changes max_attempts to 5, the downstream would still + consider max_attempts to be customized. If the following version of the + upstream content again changed max_attempts to 6, the downstream would + retain max_attempts to be 5. + + * Finally, the downstream content will locally save the upstream value of + customizable fields, allowing the author to *revert* back to them + regardless of whether the upstream content is actually available. + +* **Synchronizable, without surprises:** Downstream content can be *synced* + with updates that have been made to its linked upstream. This means that the + latest available upstream content field values will entirely replace all of + the downstream field values, *except* those which were customized, as + described in the previous item. + +* **Concrete, but flexible:** The internal implementation of upstream-downstream + syncing will assume that: + + * upstream content belongs to a V2 content library, + * downstream content belongs to a course on the same instance, and + * the link is the stringified usage key of the upstream library content. + + This will allow us to keep the implementation straightforward. However, we + will *not* expose these assumptions in the Python APIs, the HTTP APIs, or in + the persisted fields, allowing us in the future to generalize to other + upstreams (such as externally-hosted libraries) and other downstreams (such + as a standalone enrollable sequence without a course). + + If any of these assumptions are violated, we will raise an exception or log a + warning, as appropriate. Particularly, if these assumptions are violated at + the OLX level via a course import, then we will probably show a warning at + import time and refuse to sync from the unsupported upstream; however, we + will *not* fail the entire import or mangle the value of upstream link, since + we want to remain forwards-compatible with potential future forms of syncing. + As a concrete example: if a course block has *another course block's usage + key* as an upstream, then we will faithfully keep that value through the + import and export process, but we will not prompt the user to sync updates + for that block. + +* **Decoupled:** Upstream-downstream linking is not tied up with any other + courseware feature; in particular, it is unrelated to content randomization. + Randomized library content will be supported, but it will be a *synthesis* of + two features: (1) a RandomizationBlock that randomly selects a subset of its + children, where (2) some or all of those children are linked to upstream + blocks. + +Consequences +************ + +To support the Libraries Relaunch in Sumac: + +* For every XBlock in CMS, we will use XBlock fields to persist the upstream + link, its versions, its customizable fields, and its set of downstream + overrides. + + * We will avoid exposing these fields to LMS code. + + * We will define an initial set of customizable fields for Problem, Text, and + Video blocks. + +* We will define method(s) for syncing update on the XBlock runtime so that + they are available in the SplitModuleStore's XBlock Runtime + (CachingDescriptorSystem). + + * Either in the initial implementation or in a later implementation, it may + make sense to declare abstract versions of the syncing method(s) higher up + in XBlock Runtime inheritance hierarchy. + +* We will expose a CMS HTTP API for syncing updates to blocks from their + upstreams. + + * We will avoid exposing this API from the LMS. + +For reference, here are some excerpts of a potential implementation. This may +change through development and code review. + +.. code-block:: python + + ########################################################################### + # cms/lib/xblock/upstream_sync.py + ########################################################################### + + class UpstreamSyncMixin(XBlockMixin): + """ + Allows an XBlock in the CMS to be associated & synced with an upstream. + Mixed into CMS's XBLOCK_MIXINS, but not LMS's. + """ + + # Metadata related to upstream synchronization + upstream = String( + help=(""" + The usage key of a block (generally within a content library) + which serves as a source of upstream updates for this block, + or None if there is no such upstream. Please note: It is valid + for this field to hold a usage key for an upstream block + that does not exist (or does not *yet* exist) on this instance, + particularly if this downstream block was imported from a + different instance. + """), + default=None, scope=Scope.settings, hidden=True, enforce_type=True + ) + upstream_version = Integer( + help=(""" + Record of the upstream block's version number at the time this + block was created from it. If upstream_version is smaller + than the upstream block's latest version, then the user will be + able to sync updates into this downstream block. + """), + default=None, scope=Scope.settings, hidden=True, enforce_type=True, + ) + downstream_customized = Set( + help=(""" + Names of the fields which have values set on the upstream + block yet have been explicitly overridden on this downstream + block. Unless explicitly cleared by the user, these + customizations will persist even when updates are synced from + the upstream. + """), + default=[], scope=Scope.settings, hidden=True, enforce_type=True, + ) + + # Store upstream defaults for customizable fields. + upstream_display_name = String(...) + upstream_max_attempts = List(...) + ... # We will probably want to pre-define several more of these. + + def get_upstream_field_names(cls) -> dict[str, str]: + """ + Mapping from each customizable field to field which stores its upstream default. + XBlocks outside of edx-platform can override this in order to set + up their own customizable fields. + """ + return { + "display_name": "upstream_display_name", + "max_attempts": "upstream_max_attempts", + } + + def save(self, *args, **kwargs): + """ + Update `downstream_customized` when a customizable field is modified. + Uses `get_upstream_field_names` keys as the list of fields that are + customizable. + """ + ... + + @dataclass(frozen=True) + class UpstreamInfo: + """ + Metadata about a block's relationship with an upstream. + """ + usage_key: UsageKey + current_version: int + latest_version: int | None + sync_url: str + error: str | None + + @property + def sync_available(self) -> bool: + """ + Should the user be prompted to sync this block with upstream? + """ + return ( + self.latest_version + and self.current_version < self.latest_version + and not self.error + ) + + + ########################################################################### + # xmodule/modulestore/split_mongo/caching_descriptor_system.py + ########################################################################### + + class CachingDescriptorSystem(...): + + def validate_upstream_key(self, usage_key: UsageKey | str) -> UsageKey: + """ + Raise an error if the provided key is not a valid upstream reference. + Instead of explicitly checking whether a key is a LibraryLocatorV2, + callers should validate using this function, and use an `except` clause + to handle the case where the key is not a valid upstream. + Raises: InvalidKeyError, UnsupportedUpstreamKeyType + """ + ... + + def sync_from_upstream(self, *, downstream_key: UsageKey, apply_updates: bool) -> None: + """ + Python API for loading updates from upstream block. + Can choose whether or not to actually apply those updates... + apply_updates=False: Think "get fetch". + Use case: course import. + apply_updates=True: Think "git pull". + Use case: sync_updates handler. + Raises: InvalidKeyError, UnsupportedUpstreamKeyType, XBlockNotFoundError + """ + ... + + def get_upstream_info(self, downstream_key: UsageKey) -> UpstreamInfo | None: + """ + Python API for upstream metadata, or None. + Raises: InvalidKeyError, XBlockNotFoundError + """ + ... + +Finally, here is what the OLX for a library-sourced Problem XBlock in a course +might look like: + +.. code-block:: xml + + + + diff --git a/xmodule/docs/decisions/0003-library-content-block-schema.rst b/xmodule/docs/decisions/0003-library-content-block-schema.rst index cf49f72864e8..bf183dab7375 100644 --- a/xmodule/docs/decisions/0003-library-content-block-schema.rst +++ b/xmodule/docs/decisions/0003-library-content-block-schema.rst @@ -5,9 +5,9 @@ Evolving the library_content block schema Status ****** -**Provisional** +**Replaced** by the `Upstream-Downstream ADR`_. -Subject to change due to implementation learnings and stakeholder feedback. +.. _Upstream-Downstream ADR: https://docs/decisions/0020-upstream-block.rst Context ******* From a94b5af4035152e79662296bea3017b6e9ab925f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Mon, 16 Sep 2024 16:03:08 -0300 Subject: [PATCH 19/67] feat: return publishing information on get component endpoint [FC-0062] (#35476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: return publishing information on get component endpoint * feat: read data from component.versioning.draft * test: update tests * chore: update openedx-learning --------- Co-authored-by: Jillian Co-authored-by: Chris Chávez --- .../core/djangoapps/content_libraries/api.py | 17 +++++++- .../content_libraries/serializers.py | 5 +++ .../tests/test_content_libraries.py | 42 ++++++++++++++++--- .../tests/test_objecttag_export_helpers.py | 5 ++- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 9 files changed, 65 insertions(+), 14 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index c19c9bf880d0..816bd8c0099b 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -218,8 +218,12 @@ class LibraryXBlockMetadata: modified = attr.ib(type=datetime) display_name = attr.ib("") last_published = attr.ib(default=None, type=datetime) + last_draft_created = attr.ib(default=None, type=datetime) + last_draft_created_by = attr.ib("") + published_by = attr.ib("") has_unpublished_changes = attr.ib(False) tags_count = attr.ib(0) + created = attr.ib(default=None, type=datetime) @classmethod def from_component(cls, library_key, component): @@ -228,6 +232,14 @@ def from_component(cls, library_key, component): """ last_publish_log = component.versioning.last_publish_log + published_by = None + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = component.versioning.draft + last_draft_created = draft.created if draft else None + last_draft_created_by = draft.publishable_entity_version.created_by if draft else None + return cls( usage_key=LibraryUsageLocatorV2( library_key, @@ -238,7 +250,10 @@ def from_component(cls, library_key, component): created=component.created, modified=component.versioning.draft.created, last_published=None if last_publish_log is None else last_publish_log.published_at, - has_unpublished_changes=component.versioning.has_unpublished_changes + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=component.versioning.has_unpublished_changes, ) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 2062f96d93ae..e9e04646ace4 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -148,7 +148,12 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer): block_type = serializers.CharField(source="usage_key.block_type") display_name = serializers.CharField(read_only=True) + last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + published_by = serializers.CharField(read_only=True) + last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + last_draft_created_by = serializers.CharField(read_only=True) has_unpublished_changes = serializers.BooleanField(read_only=True) + created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) # When creating a new XBlock in a library, the slug becomes the ID part of # the definition key and usage key: diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 95b7309b3cd1..677178bb3b31 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -5,9 +5,11 @@ from unittest import skip import ddt +from datetime import datetime, timezone from uuid import uuid4 from django.contrib.auth.models import Group from django.test.client import Client +from freezegun import freeze_time from organizations.models import Organization from rest_framework.test import APITestCase @@ -270,12 +272,18 @@ def test_library_blocks(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'problem' XBlock to the library: - block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) + with freeze_time(create_date): + block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") self.assertDictContainsEntries(block_data, { "id": "lb:CL-TEST:téstlꜟط:problem:ࠒröblæm1", "display_name": "Blank Problem", "block_type": "problem", "has_unpublished_changes": True, + "last_published": None, + "published_by": None, + "last_draft_created": create_date.isoformat().replace('+00:00', 'Z'), + "last_draft_created_by": "Bob", }) block_id = block_data["id"] # Confirm that the result contains a definition key, but don't check its value, @@ -287,10 +295,14 @@ def test_library_blocks(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - self._commit_library_changes(lib_id) + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) + with freeze_time(publish_date): + self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False # And now the block information should also show that block has no unpublished changes: block_data["has_unpublished_changes"] = False + block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') + block_data["published_by"] = "Bob" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -311,13 +323,16 @@ def test_library_blocks(self): """.strip() - self._set_library_block_olx(block_id, new_olx) + update_date = datetime(2024, 8, 8, 8, 8, 8, tzinfo=timezone.utc) + with freeze_time(update_date): + self._set_library_block_olx(block_id, new_olx) # now reading it back, we should get that exact OLX (no change to whitespace etc.): assert self._get_library_block_olx(block_id) == new_olx # And the display name and "unpublished changes" status of the block should be updated: self.assertDictContainsEntries(self._get_library_block(block_id), { "display_name": "New Multi Choice Question", "has_unpublished_changes": True, + "last_draft_created": update_date.isoformat().replace('+00:00', 'Z') }) # Now view the XBlock's student_view (including draft changes): @@ -358,12 +373,18 @@ def test_library_blocks_studio_view(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'html' XBlock to the library: - block_data = self._add_block_to_library(lib_id, "html", "html1") + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) + with freeze_time(create_date): + block_data = self._add_block_to_library(lib_id, "html", "html1") self.assertDictContainsEntries(block_data, { "id": "lb:CL-TEST:testlib2:html:html1", "display_name": "Text", "block_type": "html", "has_unpublished_changes": True, + "last_published": None, + "published_by": None, + "last_draft_created": create_date.isoformat().replace('+00:00', 'Z'), + "last_draft_created_by": "Bob", }) block_id = block_data["id"] @@ -372,10 +393,14 @@ def test_library_blocks_studio_view(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - self._commit_library_changes(lib_id) + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) + with freeze_time(publish_date): + self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False # And now the block information should also show that block has no unpublished changes: block_data["has_unpublished_changes"] = False + block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') + block_data["published_by"] = "Bob" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -383,13 +408,17 @@ def test_library_blocks_studio_view(self): orig_olx = self._get_library_block_olx(block_id) assert ' Date: Tue, 17 Sep 2024 11:52:02 -0500 Subject: [PATCH 20/67] feat: Add collection tags to index [FC-0062] (#35483) * feat: Add collection tags to index * feat: Add api functions to update tags in collections * feat: Update tags on index when tag_object --- openedx/core/djangoapps/content/search/api.py | 25 ++++++++--- .../djangoapps/content/search/documents.py | 24 +++++++++++ .../djangoapps/content/search/handlers.py | 21 ++++++++-- .../content/search/tests/test_api.py | 41 ++++++++++++++++++- .../content/search/tests/test_documents.py | 11 +++++ .../core/djangoapps/content_libraries/api.py | 40 +++++++++++++++++- 6 files changed, 151 insertions(+), 11 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 4a775a710da6..71d09590d003 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -18,7 +18,7 @@ from meilisearch.errors import MeilisearchError from meilisearch.models.task import TaskInfo from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator from openedx_learning.api import authoring as authoring_api from common.djangoapps.student.roles import GlobalStaff from rest_framework.request import Request @@ -36,6 +36,7 @@ searchable_doc_for_library_block, searchable_doc_collections, searchable_doc_tags, + searchable_doc_tags_for_collection, ) log = logging.getLogger(__name__) @@ -395,13 +396,12 @@ def index_library(lib_key: str) -> list: return docs ############## Collections ############## - def index_collection_batch(batch, num_done) -> int: + def index_collection_batch(batch, num_done, library_key) -> int: docs = [] for collection in batch: try: doc = searchable_doc_for_collection(collection) - # Uncomment below line once collections are tagged. - # doc.update(searchable_doc_tags(collection.id)) + doc.update(searchable_doc_tags_for_collection(library_key, collection)) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing collection {collection}: {err}") @@ -428,7 +428,11 @@ def index_collection_batch(batch, num_done) -> int: status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") paginator = Paginator(collections, 100) for p in paginator.page_range: - num_collections_done = index_collection_batch(paginator.page(p).object_list, num_collections_done) + num_collections_done = index_collection_batch( + paginator.page(p).object_list, + num_collections_done, + lib_key, + ) status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") num_contexts_done += 1 @@ -604,6 +608,17 @@ def upsert_block_collections_index_docs(usage_key: UsageKey): _update_index_docs([doc]) +def upsert_collection_tags_index_docs(collection_usage_key: LibraryCollectionLocator): + """ + Updates the tags data in documents for the given library collection + """ + collection = lib_api.get_library_collection_from_usage_key(collection_usage_key) + + doc = {Fields.id: collection.id} + doc.update(searchable_doc_tags_for_collection(collection_usage_key.library_key, collection)) + _update_index_docs([doc]) + + def _get_user_orgs(request: Request) -> list[str]: """ Get the org.short_names for the organizations that the requesting user has OrgStaffRole or OrgInstructorRole. diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 6f19b610fe86..f9041468c296 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -10,6 +10,7 @@ from django.core.exceptions import ObjectDoesNotExist from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_learning.api import authoring as authoring_api +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx.core.djangoapps.content.search.models import SearchAccess from openedx.core.djangoapps.content_libraries import api as lib_api @@ -339,6 +340,28 @@ def searchable_doc_collections(usage_key: UsageKey) -> dict: return doc +def searchable_doc_tags_for_collection( + library_key: LibraryLocatorV2, + collection, +) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, with the tags data for the given library collection. + """ + doc = { + Fields.id: collection.id, + } + + collection_usage_key = lib_api.get_library_collection_usage_key( + library_key, + collection.key, + ) + + doc.update(_tags_for_content_object(collection_usage_key)) + + return doc + + def searchable_doc_for_course_block(block) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine @@ -382,6 +405,7 @@ def searchable_doc_for_collection(collection) -> dict: doc.update({ Fields.context_key: str(context_key), Fields.org: org, + Fields.usage_key: str(lib_api.get_library_collection_usage_key(context_key, collection.key)), }) except LearningPackage.contentlibrary.RelatedObjectDoesNotExist: log.warning(f"Related library not found for {collection}") diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 6a341c92ed2b..1605f8ebfd58 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -8,6 +8,7 @@ from django.dispatch import receiver from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import LibraryCollectionLocator from openedx_events.content_authoring.data import ( ContentLibraryData, ContentObjectChangedData, @@ -32,7 +33,12 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.search.models import SearchAccess -from .api import only_if_meilisearch_enabled, upsert_block_collections_index_docs, upsert_block_tags_index_docs +from .api import ( + only_if_meilisearch_enabled, + upsert_block_collections_index_docs, + upsert_block_tags_index_docs, + upsert_collection_tags_index_docs, +) from .tasks import ( delete_library_block_index_doc, delete_xblock_index_doc, @@ -191,12 +197,19 @@ def content_object_associations_changed_handler(**kwargs) -> None: # Check if valid if course or library block usage_key = UsageKey.from_string(str(content_object.object_id)) except InvalidKeyError: - log.error("Received invalid content object id") - return + try: + # Check if valid if library collection + usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id)) + except InvalidKeyError: + log.error("Received invalid content object id") + return # This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever. # So we allow a potential double "upsert" here. if not content_object.changes or "tags" in content_object.changes: - upsert_block_tags_index_docs(usage_key) + if isinstance(usage_key, LibraryCollectionLocator): + upsert_collection_tags_index_docs(usage_key) + else: + upsert_block_tags_index_docs(usage_key) if not content_object.changes or "collections" in content_object.changes: upsert_block_collections_index_docs(usage_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 023265f4d0f5..4aa41a156dab 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -185,9 +185,11 @@ def setUp(self): created_by=None, description="my collection description" ) + self.collection_usage_key = "lib-collection:org1:lib:MYCOL" self.collection_dict = { "id": self.collection.id, "block_id": self.collection.key, + "usage_key": self.collection_usage_key, "type": "collection", "display_name": "my_collection", "description": "my collection description", @@ -221,6 +223,8 @@ def test_reindex_meilisearch(self, mock_meilisearch): doc_problem2 = copy.deepcopy(self.doc_problem2) doc_problem2["tags"] = {} doc_problem2["collections"] = {} + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["tags"] = {} api.rebuild_index() assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 @@ -228,7 +232,7 @@ def test_reindex_meilisearch(self, mock_meilisearch): [ call([doc_sequential, doc_vertical]), call([doc_problem1, doc_problem2]), - call([self.collection_dict]), + call([doc_collection]), ], any_order=True, ) @@ -459,6 +463,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): doc_collection1_created = { "id": collection1.id, "block_id": collection1.key, + "usage_key": f"lib-collection:org1:lib:{collection1.key}", "type": "collection", "display_name": "Collection 1", "description": "First Collection", @@ -473,6 +478,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): doc_collection2_created = { "id": collection2.id, "block_id": collection2.key, + "usage_key": f"lib-collection:org1:lib:{collection2.key}", "type": "collection", "display_name": "Collection 2", "description": "Second Collection", @@ -487,6 +493,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): doc_collection2_updated = { "id": collection2.id, "block_id": collection2.key, + "usage_key": f"lib-collection:org1:lib:{collection2.key}", "type": "collection", "display_name": "Collection 2", "description": "Second Collection", @@ -501,6 +508,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): doc_collection1_updated = { "id": collection1.id, "block_id": collection1.key, + "usage_key": f"lib-collection:org1:lib:{collection1.key}", "type": "collection", "display_name": "Collection 1", "description": "First Collection", @@ -576,3 +584,34 @@ def test_delete_all_drafts(self, mock_meilisearch): mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( filter=delete_filter ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_tags_in_collections(self, mock_meilisearch): + # Tag collection + tagging_api.tag_object(self.collection_usage_key, self.taxonomyA, ["one", "two"]) + tagging_api.tag_object(self.collection_usage_key, self.taxonomyB, ["three", "four"]) + + # Build expected docs with tags at each stage + doc_collection_with_tags1 = { + "id": self.collection.id, + "tags": { + 'taxonomy': ['A'], + 'level0': ['A > one', 'A > two'] + } + } + doc_collection_with_tags2 = { + "id": self.collection.id, + "tags": { + 'taxonomy': ['A', 'B'], + 'level0': ['A > one', 'A > two', 'B > four', 'B > three'] + } + } + + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection_with_tags1]), + call([doc_collection_with_tags2]), + ], + any_order=True, + ) diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 7ff330c0b491..9d51bd127bb4 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -19,6 +19,7 @@ from ..documents import ( searchable_doc_for_course_block, searchable_doc_tags, + searchable_doc_tags_for_collection, searchable_doc_collections, searchable_doc_for_collection, searchable_doc_for_library_block, @@ -27,6 +28,7 @@ except RuntimeError: searchable_doc_for_course_block = lambda x: x searchable_doc_tags = lambda x: x + searchable_doc_tags_for_collection = lambda x: x searchable_doc_for_collection = lambda x: x searchable_doc_for_library_block = lambda x: x SearchAccess = {} @@ -76,6 +78,7 @@ def setUpClass(cls): created_by=None, description="my toy collection description" ) + cls.collection_usage_key = "lib-collection:edX:2012_Fall:TOY_COLLECTION" cls.library_block = library_api.create_library_block( cls.library.key, "html", @@ -109,6 +112,7 @@ def setUpClass(cls): tagging_api.tag_object(str(cls.html_block_key), cls.subject_tags, tags=["Chinese", "Jump Links"]) tagging_api.tag_object(str(cls.html_block_key), cls.difficulty_tags, tags=["Normal"]) tagging_api.tag_object(str(cls.library_block.usage_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(cls.collection_usage_key, cls.difficulty_tags, tags=["Normal"]) @property def toy_course_access_id(self): @@ -296,9 +300,12 @@ def test_html_library_block(self): def test_collection_with_library(self): doc = searchable_doc_for_collection(self.collection) + doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection)) + assert doc == { "id": self.collection.id, "block_id": self.collection.key, + "usage_key": self.collection_usage_key, "type": "collection", "org": "edX", "display_name": "Toy Collection", @@ -309,6 +316,10 @@ def test_collection_with_library(self): "breadcrumbs": [{"display_name": "some content_library"}], "created": 1680674828.0, "modified": 1680674828.0, + 'tags': { + 'taxonomy': ['Difficulty'], + 'level0': ['Difficulty > Normal'] + } } def test_collection_with_no_library(self): diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 816bd8c0099b..3dc33aec9616 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -73,7 +73,8 @@ from opaque_keys.edx.locator import ( LibraryLocatorV2, LibraryUsageLocatorV2, - LibraryLocator as LibraryLocatorV1 + LibraryLocator as LibraryLocatorV1, + LibraryCollectionLocator, ) from opaque_keys import InvalidKeyError from openedx_events.content_authoring.data import ( @@ -1262,6 +1263,43 @@ def update_library_collection_components( return collection +def get_library_collection_usage_key( + library_key: LibraryLocatorV2, + collection_key: str, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> LibraryCollectionLocator: + """ + Returns the LibraryCollectionLocator associated to a collection + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + return LibraryCollectionLocator(library_key, collection_key) + + +def get_library_collection_from_usage_key( + collection_usage_key: LibraryCollectionLocator, +) -> Collection: + """ + Return a Collection using the LibraryCollectionLocator + """ + + library_key = collection_usage_key.library_key + collection_key = collection_usage_key.collection_id + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + try: + return authoring_api.get_collection( + content_library.learning_package_id, + collection_key, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc + + # V1/V2 Compatibility Helpers # (Should be removed as part of # https://github.com/openedx/edx-platform/issues/32457) From 575e240961277991fecff760d9fe066e1b81a726 Mon Sep 17 00:00:00 2001 From: Isaac Lee <124631592+ilee2u@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:59:33 -0400 Subject: [PATCH 21/67] feat: add idv events to api (#35468) * feat: add idv events to api - moved what was in signals.py to a handlers.py (which is what their file should have been called) * chore: quality * fix: rename test file + imports * fix: change handler reverse url in other tests * fix: refactor signals and handlers pattern - following OEP-49 pattern for signals directory - user removed as param for update function - event now emitted after save * fix: unpin edx-name-affirmation * chore: add init to signals dir * fix: compile requirements * chore: quality * chore: fix some imports * chore: quality * test: added signal emissions to test_api * chore: lint --- lms/djangoapps/verify_student/api.py | 42 ++++++- lms/djangoapps/verify_student/apps.py | 2 +- .../test_retry_failed_photo_verifications.py | 2 +- ...curephotoverifications_post_save_signal.py | 2 +- .../verify_student/signals/__init__.py | 0 .../{signals.py => signals/handlers.py} | 14 +-- .../verify_student/signals/signals.py | 109 ++++++++++++++++++ .../verify_student/tests/test_api.py | 49 +++++++- .../{test_signals.py => test_handlers.py} | 26 ++--- requirements/constraints.txt | 4 - requirements/edx/base.txt | 6 +- requirements/edx/development.txt | 3 +- requirements/edx/doc.txt | 6 +- requirements/edx/testing.txt | 6 +- 14 files changed, 227 insertions(+), 44 deletions(-) create mode 100644 lms/djangoapps/verify_student/signals/__init__.py rename lms/djangoapps/verify_student/{signals.py => signals/handlers.py} (90%) create mode 100644 lms/djangoapps/verify_student/signals/signals.py rename lms/djangoapps/verify_student/tests/{test_signals.py => test_handlers.py} (88%) diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py index f61b90d682ff..941dd60453d4 100644 --- a/lms/djangoapps/verify_student/api.py +++ b/lms/djangoapps/verify_student/api.py @@ -13,6 +13,12 @@ from lms.djangoapps.verify_student.emails import send_verification_approved_email from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus from lms.djangoapps.verify_student.models import VerificationAttempt +from lms.djangoapps.verify_student.signals.signals import ( + emit_idv_attempt_approved_event, + emit_idv_attempt_created_event, + emit_idv_attempt_denied_event, + emit_idv_attempt_pending_event, +) from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from lms.djangoapps.verify_student.tasks import send_verification_status_email @@ -70,6 +76,14 @@ def create_verification_attempt(user: User, name: str, status: str, expiration_d expiration_datetime=expiration_datetime, ) + emit_idv_attempt_created_event( + attempt_id=verification_attempt.id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + return verification_attempt.id @@ -77,7 +91,7 @@ def update_verification_attempt( attempt_id: int, name: Optional[str] = None, status: Optional[str] = None, - expiration_datetime: Optional[datetime] = None + expiration_datetime: Optional[datetime] = None, ): """ Update a verification attempt. @@ -125,3 +139,29 @@ def update_verification_attempt( attempt.expiration_datetime = expiration_datetime attempt.save() + + user = attempt.user + if status == VerificationAttemptStatus.PENDING: + emit_idv_attempt_pending_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.APPROVED: + emit_idv_attempt_approved_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.DENIED: + emit_idv_attempt_denied_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) diff --git a/lms/djangoapps/verify_student/apps.py b/lms/djangoapps/verify_student/apps.py index f01bdef7e908..d553b9e0cf9a 100644 --- a/lms/djangoapps/verify_student/apps.py +++ b/lms/djangoapps/verify_student/apps.py @@ -17,5 +17,5 @@ def ready(self): """ Connect signal handlers. """ - from lms.djangoapps.verify_student import signals # pylint: disable=unused-import + from lms.djangoapps.verify_student.signals import signals # pylint: disable=unused-import from lms.djangoapps.verify_student import tasks # pylint: disable=unused-import diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py b/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py index 1c3f22aa30cd..8fa84efe3a85 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py @@ -121,7 +121,7 @@ def _create_attempts(self, num_attempts): for _ in range(num_attempts): self.create_upload_and_submit_attempt_for_user() - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_resubmit_in_date_range(self, send_idv_update_mock): call_command('retry_failed_photo_verifications', status="submitted", diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py b/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py index 99fd4ecd3a5f..c9e98a94dec0 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py @@ -38,7 +38,7 @@ def _create_attempts(self, num_attempts): for _ in range(num_attempts): self.create_and_submit_attempt_for_user() - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_command(self, send_idv_update_mock): call_command('trigger_softwaresecurephotoverifications_post_save_signal', start_date_time='2021-10-31 06:00:00') diff --git a/lms/djangoapps/verify_student/signals/__init__.py b/lms/djangoapps/verify_student/signals/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/verify_student/signals.py b/lms/djangoapps/verify_student/signals/handlers.py similarity index 90% rename from lms/djangoapps/verify_student/signals.py rename to lms/djangoapps/verify_student/signals/handlers.py index ae54deb74214..8a1d7b542b00 100644 --- a/lms/djangoapps/verify_student/signals.py +++ b/lms/djangoapps/verify_student/signals/handlers.py @@ -5,23 +5,23 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models.signals import post_save -from django.dispatch import Signal from django.dispatch.dispatcher import receiver from xmodule.modulestore.django import SignalHandler, modulestore from common.djangoapps.student.models_api import get_name, get_pending_name_change +from lms.djangoapps.verify_student.apps import VerifyStudentConfig # pylint: disable=unused-import +from lms.djangoapps.verify_student.signals.signals import idv_update_signal from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC -from .models import SoftwareSecurePhotoVerification, VerificationDeadline, VerificationAttempt +from lms.djangoapps.verify_student.models import ( + SoftwareSecurePhotoVerification, + VerificationDeadline, + VerificationAttempt +) log = logging.getLogger(__name__) -# Signal for emitting IDV submission and review updates -# providing_args = ["attempt_id", "user_id", "status", "full_name", "profile_name"] -idv_update_signal = Signal() - - @receiver(SignalHandler.course_published) def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument """ diff --git a/lms/djangoapps/verify_student/signals/signals.py b/lms/djangoapps/verify_student/signals/signals.py new file mode 100644 index 000000000000..c03d5f263191 --- /dev/null +++ b/lms/djangoapps/verify_student/signals/signals.py @@ -0,0 +1,109 @@ +""" +Signal definitions and functions to send those signals for the verify_student application. +""" + +from django.dispatch import Signal + +from openedx_events.learning.data import UserData, UserPersonalData, VerificationAttemptData +from openedx_events.learning.signals import ( + IDV_ATTEMPT_CREATED, + IDV_ATTEMPT_PENDING, + IDV_ATTEMPT_APPROVED, + IDV_ATTEMPT_DENIED, +) + +# Signal for emitting IDV submission and review updates +# providing_args = ["attempt_id", "user_id", "status", "full_name", "profile_name"] +idv_update_signal = Signal() + + +def _create_user_data(user): + """ + Helper function to create a UserData object. + """ + user_data = UserData( + id=user.id, + is_active=user.is_active, + pii=UserPersonalData( + username=user.username, + email=user.email, + name=user.get_full_name() + ) + ) + + return user_data + + +def emit_idv_attempt_created_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_CREATED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_CREATED + IDV_ATTEMPT_CREATED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_pending_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_PENDING Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_PENDING + IDV_ATTEMPT_PENDING.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_approved_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_APPROVED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_APPROVED + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_denied_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_DENIED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_DENIED + IDV_ATTEMPT_DENIED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) diff --git a/lms/djangoapps/verify_student/tests/test_api.py b/lms/djangoapps/verify_student/tests/test_api.py index 747c76f82b61..2be7b6580905 100644 --- a/lms/djangoapps/verify_student/tests/test_api.py +++ b/lms/djangoapps/verify_student/tests/test_api.py @@ -69,7 +69,8 @@ def setUp(self): ) self.attempt.save() - def test_create_verification_attempt(self): + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_created_event') + def test_create_verification_attempt(self, mock_created_event): expected_id = 2 self.assertEqual( create_verification_attempt( @@ -86,6 +87,13 @@ def test_create_verification_attempt(self): self.assertEqual(verification_attempt.name, 'Tester McTest') self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED) self.assertEqual(verification_attempt.expiration_datetime, datetime(2024, 12, 31, tzinfo=timezone.utc)) + mock_created_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=VerificationAttemptStatus.CREATED, + name='Tester McTest', + expiration_date=datetime(2024, 12, 31, tzinfo=timezone.utc), + ) def test_create_verification_attempt_no_expiration_datetime(self): expected_id = 2 @@ -129,7 +137,18 @@ def setUp(self): ('Tester McTest3', VerificationAttemptStatus.DENIED, datetime(2026, 12, 31, tzinfo=timezone.utc)), ) @ddt.unpack - def test_update_verification_attempt(self, name, status, expiration_datetime): + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_pending_event') + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_approved_event') + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_denied_event') + def test_update_verification_attempt( + self, + name, + status, + expiration_datetime, + mock_denied_event, + mock_approved_event, + mock_pending_event, + ): update_verification_attempt( attempt_id=self.attempt.id, name=name, @@ -145,6 +164,31 @@ def test_update_verification_attempt(self, name, status, expiration_datetime): self.assertEqual(verification_attempt.status, status) self.assertEqual(verification_attempt.expiration_datetime, expiration_datetime) + if status == VerificationAttemptStatus.PENDING: + mock_pending_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.APPROVED: + mock_approved_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.DENIED: + mock_denied_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + def test_update_verification_attempt_none_values(self): update_verification_attempt( attempt_id=self.attempt.id, @@ -166,6 +210,7 @@ def test_update_verification_attempt_not_found(self): VerificationAttempt.DoesNotExist, update_verification_attempt, attempt_id=999999, + name=None, status=VerificationAttemptStatus.APPROVED, ) diff --git a/lms/djangoapps/verify_student/tests/test_signals.py b/lms/djangoapps/verify_student/tests/test_handlers.py similarity index 88% rename from lms/djangoapps/verify_student/tests/test_signals.py rename to lms/djangoapps/verify_student/tests/test_handlers.py index 8d607988d4b4..40d80712f19d 100644 --- a/lms/djangoapps/verify_student/tests/test_signals.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -15,7 +15,7 @@ VerificationDeadline, VerificationAttempt ) -from lms.djangoapps.verify_student.signals import ( +from lms.djangoapps.verify_student.signals.handlers import ( _listen_for_course_publish, _listen_for_lms_retire, _listen_for_lms_retire_verification_attempts @@ -29,9 +29,9 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -class VerificationDeadlineSignalTest(ModuleStoreTestCase): +class VerificationDeadlineHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline signal + Tests for the VerificationDeadline handler """ def setUp(self): @@ -41,13 +41,13 @@ def setUp(self): VerificationDeadline.objects.all().delete() def test_no_deadline(self): - """ Verify the signal sets deadline to course end when no deadline exists.""" + """ Verify the handler sets deadline to course end when no deadline exists.""" _listen_for_course_publish('store', self.course.id) assert VerificationDeadline.deadline_for_course(self.course.id) == self.course.end def test_deadline(self): - """ Verify deadline is set to course end date by signal when changed. """ + """ Verify deadline is set to course end date by handler when changed. """ deadline = now() - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline) @@ -55,7 +55,7 @@ def test_deadline(self): assert VerificationDeadline.deadline_for_course(self.course.id) == self.course.end def test_deadline_explicit(self): - """ Verify deadline is unchanged by signal when explicitly set. """ + """ Verify deadline is unchanged by handler when explicitly set. """ deadline = now() - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline, is_explicit=True) @@ -66,9 +66,9 @@ def test_deadline_explicit(self): assert actual_deadline == deadline -class RetirementSignalTest(ModuleStoreTestCase): +class RetirementHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline signal + Tests for the VerificationDeadline handler """ def _create_entry(self): @@ -119,8 +119,8 @@ def test_idempotent(self): class PostSavePhotoVerificationTest(ModuleStoreTestCase): """ - Tests for the post_save signal on the SoftwareSecurePhotoVerification model. - This receiver should emit another signal that contains limited data about + Tests for the post_save handler on the SoftwareSecurePhotoVerification model. + This receiver should emit another handler that contains limited data about the verification attempt that was updated. """ @@ -132,7 +132,7 @@ def setUp(self): self.photo_id_image_url = 'https://test.photo' self.photo_id_key = 'test+key' - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_post_save_signal(self, mock_signal): # create new softwaresecureverification attempt = SoftwareSecurePhotoVerification.objects.create( @@ -165,7 +165,7 @@ def test_post_save_signal(self, mock_signal): full_name=attempt.user.profile.name ) - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_post_save_signal_pending_name(self, mock_signal): pending_name_change = do_name_change_request(self.user, 'Pending Name', 'test')[0] @@ -187,7 +187,7 @@ def test_post_save_signal_pending_name(self, mock_signal): ) -class RetirementSignalVerificationAttemptsTest(ModuleStoreTestCase): +class RetirementHandlerVerificationAttemptsTest(ModuleStoreTestCase): """ Tests for the LMS User Retirement signal for Verification Attempts """ diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 14b0ca47d22d..8b62125fe9de 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -142,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 - -# Temporary pin as to prevent a new version of edx-name-affirmation from being merged before we modify it to work -# properly along with work in this PR: https://github.com/openedx/edx-platform/pull/35468 -edx-name-affirmation==2.4.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a01046b11646..01d90f7930f5 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -482,10 +482,8 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/kernel.in -edx-name-affirmation==2.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/kernel.in +edx-name-affirmation==2.4.1 + # via -r requirements/edx/kernel.in edx-opaque-keys[django]==2.11.0 # via # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 909e340dc7cf..117c1a820a1c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -766,9 +766,8 @@ edx-milestones==0.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-name-affirmation==2.4.0 +edx-name-affirmation==2.4.1 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt edx-opaque-keys[django]==2.11.0 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index b1113585613f..5171eee3f916 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -562,10 +562,8 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt +edx-name-affirmation==2.4.1 + # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 41c917e38ed2..a213a4834214 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -588,10 +588,8 @@ edx-lint==5.3.7 # via -r requirements/edx/testing.in edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt +edx-name-affirmation==2.4.1 + # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via # -r requirements/edx/base.txt From 3de0dbd9eacd48dca62fa6ff2c00f2dd26b1282d Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 18 Sep 2024 15:54:01 +0500 Subject: [PATCH 22/67] feat: upgrading list_instructor_tasks to DRF ( 10th ) (#35332) * feat: upgrading simple api to drf compatible. --- lms/djangoapps/instructor/tests/test_api.py | 27 +++++++++---- lms/djangoapps/instructor/views/api.py | 40 ++++++++++++++----- lms/djangoapps/instructor/views/api_urls.py | 2 +- lms/djangoapps/instructor/views/serializer.py | 37 +++++++++++++++++ 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 6e0a2545f530..e8bcc81318da 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -4704,15 +4704,19 @@ class TestOauthInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollm Test endpoints using Oauth2 authentication. """ - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create( - entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') - ) - def setUp(self): super().setUp() + self.course = CourseFactory.create( + org='test_org', + course='test_course', + run='test_run', + entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') + ) + self.problem_location = msk_from_problem_urlname( + self.course.id, + 'robot-some-problem-urlname' + ) + self.problem_urlname = str(self.problem_location) self.other_user = UserFactory() dot_application = ApplicationFactory(user=self.other_user, authorization_grant_type='password') @@ -4744,7 +4748,14 @@ def setUp(self): "send-to": ["myself"], "subject": "This is subject", "message": "message" - }, 'data_researcher') + }, 'data_researcher'), + ('list_instructor_tasks', + { + 'problem_location_str': self.problem_urlname, + 'unique_student_identifier': self.other_user.email + }, + 'data_researcher'), + ('list_instructor_tasks', {}, 'data_researcher') ] self.fake_jwt = ('wyJUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFuZ2UtbWUiLCJleHAiOjE3MjU4OTA2NzIsImdyY' diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index d42e7173b0bf..58556ee9ab02 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -108,7 +108,7 @@ from lms.djangoapps.instructor_task.models import ReportStore from lms.djangoapps.instructor.views.serializer import ( AccessSerializer, BlockDueDateSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, - SendEmailSerializer, StudentAttemptsSerializer + SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer ) 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 @@ -2373,9 +2373,8 @@ def get(self, request, course_id): return _list_instructor_tasks(request=request, course_id=course_id) -@require_POST -@ensure_csrf_cookie -def list_instructor_tasks(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListInstructorTasks(APIView): """ List instructor tasks. @@ -2385,21 +2384,44 @@ def list_instructor_tasks(request, course_id): - `problem_location_str` and `unique_student_identifier` lists task history for problem AND student (intersection) """ - return _list_instructor_tasks(request=request, course_id=course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.SHOW_TASKS + serializer_class = ListInstructorTaskInputSerializer + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List instructor tasks. + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + return _list_instructor_tasks( + request=request, course_id=course_id, serialize_data=serializer.validated_data + ) @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_course_permission(permissions.SHOW_TASKS) -def _list_instructor_tasks(request, course_id): +def _list_instructor_tasks(request, course_id, serialize_data=None): """ List instructor tasks. Internal function with common code for both DRF and and tradition views. """ + # This method is also used by other APIs with the GET method. + # The query_params attribute is utilized for GET requests, + # where parameters are passed as query strings. + course_id = CourseKey.from_string(course_id) - params = getattr(request, 'query_params', request.POST) - problem_location_str = strip_if_string(params.get('problem_location_str', False)) - student = params.get('unique_student_identifier', None) + if serialize_data is not None: + problem_location_str = strip_if_string(serialize_data.get('problem_location_str', False)) + student = serialize_data.get('unique_student_identifier', None) + else: + params = getattr(request, 'query_params', request.POST) + problem_location_str = strip_if_string(params.get('problem_location_str', False)) + student = params.get('unique_student_identifier', None) + if student is not None: student = get_student_from_identifier(student) diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 0cb80238f7c2..9c0939a1c1b8 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -44,7 +44,7 @@ name='list_entrance_exam_instructor_tasks'), path('mark_student_can_skip_entrance_exam', api.mark_student_can_skip_entrance_exam, name='mark_student_can_skip_entrance_exam'), - path('list_instructor_tasks', api.list_instructor_tasks, name='list_instructor_tasks'), + path('list_instructor_tasks', api.ListInstructorTasks.as_view(), name='list_instructor_tasks'), path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'), path('list_forum_members', api.list_forum_members, name='list_forum_members'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 793acc9c6137..da91eba43124 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -61,6 +61,43 @@ def validate_unique_student_identifier(self, value): return user +class ListInstructorTaskInputSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for handling the input data for the problem response report generation API. + +Attributes: + unique_student_identifier (str): The email or username of the student. + This field is optional, but if provided, the `problem_location_str` + must also be provided. + problem_location_str (str): The string representing the location of the problem within the course. + This field is optional, unless `unique_student_identifier` is provided. + """ + unique_student_identifier = serializers.CharField( + max_length=255, + help_text="Email or username of student", + required=False + ) + problem_location_str = serializers.CharField( + help_text="Problem location", + required=False + ) + + def validate(self, data): + """ + Validate the data to ensure that if unique_student_identifier is provided, + problem_location_str must also be provided. + """ + unique_student_identifier = data.get('unique_student_identifier') + problem_location_str = data.get('problem_location_str') + + if unique_student_identifier and not problem_location_str: + raise serializers.ValidationError( + "unique_student_identifier must accompany problem_location_str" + ) + + return data + + class ShowStudentExtensionSerializer(serializers.Serializer): """ Serializer for validating and processing the student identifier. From 00632d9caee0bdeb001b27d831eebb2a28cbb279 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 18 Sep 2024 10:06:36 -0700 Subject: [PATCH 23/67] feat: When editing a v2 library xblock, update search index synchronously (#35495) --- openedx/core/djangoapps/content/search/handlers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 1605f8ebfd58..f50dead8474a 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -124,7 +124,9 @@ def library_block_updated_handler(**kwargs) -> None: log.error("Received null or incorrect data for event") return - upsert_library_block_index_doc.delay(str(library_block_data.usage_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) @receiver(LIBRARY_BLOCK_DELETED) @@ -138,7 +140,9 @@ def library_block_deleted(**kwargs) -> None: log.error("Received null or incorrect data for event") return - delete_library_block_index_doc.delay(str(library_block_data.usage_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + delete_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) @receiver(CONTENT_LIBRARY_UPDATED) From ad23992a5d972e4f91e720b80ec1fd4a7edae129 Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Thu, 19 Sep 2024 13:09:09 +0500 Subject: [PATCH 24/67] feat: added ORA graded notification (#35389) * feat: added ORA graded by staff notification * test: updated and added new unit tests * feat: added waffle flag and updated notification --- .../notifications/base_notification.py | 20 +++++++++++++++++++ .../djangoapps/notifications/config/waffle.py | 10 ++++++++++ .../core/djangoapps/notifications/handlers.py | 8 +++++++- .../core/djangoapps/notifications/models.py | 2 +- .../notifications/tests/test_views.py | 9 ++++++++- 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index a417d4540588..02b49df89444 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -207,6 +207,26 @@ 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], 'visible_to': [CourseStaffRole.ROLE, CourseInstructorRole.ROLE] }, + 'ora_grade_assigned': { + 'notification_app': 'grading', + 'name': 'ora_grade_assigned', + 'is_core': False, + 'info': '', + 'web': False, + 'email': False, + 'push': False, + 'email_cadence': EmailCadence.DAILY, + 'non_editable': [], + 'content_template': _('<{p}>You have received {points_earned} out of {points_possible} on your assessment: ' + '<{strong}>{ora_name}'), + 'content_context': { + 'ora_name': 'Name of ORA in course', + 'points_earned': 'Points earned', + 'points_possible': 'Points possible', + }, + 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], + }, } COURSE_NOTIFICATION_APPS = { diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index af89bb68574f..862dd32f7485 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -28,3 +28,13 @@ # .. toggle_warning: When the flag is ON, Email Notifications feature is enabled. # .. toggle_tickets: INF-1259 ENABLE_EMAIL_NOTIFICATIONS = WaffleFlag(f'{WAFFLE_NAMESPACE}.enable_email_notifications', __name__) + +# .. toggle_name: notifications.enable_ora_grade_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable ORA grade notifications +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2024-09-10 +# .. toggle_target_removal_date: 2024-10-10 +# .. toggle_tickets: INF-1304 +ENABLE_ORA_GRADE_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora_grade_notifications", __name__) diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index 505f4b5e7024..f28cb594ea6f 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -21,7 +21,7 @@ ForumRoleAudienceFilter, TeamAudienceFilter ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_ORA_GRADE_NOTIFICATION from openedx.core.djangoapps.notifications.models import CourseNotificationPreference log = logging.getLogger(__name__) @@ -72,6 +72,12 @@ def generate_user_notifications(signal, sender, notification_data, metadata, **k """ Watches for USER_NOTIFICATION_REQUESTED signal and calls send_web_notifications task """ + if ( + notification_data.notification_type == 'ora_grade_assigned' + and not ENABLE_ORA_GRADE_NOTIFICATION.is_enabled(notification_data.course_key) + ): + return + from openedx.core.djangoapps.notifications.tasks import send_notifications notification_data = notification_data.__dict__ notification_data['course_key'] = str(notification_data['course_key']) diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index e1bdf94acc33..77f7b991b546 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -23,7 +23,7 @@ ADDITIONAL_NOTIFICATION_CHANNEL_SETTINGS = ['email_cadence'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 11 +COURSE_NOTIFICATION_CONFIG_VERSION = 12 def get_course_notification_preference_config(): diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index e40e52078989..27b369d925af 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -313,7 +313,14 @@ def _expected_api_response(self, course=None): 'push': True, 'email_cadence': 'Daily', 'info': 'Notifications for submission grading.' - } + }, + 'ora_grade_assigned': { + 'web': False, + 'email': False, + 'push': False, + 'email_cadence': 'Daily', + 'info': '' + }, }, 'non_editable': {} } From 2e2c427af61e48942d7cb19c0bcca6a55cbe8fec Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Thu, 19 Sep 2024 16:46:26 +0500 Subject: [PATCH 25/67] chore: update ora2 version in requirements (#35505) --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ebf6d293554c..ce14eaae0091 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -834,7 +834,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/bundled.in packaging==24.1 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 90e25ef858dc..6cb56433fb9f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1387,7 +1387,7 @@ optimizely-sdk==4.1.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==6.11.2 +ora2==6.12.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 b2daa6c3395c..cf1f0b5f568d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -993,7 +993,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/base.txt packaging==24.1 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 15d04cce397c..935647c39d6e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1044,7 +1044,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/base.txt packaging==24.1 # via From 0196def99d95b084e41d4b29232f3a0004aedbb3 Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Thu, 19 Sep 2024 09:24:20 -0400 Subject: [PATCH 26/67] feat: use idv approved event (#35470) * feat: replace LEARNER_NOW_VERIFIED signal with new openedx-event --- .../tests/test_pipeline_integration.py | 2 +- .../docs/diagrams/certificate_generation.dsl | 2 +- lms/djangoapps/certificates/signals.py | 10 +++-- .../certificates/tests/test_signals.py | 19 +++++--- ...sso_verifications_for_old_account_links.py | 2 +- lms/djangoapps/verify_student/models.py | 44 ++++++++++++++----- openedx/core/djangoapps/signals/signals.py | 4 -- 7 files changed, 54 insertions(+), 29 deletions(-) diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 7b26cb041a0a..4bfc710fe901 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -583,7 +583,7 @@ def test_verification_signal(self): """ Verification signal is sent upon approval. """ - with mock.patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with mock.patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: # Begin the pipeline. pipeline.set_id_verification_status( auth_entry=pipeline.AUTH_ENTRY_LOGIN, diff --git a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl index beef611e4393..d7ca8fd9a400 100644 --- a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl +++ b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl @@ -31,7 +31,7 @@ workspace { } grades_app -> signal_handlers "Emits COURSE_GRADE_NOW_PASSED signal" - verify_student_app -> signal_handlers "Emits LEARNER_NOW_VERIFIED signal" + verify_student_app -> signal_handlers "Emits IDV_ATTEMPT_APPROVED signal" student_app -> signal_handlers "Emits ENROLLMENT_TRACK_UPDATED signal" allowlist -> signal_handlers "Emits APPEND_CERTIFICATE_ALLOWLIST signal" signal_handlers -> generation_handler "Invokes generate_allowlist_certificate()" diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index d8db7bbf9ce8..53055bf9c86e 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -32,9 +32,8 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_FAILED, COURSE_GRADE_NOW_PASSED, - LEARNER_NOW_VERIFIED ) -from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, IDV_ATTEMPT_APPROVED User = get_user_model() @@ -118,14 +117,17 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade') -@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed") -def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylint: disable=unused-argument +@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="learner_track_changed") +def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pylint: disable=unused-argument """ Listen for a signal indicating that the user's id verification status has changed. """ if not auto_certificate_generation_enabled(): return + event_data = kwargs.get('idv_attempt') + user = User.objects.get(id=event_data.user.id) + user_enrollments = CourseEnrollment.enrollments_for_user(user=user) expected_verification_status = IDVerificationService.user_status(user) expected_verification_status = expected_verification_status['status'] diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index d475cffbfb66..7b5552801349 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -13,22 +13,20 @@ from openedx_events.data import EventsMetadata from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from openedx_events.tests.utils import OpenEdxEventsTestMixin from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.certificates.api import has_self_generated_certificates_enabled from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - GeneratedCertificate -) +from lms.djangoapps.certificates.models import CertificateGenerationConfiguration, GeneratedCertificate from lms.djangoapps.certificates.signals import handle_exam_attempt_rejected_event from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.tests.utils import mock_passing_grade from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class SelfGeneratedCertsSignalTest(ModuleStoreTestCase): @@ -302,10 +300,17 @@ def test_failing_grade_allowlist(self): assert cert.status == CertificateStatuses.downloadable -class LearnerIdVerificationTest(ModuleStoreTestCase): +class LearnerIdVerificationTest(ModuleStoreTestCase, OpenEdxEventsTestMixin): """ Tests for certificate generation task firing on learner id verification """ + ENABLED_OPENEDX_EVENTS = ['org.openedx.learning.idv_attempt.approved.v1'] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.start_events_isolation() + def setUp(self): super().setUp() self.course_one = CourseFactory.create(self_paced=True) diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py index 4a93aa19f169..891ff9fda5d8 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py @@ -54,7 +54,7 @@ def test_performance(self): #self.assertNumQueries(100) def test_signal_called(self): - with patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: call_command('backfill_sso_verifications_for_old_account_links', '--provider-slug', self.provider.provider_id) # lint-amnesty, pylint: disable=line-too-long assert mock_signal.call_count == 1 diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 9d2195d1e5b0..23729c99a0b9 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -42,8 +42,9 @@ rsa_decrypt, rsa_encrypt ) -from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED from openedx.core.storage import get_storage +from openedx_events.learning.signals import IDV_ATTEMPT_APPROVED +from openedx_events.learning.data import UserData, VerificationAttemptData from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss @@ -248,13 +249,23 @@ def send_approval_signal(self, approved_by='None'): user_id=self.user, reviewer=approved_by )) - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=SSOVerification, - user=self.user + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from SSOVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from SSOVerification' log.info(message.format(user=self.user.username)) @@ -451,13 +462,24 @@ def approve(self, user_id=None, service=""): days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] ) self.save() - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=PhotoVerification, - user=self.user + + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from PhotoVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from PhotoVerification' log.info(message.format(user=self.user.username)) @status_before_must_be("ready", "must_retry") diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index ca693b4d109b..495389152f7a 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -36,9 +36,5 @@ # ] COURSE_GRADE_NOW_FAILED = Signal() -# Signal that indicates that a user has become verified for certificate purposes -# providing_args=['user'] -LEARNER_NOW_VERIFIED = Signal() - # providing_args=['user'] USER_ACCOUNT_ACTIVATED = Signal() # Signal indicating email verification From 61c3f6eaff9e831a189f9a4aa67e2b5a77d38e07 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 18 Sep 2024 16:41:29 -0300 Subject: [PATCH 27/67] fix: Adds a check to initialize legacy proctoring dashboard --- .../js/instructor_dashboard/instructor_dashboard.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lms/static/js/instructor_dashboard/instructor_dashboard.js b/lms/static/js/instructor_dashboard/instructor_dashboard.js index 02972a93b6c4..49fd01dbdd66 100644 --- a/lms/static/js/instructor_dashboard/instructor_dashboard.js +++ b/lms/static/js/instructor_dashboard/instructor_dashboard.js @@ -50,6 +50,12 @@ such that the value can be defined later than this assignment (file load order). $activeSection = null; + var usesProctoringLegacyView = function () { + // If the element #proctoring-mfe-view is present, then uses the new MFE + // and the legagy views should not be initialized. + return !document.getElementById('proctoring-mfe-view'); + } + SafeWaiter = (function() { function safeWaiter() { this.after_handlers = []; @@ -200,7 +206,7 @@ such that the value can be defined later than this assignment (file load order). } ]; // eslint-disable-next-line no-void - if (edx.instructor_dashboard.proctoring !== void 0) { + if (usesProctoringLegacyView() && edx.instructor_dashboard.proctoring !== void 0) { sectionsToInitialize = sectionsToInitialize.concat([ { constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView, From 166d94decfec4dae954800b7c37570bbfbee1d0e Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 19 Sep 2024 11:04:48 -0300 Subject: [PATCH 28/67] chore: Fixed a typo on a comment --- lms/static/js/instructor_dashboard/instructor_dashboard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/static/js/instructor_dashboard/instructor_dashboard.js b/lms/static/js/instructor_dashboard/instructor_dashboard.js index 49fd01dbdd66..f87e9db8e814 100644 --- a/lms/static/js/instructor_dashboard/instructor_dashboard.js +++ b/lms/static/js/instructor_dashboard/instructor_dashboard.js @@ -52,7 +52,7 @@ such that the value can be defined later than this assignment (file load order). var usesProctoringLegacyView = function () { // If the element #proctoring-mfe-view is present, then uses the new MFE - // and the legagy views should not be initialized. + // and the legacy views should not be initialized. return !document.getElementById('proctoring-mfe-view'); } From 8f88db2cad12cac6f7cc4b77c3bf2def6719486f Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan <60315450+aht007@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:14:47 +0500 Subject: [PATCH 29/67] feat: add course_run_key to learner home upgrade url (#35461) * fix: fix learner home URL to have course_run_key --- lms/djangoapps/learner_home/serializers.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index b3471715b9dc..3d156f3640ca 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -3,7 +3,7 @@ """ from datetime import date, timedelta -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin from django.conf import settings from django.urls import reverse @@ -132,7 +132,13 @@ def get_upgradeUrl(self, instance): ) if ecommerce_payment_page and verified_sku: - return f"{ecommerce_payment_page}?sku={verified_sku}" + query_params = { + 'sku': verified_sku, + 'course_run_key': str(instance.course_id) + } + encoded_params = urlencode(query_params) + upgrade_url = f"{ecommerce_payment_page}?{encoded_params}" + return upgrade_url def get_resumeUrl(self, instance): return self.context.get("resume_course_urls", {}).get(instance.course_id) From 46777610a4227589b0b36c6eb99da5a90815b04a Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Fri, 20 Sep 2024 17:06:19 +0500 Subject: [PATCH 30/67] feat: upgrading simple api to drf compatible ( 17th ) (#35394) * feat: upgrading simple api to drf compatible. --- lms/djangoapps/instructor/tests/test_api.py | 10 +++ lms/djangoapps/instructor/views/api.py | 71 ++++++++++++------- lms/djangoapps/instructor/views/api_urls.py | 2 +- lms/djangoapps/instructor/views/serializer.py | 7 ++ 4 files changed, 64 insertions(+), 26 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index e8bcc81318da..51fc514c4879 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -4175,6 +4175,16 @@ def test_change_due_date_with_reason(self): # This operation regenerates the cache, so we can use cached results from edx-when. assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_reset_due_date_with_reason(self): + url = reverse('reset_due_date', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, { + 'student': self.user1.username, + 'url': str(self.week1.location), + 'reason': 'Testing reason.' # this is optional field. + }) + assert response.status_code == 200 + assert 'Successfully reset due date for student' in response.content.decode('utf-8') + def test_change_to_invalid_due_date(self): url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 58556ee9ab02..6978eaf3fe96 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -137,7 +137,6 @@ handle_dashboard_error, keep_field_private, parse_datetime, - require_student_from_identifier, set_due_date_extension, strip_if_string, ) @@ -3035,37 +3034,59 @@ def post(self, request, course_id): due_date.strftime('%Y-%m-%d %H:%M'))) -@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', 'url') -def reset_due_date(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ResetDueDate(APIView): """ Rescinds a due date extension for a student on a particular unit. """ - course = get_course_by_id(CourseKey.from_string(course_id)) - student = require_student_from_identifier(request.POST.get('student')) - unit = find_unit(course, request.POST.get('url')) - reason = strip_tags(request.POST.get('reason', '')) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = BlockDueDateSerializer - version = getattr(course, 'course_version', None) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + reset a due date extension to a student for a particular unit. + params: + url (str): The URL related to the block that needs the due date update. + student (str): The email or username of the student whose access is being modified. + reason (str): Optional param. + """ + serializer_data = self.serializer_class(data=request.data, context={'disable_due_datetime': True}) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - original_due_date = get_date_for_block(course_id, unit.location, published_version=version) + student = serializer_data.validated_data.get('student') + if not student: + response_payload = { + 'error': f'Could not find student matching identifier: {request.data.get("student")}' + } + return JsonResponse(response_payload) - set_due_date_extension(course, unit, student, None, request.user, reason=reason) - if not original_due_date: - # It's possible the normal due date was deleted after an extension was granted: - return JsonResponse( - _("Successfully removed invalid due date extension (unit has no due date).") - ) + course = get_course_by_id(CourseKey.from_string(course_id)) + unit = find_unit(course, serializer_data.validated_data.get('url')) + reason = strip_tags(serializer_data.validated_data.get('reason', '')) + + version = getattr(course, 'course_version', None) + + original_due_date = get_date_for_block(course_id, unit.location, published_version=version) - original_due_date_str = original_due_date.strftime('%Y-%m-%d %H:%M') - return JsonResponse(_( - 'Successfully reset due date for student {0} for {1} ' - 'to {2}').format(student.profile.name, _display_unit(unit), - original_due_date_str)) + try: + set_due_date_extension(course, unit, student, None, request.user, reason=reason) + if not original_due_date: + # It's possible the normal due date was deleted after an extension was granted: + return JsonResponse( + _("Successfully removed invalid due date extension (unit has no due date).") + ) + + original_due_date_str = original_due_date.strftime('%Y-%m-%d %H:%M') + return JsonResponse(_( + 'Successfully reset due date for student {0} for {1} ' + 'to {2}').format(student.profile.name, _display_unit(unit), + original_due_date_str)) + + except Exception as error: # pylint: disable=broad-except + return JsonResponse({'error': str(error)}, status=400) @handle_dashboard_error diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 9c0939a1c1b8..a248b46ae531 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -51,7 +51,7 @@ path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'), path('change_due_date', api.ChangeDueDate.as_view(), name='change_due_date'), path('send_email', api.SendEmail.as_view(), name='send_email'), - path('reset_due_date', api.reset_due_date, name='reset_due_date'), + path('reset_due_date', api.ResetDueDate.as_view(), name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index da91eba43124..5d123ad66c81 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -215,3 +215,10 @@ def validate_student(self, value): return None return user + + def __init__(self, *args, **kwargs): + # Get context to check if `due_datetime` should be optional + disable_due_datetime = kwargs.get('context', {}).get('disable_due_datetime', False) + super().__init__(*args, **kwargs) + if disable_due_datetime: + self.fields['due_datetime'].required = False From 0f975adc14958c88e9448cf2feba5478e932f645 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 20 Sep 2024 08:51:51 -0400 Subject: [PATCH 31/67] feat: Be able to login to bare-metal studio easily. (#35172) * feat: Be able to login to bare-metal studio easily. Updating the documentation and the devstack.py files so that if you're running bare-metal you can easily setup studio login via the LMS. I also added the Ports that the various MFEs expect to the runserver scripts so that it's easier to run those locally as well. Co-authored-by: Kyle McCormick --- README.rst | 33 +++++++++++++++++++++++++++++++-- cms/envs/devstack.py | 3 ++- lms/envs/minimal.yml | 3 +++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index e74176faf91e..64186fca3ace 100644 --- a/README.rst +++ b/README.rst @@ -124,6 +124,35 @@ sites):: ./manage.py lms collectstatic ./manage.py cms collectstatic +Set up CMS SSO (for Development):: + + ./manage.py lms manage_user studio_worker example@example.com --unusable-password + # DO NOT DO THIS IN PRODUCTION. It will make your auth insecure. + ./manage.py lms create_dot_application studio-sso-id studio_worker \ + --grant-type authorization-code \ + --skip-authorization \ + --redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \ + --scopes user_id \ + --client-id 'studio-sso-id' \ + --client-secret 'studio-sso-secret' + +Set up CMS SSO (for Production): + +* Create the CMS user and the OAuth application:: + + ./manage.py lms manage_user studio_worker --unusable-password + ./manage.py lms create_dot_application studio-sso-id studio_worker \ + --grant-type authorization-code \ + --skip-authorization \ + --redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \ + --scopes user_id + +* Log into Django admin (eg. http://localhost:18000/admin/oauth2_provider/application/), + click into the application you created above (``studio-sso-id``), and copy its "Client secret". +* In your private LMS_CFG yaml file or your private Django settings module: + + * Set ``SOCIAL_AUTH_EDX_OAUTH2_KEY`` to the client ID (``studio-sso-id``). + * Set ``SOCIAL_AUTH_EDX_OAUTH2_SECRET`` to the client secret (which you copied). Run the Platform ---------------- @@ -131,11 +160,11 @@ First, ensure MySQL, Mongo, and Memcached are running. Start the LMS:: - ./manage.py lms runserver + ./manage.py lms runserver 18000 Start the CMS:: - ./manage.py cms runserver + ./manage.py cms runserver 18010 This will give you a mostly-headless Open edX platform. Most frontends have been migrated to "Micro-Frontends (MFEs)" which need to be installed and run diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index e944d67eda1b..1d3a510cdc4c 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -267,7 +267,8 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ################ Using LMS SSO for login to Studio ################ SOCIAL_AUTH_EDX_OAUTH2_KEY = 'studio-sso-key' SOCIAL_AUTH_EDX_OAUTH2_SECRET = 'studio-sso-secret' # in stage, prod would be high-entropy secret -SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = 'http://edx.devstack.lms:18000' # routed internally server-to-server +# routed internally server-to-server +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = ENV_TOKENS.get('SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT', 'http://edx.devstack.lms:18000') SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = 'http://localhost:18000' # used in browser redirect # Don't form the return redirect URL with HTTPS on devstack diff --git a/lms/envs/minimal.yml b/lms/envs/minimal.yml index d455d1f3dbf8..51d7bbf499c4 100644 --- a/lms/envs/minimal.yml +++ b/lms/envs/minimal.yml @@ -36,3 +36,6 @@ LMS_INTERNAL_ROOT_URL: "http://localhost" # So that Swagger config code doesn't complain API_ACCESS_MANAGER_EMAIL: "api-access@example.com" + +# So that you can login to studio on bare-metal +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT: 'http://localhost:18000' From c71414a247b5eebc50ac846ecdb4a327a707bff4 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 20 Sep 2024 22:42:41 +0530 Subject: [PATCH 32/67] feat: add block_id field to filterable attributes of meilisearch (#35493) --- openedx/core/djangoapps/content/search/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 71d09590d003..7fe964128e5a 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -315,6 +315,8 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: client.index(temp_index_name).update_distinct_attribute(Fields.usage_key) # Mark which attributes can be used for filtering/faceted search: client.index(temp_index_name).update_filterable_attributes([ + # Get specific block/collection using combination of block_id and context_key + Fields.block_id, Fields.block_type, Fields.context_key, Fields.org, From 4cd36d85b5e93504bbeac199cae25b4a3950f269 Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Mon, 23 Sep 2024 01:35:49 -0700 Subject: [PATCH 33/67] feat: added sender in bulk_email event (#35504) --- lms/djangoapps/bulk_email/tasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 2b96af786a97..184dfd0e6869 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -474,6 +474,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas 'course_id': str(course_email.course_id), 'to_list': [user_obj.get('email', '') for user_obj in to_list], 'total_recipients': total_recipients, + 'ace_enabled_for_bulk_email': is_bulk_email_edx_ace_enabled(), } ) # Exclude optouts (if not a retry): From 87771e76ce92e12ae7500657e178a659cccf7701 Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Mon, 23 Sep 2024 01:37:54 -0700 Subject: [PATCH 34/67] feat: replaced button and heading tags in email digest content (#35518) --- .../rest_api/discussions_notifications.py | 14 ++++++++++ .../tests/test_discussions_notifications.py | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index 25abcf80d486..f65faf7f2a67 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -399,4 +399,18 @@ def clean_thread_html_body(html_body): for match in html_body.find_all(tag): match.unwrap() + # Replace tags that are not allowed in email + tags_to_update = [ + {"source": "button", "target": "span"}, + {"source": "h1", "target": "h4"}, + {"source": "h2", "target": "h4"}, + {"source": "h3", "target": "h4"}, + ] + for tag_dict in tags_to_update: + for source_tag in html_body.find_all(tag_dict['source']): + target_tag = html_body.new_tag(tag_dict['target'], **source_tag.attrs) + if source_tag.string: + target_tag.string = source_tag.string + source_tag.replace_with(target_tag) + return str(html_body) 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 f1a71fd1239e..d92e1000feb5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py @@ -168,3 +168,29 @@ def test_only_script_tag(self): result = clean_thread_html_body(html_body) self.assertEqual(result.strip(), expected_output) + + def test_button_tag_replace(self): + """ + Tests that the clean_thread_html_body function replaces the button tag with span tag + """ + # Tests for button replacement tag with text + html_body = '' + expected_output = 'Button' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + # Tests button tag replacement without text + html_body = '' + expected_output = '' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_heading_tag_replace(self): + """ + Tests that the clean_thread_html_body function replaces the h1, h2 and h3 tags with h4 tag + """ + for tag in ['h1', 'h2', 'h3']: + html_body = f'<{tag}>Heading' + expected_output = '

Heading

' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) From 1a92009bd2d316de829fe126a52b18b0a4d3df93 Mon Sep 17 00:00:00 2001 From: Maxwell Frank <92897870+MaxFrank13@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:03:41 -0400 Subject: [PATCH 35/67] chore: Aperture code ownership update --- .github/CODEOWNERS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f9abcc671fb..a05b78e8837a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,6 +17,7 @@ lms/djangoapps/instructor_task/ lms/djangoapps/mobile_api/ openedx/core/djangoapps/credentials @openedx/2U-aperture openedx/core/djangoapps/credit @openedx/2U-aperture +openedx/core/djangoapps/enrollments/ @openedx/2U-aperture openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch openedx/core/djangoapps/user_api/ @openedx/2U-aperture @@ -37,8 +38,9 @@ lms/djangoapps/certificates/ @openedx/2U- # Discovery common/djangoapps/course_modes/ common/djangoapps/enrollment/ +lms/djangoapps/branding/ @openedx/2U-aperture lms/djangoapps/commerce/ -lms/djangoapps/experiments/ +lms/djangoapps/experiments/ @openedx/2U-aperture lms/djangoapps/learner_dashboard/ @openedx/2U-aperture lms/djangoapps/learner_home/ @openedx/2U-aperture openedx/features/content_type_gating/ From a2e29596177904d92d91bf9a5927ed295520a443 Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Mon, 23 Sep 2024 18:06:03 +0500 Subject: [PATCH 36/67] chore: update default notification preference for ora_grade_assigned (#35522) * chore: update default notification preference for ora_grade_assigned * test: updated tests --- openedx/core/djangoapps/notifications/base_notification.py | 4 ++-- openedx/core/djangoapps/notifications/tests/test_views.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 02b49df89444..b57d88cea616 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -212,8 +212,8 @@ 'name': 'ora_grade_assigned', 'is_core': False, 'info': '', - 'web': False, - 'email': False, + 'web': True, + 'email': True, 'push': False, 'email_cadence': EmailCadence.DAILY, 'non_editable': [], diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 27b369d925af..b7bd0414a27f 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -315,8 +315,8 @@ def _expected_api_response(self, course=None): 'info': 'Notifications for submission grading.' }, 'ora_grade_assigned': { - 'web': False, - 'email': False, + 'web': True, + 'email': True, 'push': False, 'email_cadence': 'Daily', 'info': '' From 872174e28d92556a2384525e674f0f809d2b2cc9 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 9 Sep 2024 09:58:19 -0400 Subject: [PATCH 37/67] build: Switch to ubuntu-latest for builds This code does not have any dependencies that are specific to any specific version of ubuntu. So instead of testing on a specific version and then needing to do work to keep the versions up-to-date, we switch to the ubuntu-latest target which should be sufficient for testing purposes. This work is being done as a part of https://github.com/openedx/platform-roadmap/issues/377 closes https://github.com/openedx/edx-platform/issues/35314 --- .github/workflows/ci-static-analysis.yml | 2 +- .github/workflows/compile-python-requirements.yml | 2 +- .github/workflows/js-tests.yml | 2 +- .github/workflows/lint-imports.yml | 2 +- .github/workflows/migrations-check.yml | 2 +- .github/workflows/publish-ci-docker-image.yml | 2 +- .github/workflows/pylint-checks.yml | 2 +- .github/workflows/quality-checks.yml | 2 +- .github/workflows/semgrep.yml | 2 +- .github/workflows/static-assets-check.yml | 2 +- .github/workflows/unit-tests.yml | 12 ++++++------ .github/workflows/upgrade-one-python-dependency.yml | 2 +- .github/workflows/verify-dunder-init.yml | 2 +- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml index 7e768a456463..a3b0527aad72 100644 --- a/.github/workflows/ci-static-analysis.yml +++ b/.github/workflows/ci-static-analysis.yml @@ -10,7 +10,7 @@ jobs: matrix: python-version: - "3.11" - os: ["ubuntu-20.04"] + os: ["ubuntu-latest"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml index 0ff99b9c685a..21cb80083f1d 100644 --- a/.github/workflows/compile-python-requirements.yml +++ b/.github/workflows/compile-python-requirements.yml @@ -15,7 +15,7 @@ defaults: jobs: recompile-python-dependencies: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out target branch diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 4d025e540163..c9d2d7ab1191 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] node-version: [18, 20] python-version: - "3.11" diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index 8ead8396bf39..e3c59ec09304 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -9,7 +9,7 @@ on: jobs: lint-imports: name: Lint Python Imports - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out branch diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 183b90effa29..ec3ff21e60bc 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] python-version: - "3.11" # 'pinned' is used to install the latest patch version of Django diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml index 0a9f50f6daf9..6a0f3768b7e6 100644 --- a/.github/workflows/publish-ci-docker-image.yml +++ b/.github/workflows/publish-ci-docker-image.yml @@ -7,7 +7,7 @@ on: jobs: push: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index eeb53c24ed98..58560bf3073f 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -8,7 +8,7 @@ on: jobs: run-pylint: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index cf8ffd5d2910..5445d70e3b4b 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] python-version: - "3.11" node-version: [20] diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 7f2b4925af8e..d880d7351766 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -17,7 +17,7 @@ jobs: runs-on: "${{ matrix.os }}" strategy: matrix: - os: ["ubuntu-20.04"] + os: ["ubuntu-latest"] python-version: - "3.11" diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index 7bbfd3369b6b..177416770400 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] python-version: - "3.11" node-version: [18, 20] diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3e442b75d4e7..a697700898de 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -15,7 +15,7 @@ concurrency: jobs: run-tests: name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: @@ -142,7 +142,7 @@ jobs: overwrite: true collect-and-verify: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Python @@ -207,7 +207,7 @@ jobs: # https://github.com/orgs/community/discussions/33579 success: name: Unit tests successful - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest if: always() needs: [run-tests] steps: @@ -218,7 +218,7 @@ jobs: jobs: ${{ toJSON(needs) }} compile-warnings-report: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: [run-tests] steps: - uses: actions/checkout@v4 @@ -246,7 +246,7 @@ jobs: overwrite: true merge-artifacts: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: [compile-warnings-report] steps: - name: Merge Pytest Warnings JSON Artifacts @@ -266,7 +266,7 @@ jobs: # Combine and upload coverage reports. coverage: if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false)) - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: [run-tests] strategy: matrix: diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml index 6ca5dfcb355e..84a00266e99f 100644 --- a/.github/workflows/upgrade-one-python-dependency.yml +++ b/.github/workflows/upgrade-one-python-dependency.yml @@ -28,7 +28,7 @@ defaults: jobs: upgrade-one-python-dependency: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out target branch diff --git a/.github/workflows/verify-dunder-init.yml b/.github/workflows/verify-dunder-init.yml index 611fc0afc6e3..9d920238ebd4 100644 --- a/.github/workflows/verify-dunder-init.yml +++ b/.github/workflows/verify-dunder-init.yml @@ -8,7 +8,7 @@ on: jobs: verify_dunder_init: name: Verify __init__.py Files - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out branch From d8eef6e347b36c61221c455fdf1931290939e298 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 11 Sep 2024 09:26:57 -0400 Subject: [PATCH 38/67] build: Run mongosh commands within the container. This is no longer installed by default on ubuntu and so we have to either manually install it or just run the relevant commands in the container here it's already available. This lets us do some of the test setup in a more robust way. --- .github/workflows/migrations-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index ec3ff21e60bc..f253d48e4f41 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Setup mongodb user run: | - mongosh edxapp --eval ' + docker exec ${{ job.services.mongo.id }} mongosh edxapp --eval ' db.createUser( { user: "edxapp", @@ -67,7 +67,7 @@ jobs: - name: Verify mongo and mysql db credentials run: | mysql -h 127.0.0.1 -uedxapp001 -ppassword -e "select 1;" edxapp - mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp + docker exec ${{ job.services.mongo.id }} mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp - name: Checkout repo uses: actions/checkout@v4 From e6e5bedf63fc393b95fc74501298fba72099d5bb Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 11 Sep 2024 10:05:23 -0400 Subject: [PATCH 39/67] fix: Don't start the mongo service. We stopped using mongo on the runner directly a while ago so this is just an errant start that should have been removed. --- .github/workflows/static-assets-check.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index 177416770400..0a417f9b1c79 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -72,9 +72,6 @@ jobs: run: | pip install -r requirements/edx/assets.txt - - name: Initiate Mongo DB Service - run: sudo systemctl start mongod - - name: Add node_modules bin to $Path run: echo $GITHUB_WORKSPACE/node_modules/.bin >> $GITHUB_PATH From 1804fbb13107bd9681c5c51c5c20e90edf9e09d2 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 17 Sep 2024 15:10:12 -0400 Subject: [PATCH 40/67] build!: enable md4 for testing. Operators Note: In newer versions of ubuntu the MD4 hashing algorithm is disabled by default. To enable it the openssl config needs to be updated in a manner similar to what's being done here. Alternatively, you can set the `FEATURES['ENABLE_BLAKE2B_HASHING']` setting to `True` which will switch to a newer hashing algorithm where MD4 was previously used. Because this hashing is being used as a part of the edx-platform caching mechanism, this will effectively clear the cache for the items that use this hash. The will impact any items where the cache key might have been too big to store in memcache so it's hard to predict exactly which items will be impacted. BREAKING CHANGE: See the operator note above for more details as this may break for users transitioning from Ubuntu 20.04 to newer versions. --- .github/workflows/unit-tests.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a697700898de..5fef1c8352ce 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -66,7 +66,29 @@ jobs: - name: install system requirements run: | - sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx + sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx openssl + + # This is needed until the ENABLE_BLAKE2B_HASHING can be removed and we + # can stop using MD4 by default. + - name: enable md4 hashing in libssl + run: | + cat < Date: Tue, 24 Sep 2024 02:31:20 -0700 Subject: [PATCH 41/67] fix: updated edx.ace.message_sent event (#35498) * fix: updated edx.ace.message_sent event * fix: fixed pylint checks --- lms/djangoapps/bulk_email/signals.py | 30 ++++++++++++---------------- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 17 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index 9f6540651eeb..fb8749bf45a9 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -1,7 +1,6 @@ """ Signal handlers for the bulk_email app """ -from django.contrib.auth import get_user_model from django.dispatch import receiver from eventtracking import tracker @@ -32,29 +31,26 @@ def ace_email_sent_handler(sender, **kwargs): """ When an email is sent using ACE, this method will create an event to detect ace email success status """ - # Fetch the message object from kwargs, defaulting to None if not present - message = kwargs.get('message', None) - - user_model = get_user_model() - try: - user_id = user_model.objects.get(email=message.recipient.email_address).id - except user_model.DoesNotExist: - user_id = None - course_email = message.context.get('course_email', None) - course_id = message.context.get('course_id') + # Fetch the message dictionary from kwargs, defaulting to {} if not present + message = kwargs.get('message', {}) + recipient = message.get('recipient', {}) + message_name = message.get('name', None) + context = message.get('context', {}) + email_address = recipient.get('email', None) + user_id = recipient.get('user_id', None) + channel = message.get('channel', None) + course_id = context.get('course_id', None) if not course_id: + course_email = context.get('course_email', None) course_id = course_email.course_id if course_email else None - try: - channel = sender.__class__.__name__ - except AttributeError: - channel = 'Other' + tracker.emit( 'edx.ace.message_sent', { - 'message_type': message.name, + 'message_type': message_name, 'channel': channel, 'course_id': course_id, 'user_id': user_id, - 'user_email': message.recipient.email_address, + 'user_email': email_address, } ) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4a65af081ce4..0b246816f559 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -401,7 +401,7 @@ drf-yasg==1.21.7 # via # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/kernel.in edx-api-doc-tools==1.8.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c5db40448d94..e16f83b049f7 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -657,7 +657,7 @@ drf-yasg==1.21.7 # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index ade1d06afbfe..2f916b40c535 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -481,7 +481,7 @@ drf-yasg==1.21.7 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/base.txt edx-api-doc-tools==1.8.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 527c82dce45a..93a0313a1123 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -505,7 +505,7 @@ drf-yasg==1.21.7 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/base.txt edx-api-doc-tools==1.8.0 # via From ed59e79417c713aa1a3fcb447df5268b7654daf1 Mon Sep 17 00:00:00 2001 From: Varsha Menon Date: Tue, 24 Sep 2024 09:23:57 -0400 Subject: [PATCH 42/67] fix: fix broken proctoring settings link --- cms/templates/course_outline.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 16d9ccbd4ca5..f44fdcfc8055 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -162,7 +162,7 @@

${_("This course has proctored exam settings that are % if mfe_proctored_exam_settings_url: <% url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='') %> ${Text(_("To update these settings go to the {link_start}Proctored Exam Settings page{link_end}.")).format( - link_start=HTML('').format( + link_start=HTML('').format( mfe_proctored_exam_settings_url=mfe_proctored_exam_settings_url ), link_end=HTML("") From 84d2ad95151d11960d76fb97bf484a070d6dfc5a Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Mon, 31 May 2021 17:45:03 +0200 Subject: [PATCH 43/67] fix: increase grades rounding precision Enabling the rounding in #16837 has been causing noticeable (up to 1 percentage point) differences between non-rounded subsection grades and a total grade for a course. This increases the grade precision to reduce the negative implications of double rounding. --- lms/djangoapps/grades/rest_api/v1/tests/test_views.py | 8 ++++---- lms/djangoapps/grades/scores.py | 4 ++-- lms/djangoapps/grades/tests/test_course_grade_factory.py | 8 ++++---- lms/djangoapps/grades/tests/test_subsection_grade.py | 2 +- xmodule/graders.py | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py index cd2107ec7c29..656e7e6b4396 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py @@ -302,7 +302,7 @@ def setUpClass(cls): + [ { 'category': 'Homework', - 'detail': 'Homework Average = 0%', + 'detail': 'Homework Average = 0.00%', 'label': 'HW Avg', 'percent': 0.0, 'prominent': True } @@ -332,21 +332,21 @@ def setUpClass(cls): }, { 'category': 'Lab', - 'detail': 'Lab Average = 0%', + 'detail': 'Lab Average = 0.00%', 'label': 'Lab Avg', 'percent': 0.0, 'prominent': True }, { 'category': 'Midterm Exam', - 'detail': 'Midterm Exam = 0%', + 'detail': 'Midterm Exam = 0.00%', 'label': 'Midterm', 'percent': 0.0, 'prominent': True }, { 'category': 'Final Exam', - 'detail': 'Final Exam = 0%', + 'detail': 'Final Exam = 0.00%', 'label': 'Final', 'percent': 0.0, 'prominent': True diff --git a/lms/djangoapps/grades/scores.py b/lms/djangoapps/grades/scores.py index f621d85ea17b..38dd0dc18926 100644 --- a/lms/djangoapps/grades/scores.py +++ b/lms/djangoapps/grades/scores.py @@ -162,8 +162,8 @@ def compute_percent(earned, possible): Returns the percentage of the given earned and possible values. """ if possible > 0: - # Rounds to two decimal places. - return around(earned / possible, decimals=2) + # Rounds to four decimal places. + return around(earned / possible, decimals=4) else: return 0.0 diff --git a/lms/djangoapps/grades/tests/test_course_grade_factory.py b/lms/djangoapps/grades/tests/test_course_grade_factory.py index 4e3bcde0ad95..2066b6cd0d7c 100644 --- a/lms/djangoapps/grades/tests/test_course_grade_factory.py +++ b/lms/djangoapps/grades/tests/test_course_grade_factory.py @@ -185,26 +185,26 @@ def test_course_grade_summary(self): 'section_breakdown': [ { 'category': 'Homework', - 'detail': 'Homework 1 - Test Sequential X with an & Ampersand - 50% (1/2)', + 'detail': 'Homework 1 - Test Sequential X with an & Ampersand - 50.00% (1/2)', 'label': 'HW 01', 'percent': 0.5 }, { 'category': 'Homework', - 'detail': 'Homework 2 - Test Sequential A - 0% (0/1)', + 'detail': 'Homework 2 - Test Sequential A - 0.00% (0/1)', 'label': 'HW 02', 'percent': 0.0 }, { 'category': 'Homework', - 'detail': 'Homework Average = 25%', + 'detail': 'Homework Average = 25.00%', 'label': 'HW Avg', 'percent': 0.25, 'prominent': True }, { 'category': 'NoCredit', - 'detail': 'NoCredit Average = 0%', + 'detail': 'NoCredit Average = 0.00%', 'label': 'NC Avg', 'percent': 0, 'prominent': True diff --git a/lms/djangoapps/grades/tests/test_subsection_grade.py b/lms/djangoapps/grades/tests/test_subsection_grade.py index 7dd39af4ece2..2398e7a71000 100644 --- a/lms/djangoapps/grades/tests/test_subsection_grade.py +++ b/lms/djangoapps/grades/tests/test_subsection_grade.py @@ -14,7 +14,7 @@ @ddt class SubsectionGradeTest(GradeTestBase): # lint-amnesty, pylint: disable=missing-class-docstring - @data((50, 100, .50), (59.49, 100, .59), (59.51, 100, .60), (59.50, 100, .60), (60.5, 100, .60)) + @data((50, 100, .5), (.5949, 100, .0059), (.5951, 100, .006), (.595, 100, .0059), (.605, 100, .006)) @unpack def test_create_and_read(self, mock_earned, mock_possible, expected_result): with mock_get_score(mock_earned, mock_possible): diff --git a/xmodule/graders.py b/xmodule/graders.py index a587204d682e..5261b9f4479a 100644 --- a/xmodule/graders.py +++ b/xmodule/graders.py @@ -387,7 +387,7 @@ def grade(self, grade_sheet, generate_random_scores=False): section_name = scores[i].display_name percentage = scores[i].percent_graded - summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})" + summary_format = "{section_type} {index} - {name} - {percent:.2%} ({earned:.3n}/{possible:.3n})" summary = summary_format.format( index=i + self.starting_index, section_type=self.section_type, @@ -421,7 +421,7 @@ def grade(self, grade_sheet, generate_random_scores=False): if len(breakdown) == 1: # if there is only one entry in a section, suppress the existing individual entry and the average, # and just display a single entry for the section. - total_detail = "{section_type} = {percent:.0%}".format( + total_detail = "{section_type} = {percent:.2%}".format( percent=total_percent, section_type=self.section_type, ) @@ -430,7 +430,7 @@ def grade(self, grade_sheet, generate_random_scores=False): 'detail': total_detail, 'category': self.category, 'prominent': True}, ] else: # Translators: "Homework Average = 0%" - total_detail = _("{section_type} Average = {percent:.0%}").format( + total_detail = _("{section_type} Average = {percent:.2%}").format( percent=total_percent, section_type=self.section_type ) From aeebac97baf7fe3828f7684272aaadfd43ae7ea8 Mon Sep 17 00:00:00 2001 From: Varsha Menon Date: Tue, 24 Sep 2024 10:52:31 -0400 Subject: [PATCH 44/67] feat: add verification attempt django admin --- lms/djangoapps/verify_student/admin.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py index 6de066e6f797..1d3124580ad1 100644 --- a/lms/djangoapps/verify_student/admin.py +++ b/lms/djangoapps/verify_student/admin.py @@ -2,14 +2,14 @@ Admin site configurations for verify_student. """ - from django.contrib import admin from lms.djangoapps.verify_student.models import ( ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, - SSPVerificationRetryConfig + SSPVerificationRetryConfig, + VerificationAttempt ) @@ -50,3 +50,13 @@ class SSPVerificationRetryAdmin(admin.ModelAdmin): Admin for the SSPVerificationRetryConfig table. """ pass # lint-amnesty, pylint: disable=unnecessary-pass + + +@admin.register(VerificationAttempt) +class VerificationAttemptAdmin(admin.ModelAdmin): + """ + Admin for the VerificationAttempt table. + """ + list_display = ('id', 'user', 'name', 'status', 'expiration_datetime',) + raw_id_fields = ('user',) + search_fields = ('user__username', 'name',) From 47021e9ae406967f111577d5e389d608408a9cf9 Mon Sep 17 00:00:00 2001 From: Hamzah Ullah Date: Tue, 24 Sep 2024 13:04:14 -0400 Subject: [PATCH 45/67] chore: add frontend-app-learner-portal-enteprise localhost to trusted origin (#35529) --- lms/envs/devstack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 7a06f717996c..01100e924059 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -546,6 +546,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing 'http://localhost:1992', # frontend-app-ora 'http://localhost:2002', # frontend-app-discussions 'http://localhost:1991', # frontend-app-admin-portal + 'http://localhost:8734', # frontend-app-learner-portal-enterprise 'http://localhost:1999', # frontend-app-authn 'http://localhost:18450', # frontend-app-support-tools 'http://localhost:1994', # frontend-app-gradebook From b50c42318c427dd8e4459c2141a51445e7b230d6 Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Wed, 25 Sep 2024 14:40:08 +0500 Subject: [PATCH 46/67] feat: added country disabling feature (#35451) * feat: added country disabling feature --- cms/envs/common.py | 7 +++ cms/envs/production.py | 7 +++ lms/envs/common.py | 8 ++++ lms/envs/production.py | 7 +++ .../core/djangoapps/user_api/accounts/api.py | 7 ++- .../user_api/accounts/tests/test_api.py | 31 ++++++++++--- .../djangoapps/user_authn/config/waffle.py | 1 - .../user_authn/views/registration_form.py | 14 +++++- .../user_authn/views/tests/test_register.py | 46 ++++++++++++++++++- .../core/djangoapps/user_authn/views/utils.py | 17 +++++++ 10 files changed, 133 insertions(+), 12 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 34dd8503f35e..79186248414d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2936,3 +2936,10 @@ def _should_send_learning_badge_events(settings): # See https://www.meilisearch.com/docs/learn/security/tenant_tokens MEILISEARCH_INDEX_PREFIX = "" MEILISEARCH_API_KEY = "devkey" + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = [] diff --git a/cms/envs/production.py b/cms/envs/production.py index 50519b55229b..ad7667772f9a 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -689,3 +689,10 @@ def get_env_setting(setting): } BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID) + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', []) diff --git a/lms/envs/common.py b/lms/envs/common.py index 334669215397..f2bcfa822b6e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5544,3 +5544,11 @@ def _should_send_learning_badge_events(settings): # .. setting_default: empty dictionary # .. setting_description: Dictionary with additional information that you want to share in the report. SURVEY_REPORT_EXTRA_DATA = {} + + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = [] diff --git a/lms/envs/production.py b/lms/envs/production.py index a1acd692f4e1..6dc6be634178 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -1124,3 +1124,10 @@ def get_env_setting(setting): EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs(EVENT_BUS_PRODUCER_CONFIG, ENV_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {})) BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID) + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', []) diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 6e7a21118852..6cc466ba0038 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -3,7 +3,6 @@ Programmatic integration point for User API Accounts sub-application """ - import datetime import re @@ -152,6 +151,12 @@ def update_account_settings(requesting_user, update, username=None): _validate_email_change(user, update, field_errors) _validate_secondary_email(user, update, field_errors) + if update.get('country', '') in settings.DISABLED_COUNTRIES: + field_errors['country'] = { + 'developer_message': 'Country is disabled for registration', + 'user_message': 'This country cannot be selected for user registration' + } + old_name = _validate_name_change(user_profile, update, field_errors) old_language_proficiencies = _get_old_language_proficiencies_if_updating(user_profile, update) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 81a3158eb5bb..5123c4cf41c2 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -3,7 +3,6 @@ Most of the functionality is covered in test_views.py. """ - import datetime import itertools import unicodedata @@ -16,6 +15,7 @@ from django.http import HttpResponse from django.test import TestCase from django.test.client import RequestFactory +from django.test.utils import override_settings from django.urls import reverse from pytz import UTC from social_django.models import UserSocialAuth @@ -82,7 +82,8 @@ def create_account(self, username, password, email): @skip_unless_lms @ddt.ddt -@patch('common.djangoapps.student.views.management.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True)) # lint-amnesty, pylint: disable=line-too-long +@patch('common.djangoapps.student.views.management.render_to_response', + Mock(side_effect=mock_render_to_response, autospec=True)) # lint-amnesty, pylint: disable=line-too-long class TestAccountApi(UserSettingsEventTestMixin, EmailTemplateTagMixin, CreateAccountMixin, RetirementTestCase): """ These tests specifically cover the parts of the API methods that are not covered by test_views.py. @@ -205,7 +206,7 @@ def test_add_social_links(self): account_settings = get_account_settings(self.default_request)[0] assert account_settings['social_links'] == \ - sorted((original_social_links + extra_social_links), key=(lambda s: s['platform'])) + sorted((original_social_links + extra_social_links), key=(lambda s: s['platform'])) def test_replace_social_links(self): original_facebook_link = dict(platform="facebook", social_link="https://www.facebook.com/myself") @@ -306,7 +307,7 @@ def test_update_validation_error_for_enterprise( with pytest.raises(AccountValidationError) as validation_error: update_account_settings(self.user, update_data) field_errors = validation_error.value.field_errors - assert 'This field is not editable via this API' ==\ + assert 'This field is not editable via this API' == \ field_errors[field_name_value[0]]['developer_message'] else: update_account_settings(self.user, update_data) @@ -424,8 +425,8 @@ def test_name_update_does_not_require_idv(self, has_passable_cert, enrolled_in_v """ Test that the user can change their name if change does not require IDV. """ - with patch('openedx.core.djangoapps.user_api.accounts.api.get_certificates_for_user') as mock_get_certs,\ - patch('openedx.core.djangoapps.user_api.accounts.api.get_verified_enrollments') as \ + with patch('openedx.core.djangoapps.user_api.accounts.api.get_certificates_for_user') as mock_get_certs, \ + patch('openedx.core.djangoapps.user_api.accounts.api.get_verified_enrollments') as \ mock_get_verified_enrollments: mock_get_certs.return_value = ( [{'status': CertificateStatuses.downloadable}] if @@ -439,7 +440,8 @@ def test_name_update_does_not_require_idv(self, has_passable_cert, enrolled_in_v assert account_settings['name'] == 'New Name' @patch('django.core.mail.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) # lint-amnesty, pylint: disable=line-too-long + @patch('common.djangoapps.student.views.management.render_to_string', + Mock(side_effect=mock_render_to_string, autospec=True)) def test_update_sending_email_fails(self, send_mail): """Test what happens if all validation checks pass, but sending the email for email change fails.""" send_mail.side_effect = [Exception, None] @@ -514,6 +516,7 @@ def test_language_proficiency_eventing(self): """ Test that eventing of language proficiencies, which happens update_account_settings method, behaves correctly. """ + def verify_event_emitted(new_value, old_value): """ Confirm that the user setting event was properly emitted @@ -571,6 +574,20 @@ def test_change_country_removes_state(self): assert account_settings['country'] is None assert account_settings['state'] is None + @override_settings(DISABLED_COUNTRIES=['KP']) + def test_change_to_disabled_country(self): + """ + Test that changing the country to a disabled country is not allowed + """ + # First set the country and state + update_account_settings(self.user, {"country": UserProfile.COUNTRY_WITH_STATES, "state": "MA"}) + account_settings = get_account_settings(self.default_request)[0] + assert account_settings['country'] == UserProfile.COUNTRY_WITH_STATES + assert account_settings['state'] == 'MA' + + with self.assertRaises(AccountValidationError): + update_account_settings(self.user, {"country": "KP"}) + def test_get_name_validation_error_too_long(self): """ Test validation error when the name is too long. diff --git a/openedx/core/djangoapps/user_authn/config/waffle.py b/openedx/core/djangoapps/user_authn/config/waffle.py index c34c81e1d063..3cbb0cb2e18b 100644 --- a/openedx/core/djangoapps/user_authn/config/waffle.py +++ b/openedx/core/djangoapps/user_authn/config/waffle.py @@ -2,7 +2,6 @@ Waffle flags and switches for user authn. """ - from edx_toggles.toggles import WaffleSwitch _WAFFLE_NAMESPACE = 'user_authn' diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 1d1089ce0214..7a0207f8b93c 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -23,6 +23,7 @@ from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_api.helpers import FormDescription from openedx.core.djangoapps.user_authn.utils import check_pwned_password, is_registration_api_v1 as is_api_v1 +from openedx.core.djangoapps.user_authn.views.utils import remove_disabled_country_from_list from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.api import enterprise_customer_for_request from common.djangoapps.student.models import ( @@ -297,6 +298,15 @@ def cleaned_extended_profile(self): if key in self.extended_profile_fields and value is not None } + def clean_country(self): + """ + Check if the user's country is in the embargoed countries list. + """ + country = self.cleaned_data.get("country") + if country in settings.DISABLED_COUNTRIES: + raise ValidationError(_("Registration from this country is not allowed due to restrictions.")) + return self.cleaned_data.get("country") + def get_registration_extension_form(*args, **kwargs): """ @@ -686,7 +696,7 @@ def _add_marketing_emails_opt_in_field(self, form_desc, required=False): """ opt_in_label = _( 'I agree that {platform_name} may send me marketing messages.').format( - platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), ) form_desc.add_field( @@ -974,7 +984,7 @@ def _add_country_field(self, form_desc, required=True): label=country_label, instructions=country_instructions, field_type="select", - options=list(countries), + options=list(remove_disabled_country_from_list(dict(countries)).items()), include_default_option=True, required=required, error_messages={ diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 02d5a72074f5..16f7da8010d6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -65,6 +65,7 @@ password_validators_instruction_texts, password_validators_restrictions ) + ENABLE_AUTO_GENERATED_USERNAME = settings.FEATURES.copy() ENABLE_AUTO_GENERATED_USERNAME['ENABLE_AUTO_GENERATED_USERNAME'] = True @@ -1556,7 +1557,7 @@ def test_activation_email(self): assert len(mail.outbox) == 1 sent_email = mail.outbox[0] assert sent_email.to == [self.EMAIL] - assert sent_email.subject ==\ + assert sent_email.subject == \ f'Action Required: Activate your {settings.PLATFORM_NAME} account' assert f'high-quality {settings.PLATFORM_NAME} courses' in sent_email.body @@ -2468,6 +2469,31 @@ def test_register_error_with_pwned_password(self, emit): }) assert response.status_code == 400 + @override_settings(DISABLED_COUNTRIES=['KP']) + def test_register_with_disabled_country(self): + """ + Test case to check user registration is forbidden when registration is disabled for a country + """ + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + "country": "KP", + }) + assert response.status_code == 400 + response_json = json.loads(response.content.decode('utf-8')) + self.assertDictEqual( + response_json, + {'country': + [ + { + 'user_message': 'Registration from this country is not allowed due to restrictions.' + } + ], 'error_code': 'validation-error'} + ) + @httpretty.activate @ddt.ddt @@ -2575,6 +2601,24 @@ def test_success(self): self._verify_user_existence(user_exists=True, social_link_exists=True, user_is_active=False) + @override_settings(DISABLED_COUNTRIES=['US']) + def test_with_disabled_country(self): + """ + Test case to check user registration is forbidden when registration is disabled for a country + """ + self._verify_user_existence(user_exists=False, social_link_exists=False) + self._setup_provider_response(success=True) + response = self.client.post(self.url, self.data()) + assert response.status_code == 400 + assert response.json() == { + 'country': [ + { + 'user_message': 'Registration from this country is not allowed due to restrictions.' + } + ], 'error_code': 'validation-error' + } + self._verify_user_existence(user_exists=False, social_link_exists=False, user_is_active=False) + def test_unlinked_active_user(self): user = UserFactory() response = self.client.post(self.url, self.data(user)) diff --git a/openedx/core/djangoapps/user_authn/views/utils.py b/openedx/core/djangoapps/user_authn/views/utils.py index 9b4054bd4037..b9fb096621f2 100644 --- a/openedx/core/djangoapps/user_authn/views/utils.py +++ b/openedx/core/djangoapps/user_authn/views/utils.py @@ -3,6 +3,8 @@ """ import logging import re +from typing import Dict + from django.conf import settings from django.contrib import messages from django.utils.translation import gettext as _ @@ -177,3 +179,18 @@ def get_auto_generated_username(data): # We generate the username regardless of whether the name is empty or invalid. We do this # because the name validations occur later, ensuring that users cannot create an account without a valid name. return f"{username_prefix}_{username_suffix}" if username_prefix else username_suffix + + +def remove_disabled_country_from_list(countries: Dict) -> Dict: + """ + Remove disabled countries from the list of countries. + + Args: + - countries (dict): List of countries. + + Returns: + - dict: Dict of countries with disabled countries removed. + """ + for country_code in settings.DISABLED_COUNTRIES: + del countries[country_code] + return countries From 9ae65bbe9dd0292f87090e6dda684ceef743e756 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Wed, 25 Sep 2024 16:01:05 +0500 Subject: [PATCH 47/67] feat: upgrade get_student_enrollment_status api with drf (22nd) (#35464) * feat!: upgrading simple api with DRF. --- lms/djangoapps/instructor/views/api.py | 87 +++++++++---------- lms/djangoapps/instructor/views/api_urls.py | 3 +- lms/djangoapps/instructor/views/serializer.py | 28 +++--- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 45c460d32908..cae166e7b9be 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -108,7 +108,7 @@ from lms.djangoapps.instructor_task.models import ReportStore from lms.djangoapps.instructor.views.serializer import ( AccessSerializer, BlockDueDateSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, - SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer + SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer, UniqueStudentIdentifierSerializer ) 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 @@ -1703,60 +1703,55 @@ def post(self, request, course_id): return JsonResponse({"status": success_status}) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.VIEW_ENROLLMENTS) -@require_post_params( - unique_student_identifier="email or username of student for whom to get enrollment status" -) -def get_student_enrollment_status(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class GetStudentEnrollmentStatus(APIView): """ Get the enrollment status of a student. - Limited to staff access. - - Takes query parameter unique_student_identifier """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_ENROLLMENTS - error = '' - user = None - mode = None - is_active = None + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Permission: Limited to staff access. + Takes query parameter unique_student_identifier + """ + error = '' + mode = None + is_active = None - course_id = CourseKey.from_string(course_id) - unique_student_identifier = request.POST.get('unique_student_identifier') + course_id = CourseKey.from_string(course_id) + unique_student_identifier = request.data.get("unique_student_identifier") - try: - user = get_student_from_identifier(unique_student_identifier) - mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_id) - except User.DoesNotExist: - # The student could have been invited to enroll without having - # registered. We'll also look at CourseEnrollmentAllowed - # records, so let the lack of a User slide. - pass - - enrollment_status = _('Enrollment status for {student}: unknown').format(student=unique_student_identifier) - - if user and mode: - if is_active: - enrollment_status = _('Enrollment status for {student}: active').format(student=user) - else: - enrollment_status = _('Enrollment status for {student}: inactive').format(student=user) - else: - email = user.email if user else unique_student_identifier - allowed = CourseEnrollmentAllowed.may_enroll_and_unenrolled(course_id) - if allowed and email in [cea.email for cea in allowed]: - enrollment_status = _('Enrollment status for {student}: pending').format(student=email) + serializer_data = UniqueStudentIdentifierSerializer(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) + + user = serializer_data.validated_data.get('unique_student_identifier') + if user: + mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_id) + + if user and mode: + if is_active: + enrollment_status = _('Enrollment status for {student}: active').format(student=user) + else: + enrollment_status = _('Enrollment status for {student}: inactive').format(student=user) else: - enrollment_status = _('Enrollment status for {student}: never enrolled').format(student=email) + email = user.email if user else unique_student_identifier + allowed = CourseEnrollmentAllowed.may_enroll_and_unenrolled(course_id) + if allowed and email in [cea.email for cea in allowed]: + enrollment_status = _('Enrollment status for {student}: pending').format(student=email) + else: + enrollment_status = _('Enrollment status for {student}: never enrolled').format(student=email) - response_payload = { - 'course_id': str(course_id), - 'error': error, - 'enrollment_status': enrollment_status - } + response_payload = { + 'course_id': str(course_id), + 'error': error, + 'enrollment_status': enrollment_status + } - return JsonResponse(response_payload) + return JsonResponse(response_payload) class StudentProgressUrlSerializer(serializers.Serializer): diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index a248b46ae531..92d5f46bc70e 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -32,7 +32,8 @@ path('get_issued_certificates/', api.get_issued_certificates, name='get_issued_certificates'), path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), - path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"), + path('get_student_enrollment_status', api.GetStudentEnrollmentStatus.as_view(), + name="get_student_enrollment_status"), path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'), path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'), path('rescore_problem', api.rescore_problem, name='rescore_problem'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 5d123ad66c81..59ac66ab838b 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -31,23 +31,14 @@ class Meta: fields = ['username', 'email', 'first_name', 'last_name'] -class AccessSerializer(serializers.Serializer): +class UniqueStudentIdentifierSerializer(serializers.Serializer): """ - Serializer for managing user access changes. - This serializer validates and processes the data required to modify - user access within a system. + Serializer for identifying unique_student. """ 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): """ @@ -61,6 +52,21 @@ def validate_unique_student_identifier(self, value): return user +class AccessSerializer(UniqueStudentIdentifierSerializer): + """ + Serializer for managing user access changes. + This serializer validates and processes the data required to modify + user access within a system. + """ + 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" + ) + + class ListInstructorTaskInputSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Serializer for handling the input data for the problem response report generation API. From f65403975a5a07a1ade5ed5ddd37f8d194c792f0 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Wed, 25 Sep 2024 10:32:25 -0400 Subject: [PATCH 48/67] fix: don't wrap HTML data with newlines when serializing for LC (#35532) When serializing to OLX, the Learning Core runtime wraps HTML content in CDATA to avoid having to escape every individual `<`, `>`, and `&`. The runtime also puts newlines around the content within the CDATA, So, given HTML content `...`, we get ``. The problem is that every time you serialize an HTML block to OLX, it adds another pair of newlines. These newlines aren't visible to the end users, but they do make it so that importing and exporting content never reached a stable, aka "canonical" form. It also makes unit testing difficult, because the value of `html_block.data` becomes a moving target. We do not believe these newlines are necessary, so we have removed them from the `CDATA` block, and added a unit test to ensure that HTML blocks having a canonical serialization. Closes: https://github.com/openedx/edx-platform/issues/35525 --- .../content_libraries/tests/test_runtime.py | 82 +++++++++++++++++++ .../content_staging/tests/test_clipboard.py | 2 +- .../lib/xblock_serializer/block_serializer.py | 2 +- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py index 89b8cdefd86b..f79808a7ec9a 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py @@ -3,6 +3,7 @@ """ import json from gettext import GNUTranslations +from django.test import TestCase from completion.test_utils import CompletionWaffleTestMixin from django.db import connections, transaction @@ -24,6 +25,7 @@ from openedx.core.djangoapps.dark_lang.models import DarkLangConfig from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms +from openedx.core.lib.xblock_serializer import api as serializer_api from common.djangoapps.student.tests.factories import UserFactory @@ -59,6 +61,86 @@ def setUp(self): ) +@skip_unless_cms +class ContentLibraryOlxTests(ContentLibraryContentTestMixin, TestCase): + """ + Basic test of the Learning-Core-based XBlock serialization-deserialization, using XBlocks in a content library. + """ + + def test_html_round_trip(self): + """ + Test that if we deserialize and serialize an HTMLBlock repeatedly, two things hold true: + + 1. Even if the OLX changes format, the inner content does not change format. + 2. The OLX settles into a stable state after 1 round trip. + + (We are particularly testing HTML, but it would be good to confirm that these principles hold true for + XBlocks in general.) + """ + usage_key = library_api.create_library_block(self.library.key, "html", "roundtrip").usage_key + + # The block's actual HTML has some extraneous spaces and newlines, as well as comment. + # We expect this to be preserved through the round-trips. + block_content = '''\ +
+
+

There is a space on either side of this sentence.

+

\tThere is a tab on either side of this sentence.\t

+

🙃There is an emoji on either side of this sentence.🙂

+

There is nothing on either side of this sentence.

+
+

\t ]]>

+ +
''' + + # The OLX containing the HTML also has some extraneous stuff, which do *not* expect to survive the round-trip. + olx_1 = f'''\ + + ''' + + # Here is what we expect the OLX to settle down to. Notable changes: + # * url_name is added. + # * some_fake_field is gone. + # * The OLX comment is gone. + # * A trailing newline is added at the end of the export. + # DEVS: If you are purposefully tweaking the formatting of the xblock serializer, then it's fine to + # update the value of this variable, as long as: + # 1. the {block_content} remains unchanged, and + # 2. the canonical_olx remains stable through the 2nd round trip. + canonical_olx = ( + f'\n' + ) + + # Save the block to LC, and re-load it. + library_api.set_library_block_olx(usage_key, olx_1) + library_api.publish_changes(self.library.key) + block_saved_1 = xblock_api.load_block(usage_key, self.staff_user) + + # Content should be preserved... + assert block_saved_1.data == block_content + + # ...but the serialized OLX will have changed to match the 'canonical' OLX. + olx_2 = serializer_api.serialize_xblock_to_olx(block_saved_1).olx_str + assert olx_2 == canonical_olx + + # Now, save that OLX back to LC, and re-load it again. + library_api.set_library_block_olx(usage_key, olx_2) + library_api.publish_changes(self.library.key) + block_saved_2 = xblock_api.load_block(usage_key, self.staff_user) + + # Again, content should be preserved... + assert block_saved_2.data == block_saved_1.data == block_content + + # ...and this time, the OLX should have settled too. + olx_3 = serializer_api.serialize_xblock_to_olx(block_saved_2).olx_str + assert olx_3 == olx_2 == canonical_olx + + class ContentLibraryRuntimeTests(ContentLibraryContentTestMixin): """ Basic tests of the Learning-Core-based XBlock runtime using XBlocks in a diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 00c4466b7d48..551f94e90e1a 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -159,7 +159,7 @@ def test_copy_html(self): Sample ]]> - """).lstrip() + """).replace("\n", "") + "\n" # No newlines, expect one trailing newline. # Now if we GET the clipboard again, the GET response should exactly equal the last POST response: assert client.get(CLIPBOARD_ENDPOINT).json() == response_data diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py index 966380f25061..f12bf5336af5 100644 --- a/openedx/core/lib/xblock_serializer/block_serializer.py +++ b/openedx/core/lib/xblock_serializer/block_serializer.py @@ -133,7 +133,7 @@ def _serialize_html_block(self, block) -> etree.Element: # Escape any CDATA special chars escaped_block_data = block.data.replace("]]>", "]]>") - olx_node.text = etree.CDATA("\n" + escaped_block_data + "\n") + olx_node.text = etree.CDATA(escaped_block_data) return olx_node From 465e0af23002dc982f9273c2fffa41902fd6d6b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:33:57 +0000 Subject: [PATCH 49/67] fix(deps): update dependency underscore to v1.13.0 [security] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87c75af69044..edcf2a776e8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "style-loader": "0.18.2", "svg-inline-loader": "0.8.2", "uglify-js": "2.7.0", - "underscore": "1.12.1", + "underscore": "1.13.0", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", @@ -24564,9 +24564,9 @@ } }, "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.0.tgz", + "integrity": "sha512-sCs4H3pCytsb5K7i072FAEC9YlSYFIbosvM0tAKAlpSSUgD7yC1iXSEGdl5XrDKQ1YUB+p/HDzYrSG2H2Vl36g==", "license": "MIT" }, "node_modules/underscore.string": { diff --git a/package.json b/package.json index 3bd526c3b481..66da2b612830 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "style-loader": "0.18.2", "svg-inline-loader": "0.8.2", "uglify-js": "2.7.0", - "underscore": "1.12.1", + "underscore": "1.13.0", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", From cf614cb9c28052b5fb31ab78e1a4eddb6227b3d3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:00:40 +0000 Subject: [PATCH 50/67] feat: Upgrade Python dependency edx-enterprise (#35538) Bump the version to drop references to edx-rest-api-client that don't exist in the latest version. Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` Co-authored-by: feanil <781561+feanil@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index fffc9ac163b7..5afbe5f94e40 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.13 +edx-enterprise==4.25.15 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0b246816f559..097d21524190 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.13 +edx-enterprise==4.25.15 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index e16f83b049f7..3fa131288294 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.13 +edx-enterprise==4.25.15 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 2f916b40c535..1c12d28955c6 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.13 +edx-enterprise==4.25.15 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 93a0313a1123..6e36d98f04d8 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.13 +edx-enterprise==4.25.15 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 3768efa4142f15d032b0b677502b871b5fd59a7c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:18:18 +0000 Subject: [PATCH 51/67] fix(deps): update dependency underscore to v1.13.1 [security] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index edcf2a776e8d..82a6611d3abb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "style-loader": "0.18.2", "svg-inline-loader": "0.8.2", "uglify-js": "2.7.0", - "underscore": "1.13.0", + "underscore": "1.13.1", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", @@ -24564,9 +24564,9 @@ } }, "node_modules/underscore": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.0.tgz", - "integrity": "sha512-sCs4H3pCytsb5K7i072FAEC9YlSYFIbosvM0tAKAlpSSUgD7yC1iXSEGdl5XrDKQ1YUB+p/HDzYrSG2H2Vl36g==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", "license": "MIT" }, "node_modules/underscore.string": { diff --git a/package.json b/package.json index 66da2b612830..1f48500e6e27 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "style-loader": "0.18.2", "svg-inline-loader": "0.8.2", "uglify-js": "2.7.0", - "underscore": "1.13.0", + "underscore": "1.13.1", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", From 5446877a864f648fd42b79d82542ef9ae1172470 Mon Sep 17 00:00:00 2001 From: Jillian Date: Thu, 26 Sep 2024 03:59:18 +0930 Subject: [PATCH 52/67] Soft delete collections (#35496) * refactor: use django signals to trigger LIBRARY_COLLECTION events * refactor: use collection usage_key as search document id This change standardises the search document "id" to be a meilisearch ID generated from the usage key, for all types of indexed objects. This is important for collections so we can locate the collection document in the search index solely from the data provided by the LIBRARY_COLLECTION_DELETED event (library_key + collection_key), even if the collection has been deleted from the database. * refactor: avoid fetching more data than we have to. * get_library_collection_usage_key and searchable_doc_tags_for_collection do not need a Collection object; the usage key can be created from the library_key and collection_key. * updated searchable_doc_for_collection to require the parts of the collection usage key + an optional collection. This allows us to identify the collection's search document from its usage key without requiring an existing Collection object (in case it's been deleted). Also removes the edge case for indexing Collections not associated with a ContentLibrary -- this won't ever really happen. * feat: remove soft- and hard-deleted collections from search index * feat: adds library_component_usage_key to content_libraries.api * refactor: send CONTENT_OBJECT_ASSOCIATON_CHANGED on django model signals so that added/removed collections are removed/re-added to component documents. Special case: When a collection is soft-deleted/restored, we detect this in the search index and update the collection's component documents directly, without a CONTENT_OBJECT_ASSOCIATON_CHANGED signal. * chore: bumps openedx-learning to 0.13.0 --- openedx/core/djangoapps/content/search/api.py | 126 +++++++++++--- .../djangoapps/content/search/documents.py | 109 +++++++----- .../djangoapps/content/search/handlers.py | 2 + .../core/djangoapps/content/search/tasks.py | 15 +- .../content/search/tests/test_api.py | 116 ++++++++++++- .../content/search/tests/test_documents.py | 37 +---- .../core/djangoapps/content_libraries/api.py | 70 ++------ .../content_libraries/signal_handlers.py | 157 +++++++++++++++++- .../content_libraries/tests/test_api.py | 44 ++++- .../tests/test_views_collections.py | 27 ++- .../content_libraries/views_collections.py | 30 +++- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 16 files changed, 556 insertions(+), 187 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 7fe964128e5a..b5ed1bde78e1 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -15,7 +15,7 @@ from django.core.cache import cache from django.core.paginator import Paginator from meilisearch import Client as MeilisearchClient -from meilisearch.errors import MeilisearchError +from meilisearch.errors import MeilisearchApiError, MeilisearchError from meilisearch.models.task import TaskInfo from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator @@ -34,6 +34,7 @@ searchable_doc_for_course_block, searchable_doc_for_collection, searchable_doc_for_library_block, + searchable_doc_for_usage_key, searchable_doc_collections, searchable_doc_tags, searchable_doc_tags_for_collection, @@ -402,8 +403,8 @@ def index_collection_batch(batch, num_done, library_key) -> int: docs = [] for collection in batch: try: - doc = searchable_doc_for_collection(collection) - doc.update(searchable_doc_tags_for_collection(library_key, collection)) + doc = searchable_doc_for_collection(library_key, collection.key, collection=collection) + doc.update(searchable_doc_tags_for_collection(library_key, collection.key)) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing collection {collection}: {err}") @@ -512,15 +513,28 @@ def delete_index_doc(usage_key: UsageKey) -> None: Args: usage_key (UsageKey): The usage key of the XBlock to be removed from the index """ - current_rebuild_index_name = _get_running_rebuild_index_name() + doc = searchable_doc_for_usage_key(usage_key) + _delete_index_doc(doc[Fields.id]) + + +def _delete_index_doc(doc_id) -> None: + """ + Helper function that deletes the document with the given ID from the search index + + If there is a rebuild in progress, the document will also be removed from the new index. + """ + if not doc_id: + return client = _get_meilisearch_client() + current_rebuild_index_name = _get_running_rebuild_index_name() tasks = [] if current_rebuild_index_name: - # If there is a rebuild in progress, the document will also be deleted from the new index. - tasks.append(client.index(current_rebuild_index_name).delete_document(meili_id_from_opaque_key(usage_key))) - tasks.append(client.index(STUDIO_INDEX_NAME).delete_document(meili_id_from_opaque_key(usage_key))) + # If there is a rebuild in progress, the document will also be removed from the new index. + tasks.append(client.index(current_rebuild_index_name).delete_document(doc_id)) + + tasks.append(client.index(STUDIO_INDEX_NAME).delete_document(doc_id)) _wait_for_meili_tasks(tasks) @@ -563,20 +577,94 @@ def upsert_library_block_index_doc(usage_key: UsageKey) -> None: _update_index_docs(docs) +def _get_document_from_index(document_id: str) -> dict: + """ + Returns the Document identified by the given ID, from the given index. + + Returns None if the document or index do not exist. + """ + client = _get_meilisearch_client() + document = None + index_name = STUDIO_INDEX_NAME + try: + index = client.get_index(index_name) + document = index.get_document(document_id) + except (MeilisearchError, MeilisearchApiError) as err: + # The index or document doesn't exist + log.warning(f"Unable to fetch document {document_id} from {index_name}: {err}") + + return document + + def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collection_key: str) -> None: """ - Creates or updates the document for the given Library Collection in the search index + Creates, updates, or deletes the document for the given Library Collection in the search index. + + If the Collection is not found or disabled (i.e. soft-deleted), then delete it from the search index. """ - content_library = lib_api.ContentLibrary.objects.get_by_key(library_key) - collection = authoring_api.get_collection( - learning_package_id=content_library.learning_package_id, - collection_key=collection_key, - ) - docs = [ - searchable_doc_for_collection(collection) - ] + doc = searchable_doc_for_collection(library_key, collection_key) + update_components = False - _update_index_docs(docs) + # Soft-deleted/disabled collections are removed from the index + # and their components updated. + if doc.get('_disabled'): + + _delete_index_doc(doc[Fields.id]) + + update_components = True + + # Hard-deleted collections are also deleted from the index, + # but their components are automatically updated as part of the deletion process, so we don't have to. + elif not doc.get(Fields.type): + + _delete_index_doc(doc[Fields.id]) + + # Otherwise, upsert the collection. + # Newly-added/restored collection get their components updated too. + else: + already_indexed = _get_document_from_index(doc[Fields.id]) + if not already_indexed: + update_components = True + + _update_index_docs([doc]) + + # Asynchronously update the collection's components "collections" field + if update_components: + from .tasks import update_library_components_collections as update_task + + update_task.delay(str(library_key), collection_key) + + +def update_library_components_collections( + library_key: LibraryLocatorV2, + collection_key: str, + batch_size: int = 1000, +) -> None: + """ + Updates the "collections" field for all components associated with a given Library Collection. + + Because there may be a lot of components, we send these updates to Meilisearch in batches. + """ + library = lib_api.get_library(library_key) + components = authoring_api.get_collection_components(library.learning_package.id, collection_key) + + paginator = Paginator(components, batch_size) + for page in paginator.page_range: + docs = [] + + for component in paginator.page(page).object_list: + usage_key = lib_api.library_component_usage_key( + library_key, + component, + ) + doc = searchable_doc_collections(usage_key) + docs.append(doc) + + log.info( + f"Updating document.collections for library {library_key} components" + f" page {page} / {paginator.num_pages}" + ) + _update_index_docs(docs) def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None: @@ -614,10 +702,8 @@ def upsert_collection_tags_index_docs(collection_usage_key: LibraryCollectionLoc """ Updates the tags data in documents for the given library collection """ - collection = lib_api.get_library_collection_from_usage_key(collection_usage_key) - doc = {Fields.id: collection.id} - doc.update(searchable_doc_tags_for_collection(collection_usage_key.library_key, collection)) + doc = searchable_doc_tags_for_collection(collection_usage_key.library_key, collection_usage_key.collection_id) _update_index_docs([doc]) diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index f9041468c296..eabeab9654ca 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -16,7 +16,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 +from openedx_learning.api.authoring_models import Collection log = logging.getLogger(__name__) @@ -112,6 +112,15 @@ def _meili_access_id_from_context_key(context_key: LearningContextKey) -> int: return access.id +def searchable_doc_for_usage_key(usage_key: UsageKey) -> dict: + """ + Generates a base document identified by its usage key. + """ + return { + Fields.id: meili_id_from_opaque_key(usage_key), + } + + def _fields_from_block(block) -> dict: """ Given an XBlock instance, call its index_dictionary() method to load any @@ -297,14 +306,14 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title block = xblock_api.load_block(xblock_metadata.usage_key, user=None) - doc = { - Fields.id: meili_id_from_opaque_key(xblock_metadata.usage_key), + doc = searchable_doc_for_usage_key(xblock_metadata.usage_key) + doc.update({ Fields.type: DocType.library_block, Fields.breadcrumbs: [], Fields.created: xblock_metadata.created.timestamp(), Fields.modified: xblock_metadata.modified.timestamp(), Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None, - } + }) doc.update(_fields_from_block(block)) @@ -319,9 +328,7 @@ def searchable_doc_tags(usage_key: UsageKey) -> dict: Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, with the tags data for the given content object. """ - doc = { - Fields.id: meili_id_from_opaque_key(usage_key), - } + doc = searchable_doc_for_usage_key(usage_key) doc.update(_tags_for_content_object(usage_key)) return doc @@ -332,9 +339,7 @@ def searchable_doc_collections(usage_key: UsageKey) -> dict: Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, with the collections data for the given content object. """ - doc = { - Fields.id: meili_id_from_opaque_key(usage_key), - } + doc = searchable_doc_for_usage_key(usage_key) doc.update(_collections_for_content_object(usage_key)) return doc @@ -342,21 +347,17 @@ def searchable_doc_collections(usage_key: UsageKey) -> dict: def searchable_doc_tags_for_collection( library_key: LibraryLocatorV2, - collection, + collection_key: str, ) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, with the tags data for the given library collection. """ - doc = { - Fields.id: collection.id, - } - collection_usage_key = lib_api.get_library_collection_usage_key( library_key, - collection.key, + collection_key, ) - + doc = searchable_doc_for_usage_key(collection_usage_key) doc.update(_tags_for_content_object(collection_usage_key)) return doc @@ -368,49 +369,65 @@ def searchable_doc_for_course_block(block) -> dict: like Meilisearch or Elasticsearch, so that the given course block can be found using faceted search. """ - doc = { - Fields.id: meili_id_from_opaque_key(block.usage_key), + doc = searchable_doc_for_usage_key(block.usage_key) + doc.update({ Fields.type: DocType.course_block, - } + }) doc.update(_fields_from_block(block)) return doc -def searchable_doc_for_collection(collection) -> dict: +def searchable_doc_for_collection( + library_key: LibraryLocatorV2, + collection_key: str, + *, + # Optionally provide the collection if we've already fetched one + collection: Collection | None = None, +) -> 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. + + If no collection is found for the given library_key + collection_key, the returned document will contain only basic + information derived from the collection usage key, and no Fields.type value will be included in the returned dict. """ - doc = { - Fields.id: collection.id, - Fields.block_id: collection.key, - 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, - Fields.num_children: collection.entities.count(), - } - # Just in case learning_package is not related to a library + collection_usage_key = lib_api.get_library_collection_usage_key( + library_key, + collection_key, + ) + + doc = searchable_doc_for_usage_key(collection_usage_key) + try: - context_key = collection.learning_package.contentlibrary.library_key - org = str(context_key.org) + collection = collection or lib_api.get_library_collection_from_usage_key(collection_usage_key) + except lib_api.ContentLibraryCollectionNotFound: + # Collection not found, so we can only return the base doc + pass + + if collection: + assert collection.key == collection_key + doc.update({ - Fields.context_key: str(context_key), - Fields.org: org, - Fields.usage_key: str(lib_api.get_library_collection_usage_key(context_key, collection.key)), + Fields.context_key: str(library_key), + Fields.org: str(library_key.org), + Fields.usage_key: str(collection_usage_key), + Fields.block_id: collection.key, + Fields.type: DocType.collection, + Fields.display_name: collection.title, + Fields.description: collection.description, + Fields.created: collection.created.timestamp(), + Fields.modified: collection.modified.timestamp(), + Fields.num_children: collection.entities.count(), + Fields.access_id: _meili_access_id_from_context_key(library_key), + Fields.breadcrumbs: [{"display_name": collection.learning_package.title}], }) - 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}] + + # Disabled collections should be removed from the search index, + # so we mark them as _disabled + if not collection.enabled: + doc['_disabled'] = True return doc diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index f50dead8474a..085387d336b1 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -23,6 +23,7 @@ LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, XBLOCK_CREATED, XBLOCK_DELETED, @@ -166,6 +167,7 @@ def content_library_updated_handler(**kwargs) -> None: @receiver(LIBRARY_COLLECTION_CREATED) +@receiver(LIBRARY_COLLECTION_DELETED) @receiver(LIBRARY_COLLECTION_UPDATED) @only_if_meilisearch_enabled def library_collection_updated_handler(**kwargs) -> None: diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index d9dad834db29..98390a12f3b3 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -90,10 +90,23 @@ def update_content_library_index_docs(library_key_str: str) -> None: @set_code_owner_attribute def update_library_collection_index_doc(library_key_str: str, collection_key: str) -> None: """ - Celery task to update the content index documents for a library collection + Celery task to update the content index document for a library collection """ library_key = LibraryLocatorV2.from_string(library_key_str) log.info("Updating content index documents for collection %s in library%s", collection_key, library_key) api.upsert_library_collection_index_doc(library_key, collection_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def update_library_components_collections(library_key_str: str, collection_key: str) -> None: + """ + Celery task to update the "collections" field for components in the given content library collection. + """ + library_key = LibraryLocatorV2.from_string(library_key_str) + + log.info("Updating document.collections for library %s collection %s components", library_key, collection_key) + + api.update_library_components_collections(library_key, collection_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 4aa41a156dab..2f43896c197e 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -187,7 +187,7 @@ def setUp(self): ) self.collection_usage_key = "lib-collection:org1:lib:MYCOL" self.collection_dict = { - "id": self.collection.id, + "id": "lib-collectionorg1libmycol-5b647617", "block_id": self.collection.key, "usage_key": self.collection_usage_key, "type": "collection", @@ -461,7 +461,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): # Build expected docs at each stage lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key) doc_collection1_created = { - "id": collection1.id, + "id": "lib-collectionorg1libcol1-283a79c9", "block_id": collection1.key, "usage_key": f"lib-collection:org1:lib:{collection1.key}", "type": "collection", @@ -476,7 +476,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_created = { - "id": collection2.id, + "id": "lib-collectionorg1libcol2-46823d4d", "block_id": collection2.key, "usage_key": f"lib-collection:org1:lib:{collection2.key}", "type": "collection", @@ -491,7 +491,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_updated = { - "id": collection2.id, + "id": "lib-collectionorg1libcol2-46823d4d", "block_id": collection2.key, "usage_key": f"lib-collection:org1:lib:{collection2.key}", "type": "collection", @@ -506,7 +506,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "breadcrumbs": [{"display_name": "Library"}], } doc_collection1_updated = { - "id": collection1.id, + "id": "lib-collectionorg1libcol1-283a79c9", "block_id": collection1.key, "usage_key": f"lib-collection:org1:lib:{collection1.key}", "type": "collection", @@ -593,14 +593,14 @@ def test_index_tags_in_collections(self, mock_meilisearch): # Build expected docs with tags at each stage doc_collection_with_tags1 = { - "id": self.collection.id, + "id": "lib-collectionorg1libmycol-5b647617", "tags": { 'taxonomy': ['A'], 'level0': ['A > one', 'A > two'] } } doc_collection_with_tags2 = { - "id": self.collection.id, + "id": "lib-collectionorg1libmycol-5b647617", "tags": { 'taxonomy': ['A', 'B'], 'level0': ['A > one', 'A > two', 'B > four', 'B > three'] @@ -615,3 +615,105 @@ def test_index_tags_in_collections(self, mock_meilisearch): ], any_order=True, ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_delete_collection(self, mock_meilisearch): + """ + Test soft-deleting, restoring, and hard-deleting a collection. + """ + # Add a component to the collection + updated_date = datetime(2023, 6, 7, 8, 9, 10, tzinfo=timezone.utc) + with freeze_time(updated_date): + library_api.update_library_collection_components( + self.library.key, + collection_key=self.collection.key, + usage_keys=[ + self.problem1.usage_key, + ], + ) + + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["num_children"] = 1 + doc_collection["modified"] = updated_date.timestamp() + doc_problem_with_collection = { + "id": self.doc_problem1["id"], + "collections": { + "display_name": [self.collection.title], + "key": [self.collection.key], + }, + } + + # Should update the collection and its component + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection]), + call([doc_problem_with_collection]), + ], + any_order=True, + ) + mock_meilisearch.return_value.index.reset_mock() + + # Soft-delete the collection + authoring_api.delete_collection( + self.collection.learning_package_id, + self.collection.key, + ) + + doc_problem_without_collection = { + "id": self.doc_problem1["id"], + "collections": {}, + } + + # Should delete the collection document + mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( + self.collection_dict["id"], + ) + # ...and update the component's "collections" field + mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([ + doc_problem_without_collection, + ]) + mock_meilisearch.return_value.index.reset_mock() + + # We need to mock get_document here so that when we restore the collection below, meilisearch knows the + # collection is being re-added, so it will update its components too. + mock_meilisearch.return_value.get_index.return_value.get_document.return_value = None + + # Restore the collection + restored_date = datetime(2023, 8, 9, 10, 11, 12, tzinfo=timezone.utc) + with freeze_time(restored_date): + authoring_api.restore_collection( + self.collection.learning_package_id, + self.collection.key, + ) + + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["num_children"] = 1 + doc_collection["modified"] = restored_date.timestamp() + + # Should update the collection and its component's "collections" field + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection]), + call([doc_problem_with_collection]), + ], + any_order=True, + ) + mock_meilisearch.return_value.index.reset_mock() + + # Hard-delete the collection + authoring_api.delete_collection( + self.collection.learning_package_id, + self.collection.key, + hard_delete=True, + ) + + # Should delete the collection document + mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( + self.collection_dict["id"], + ) + # ...and cascade delete updates the "collections" field for the associated components + mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([ + doc_problem_without_collection, + ]) diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 9d51bd127bb4..755ab4d19ad3 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -5,7 +5,6 @@ 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.djangoapps.content_libraries import api as library_api @@ -299,11 +298,11 @@ def test_html_library_block(self): } def test_collection_with_library(self): - doc = searchable_doc_for_collection(self.collection) - doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection)) + doc = searchable_doc_for_collection(self.library.key, self.collection.key) + doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key)) assert doc == { - "id": self.collection.id, + "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", "block_id": self.collection.key, "usage_key": self.collection_usage_key, "type": "collection", @@ -321,33 +320,3 @@ def test_collection_with_library(self): 'level0': ['Difficulty > Normal'] } } - - 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, - key="MYCOL", - title="my_collection", - created_by=None, - description="my collection description" - ) - doc = searchable_doc_for_collection(collection) - assert doc == { - "id": collection.id, - "block_id": collection.key, - "type": "collection", - "display_name": "my_collection", - "description": "my collection description", - "num_children": 0, - "context_key": learning_package.key, - "access_id": self.toy_course_access_id, - "breadcrumbs": [{"display_name": "some learning_package"}], - "created": created_date.timestamp(), - "modified": created_date.timestamp(), - } diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 3dc33aec9616..a9601a4e70a7 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -79,20 +79,15 @@ from opaque_keys import InvalidKeyError from openedx_events.content_authoring.data import ( ContentLibraryData, - ContentObjectChangedData, LibraryBlockData, - LibraryCollectionData, ) from openedx_events.content_authoring.signals import ( - CONTENT_OBJECT_ASSOCIATIONS_CHANGED, CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, - LIBRARY_COLLECTION_CREATED, - LIBRARY_COLLECTION_UPDATED, ) from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage, PublishableEntity @@ -242,10 +237,9 @@ def from_component(cls, library_key, component): last_draft_created_by = draft.publishable_entity_version.created_by if draft else None return cls( - usage_key=LibraryUsageLocatorV2( + usage_key=library_component_usage_key( library_key, - component.component_type.name, - component.local_key, + component, ), display_name=component.versioning.draft.title, created=component.created, @@ -787,6 +781,20 @@ def set_library_block_olx(usage_key, new_olx_str): ) +def library_component_usage_key( + library_key: LibraryLocatorV2, + component: Component, +) -> LibraryUsageLocatorV2: + """ + Returns a LibraryUsageLocatorV2 for the given library + component. + """ + return LibraryUsageLocatorV2( # type: ignore[abstract] + library_key, + block_type=component.component_type.name, + usage_id=component.local_key, + ) + + def validate_can_add_block_to_library( library_key: LibraryLocatorV2, block_type: str, @@ -1103,8 +1111,7 @@ def create_library_collection( content_library: ContentLibrary | None = None, ) -> Collection: """ - Creates a Collection in the given ContentLibrary, - and emits a LIBRARY_COLLECTION_CREATED event. + Creates a Collection in the given ContentLibrary. If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. """ @@ -1125,14 +1132,6 @@ def create_library_collection( except IntegrityError as err: raise LibraryCollectionAlreadyExists from err - # Emit event for library collection created - LIBRARY_COLLECTION_CREATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - ) - ) - return collection @@ -1146,8 +1145,7 @@ def update_library_collection( content_library: ContentLibrary | None = None, ) -> Collection: """ - Creates a Collection in the given ContentLibrary, - and emits a LIBRARY_COLLECTION_CREATED event. + Updates a Collection in the given ContentLibrary. """ if not content_library: content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] @@ -1165,14 +1163,6 @@ def update_library_collection( except Collection.DoesNotExist as exc: raise ContentLibraryCollectionNotFound from exc - # Emit event for library collection updated - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - ) - ) - return collection @@ -1243,40 +1233,16 @@ def update_library_collection_components( created_by=created_by, ) - # Emit event for library collection updated - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - ) - ) - - # Emit a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for each of the objects added/removed - for usage_key in usage_keys: - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( - content_object=ContentObjectChangedData( - object_id=str(usage_key), - changes=["collections"], - ), - ) - return collection def get_library_collection_usage_key( library_key: LibraryLocatorV2, collection_key: str, - # As an optimization, callers may pass in a pre-fetched ContentLibrary instance - content_library: ContentLibrary | None = None, ) -> LibraryCollectionLocator: """ Returns the LibraryCollectionLocator associated to a collection """ - if not content_library: - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - assert content_library - assert content_library.learning_package_id - assert content_library.library_key == library_key return LibraryCollectionLocator(library_key, collection_key) diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py index 768b49d55f22..fedee045a9f6 100644 --- a/openedx/core/djangoapps/content_libraries/signal_handlers.py +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -5,13 +5,28 @@ import logging from django.conf import settings +from django.db.models.signals import post_save, post_delete, m2m_changed from django.dispatch import receiver +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_events.content_authoring.data import ( + ContentObjectChangedData, + LibraryCollectionData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, +) +from openedx_learning.api.authoring import get_collection_components, get_component, get_components +from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, Component + from lms.djangoapps.grades.api import signals as grades_signals -from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order -from opaque_keys.edx.locator import LibraryUsageLocatorV2 # lint-amnesty, pylint: disable=wrong-import-order -from .models import LtiGradedResource +from .api import library_component_usage_key +from .models import ContentLibrary, LtiGradedResource log = logging.getLogger(__name__) @@ -55,3 +70,139 @@ def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument resource.update_score(weighted_earned, weighted_possible, modified) log.info("LTI 1.3: Score Signal: Grade upgraded: resource; %s", resource) + + +@receiver(post_save, sender=Collection, dispatch_uid="library_collection_saved") +def library_collection_saved(sender, instance, created, **kwargs): + """ + Raises LIBRARY_COLLECTION_CREATED if the Collection is new, + or LIBRARY_COLLECTION_UPDATED if updated an existing Collection. + """ + try: + library = ContentLibrary.objects.get(learning_package_id=instance.learning_package_id) + except ContentLibrary.DoesNotExist: + log.error("{instance} is not associated with a content library.") + return + + if created: + LIBRARY_COLLECTION_CREATED.send_event( + library_collection=LibraryCollectionData( + library_key=library.library_key, + collection_key=instance.key, + ) + ) + else: + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library.library_key, + collection_key=instance.key, + ) + ) + + +@receiver(post_delete, sender=Collection, dispatch_uid="library_collection_deleted") +def library_collection_deleted(sender, instance, **kwargs): + """ + Raises LIBRARY_COLLECTION_DELETED for the deleted Collection. + """ + try: + library = ContentLibrary.objects.get(learning_package_id=instance.learning_package_id) + except ContentLibrary.DoesNotExist: + log.error("{instance} is not associated with a content library.") + return + + LIBRARY_COLLECTION_DELETED.send_event( + library_collection=LibraryCollectionData( + library_key=library.library_key, + collection_key=instance.key, + ) + ) + + +def _library_collection_component_changed( + component: Component, + library_key: LibraryLocatorV2 | None = None, +) -> None: + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the component. + """ + if not library_key: + try: + library = ContentLibrary.objects.get( + learning_package_id=component.learning_package_id, + ) + except ContentLibrary.DoesNotExist: + log.error("{component} is not associated with a content library.") + return + + library_key = library.library_key + + assert library_key + + usage_key = library_component_usage_key( + library_key, + component, + ) + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(usage_key), + changes=["collections"], + ), + ) + + +@receiver(post_save, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_saved") +def library_collection_entity_saved(sender, instance, created, **kwargs): + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added to a collection. + """ + if created: + # Component.pk matches its entity.pk + component = get_component(instance.entity_id) + _library_collection_component_changed(component) + + +@receiver(post_delete, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_deleted") +def library_collection_entity_deleted(sender, instance, **kwargs): + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components removed from a collection. + """ + # Component.pk matches its entity.pk + component = get_component(instance.entity_id) + _library_collection_component_changed(component) + + +@receiver(m2m_changed, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entities_changed") +def library_collection_entities_changed(sender, instance, action, pk_set, **kwargs): + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added/removed/cleared from a collection. + """ + if not isinstance(instance, Collection): + return + + if action not in ["post_add", "post_remove", "post_clear"]: + return + + try: + library = ContentLibrary.objects.get( + learning_package_id=instance.learning_package_id, + ) + except ContentLibrary.DoesNotExist: + log.error("{instance} is not associated with a content library.") + return + + if pk_set: + components = get_collection_components( + instance.learning_package_id, + instance.key, + ).filter(pk__in=pk_set) + else: + # When action=="post_clear", pk_set==None + # Since the collection instance now has an empty entities set, + # we don't know which ones were removed, so we need to update associations for all library components. + components = get_components( + instance.learning_package_id, + ) + + for component in components.all(): + _library_collection_component_changed(component, library.library_key) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index b02e71b002a3..8041c508dc31 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -20,9 +20,11 @@ from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, ) from openedx_events.tests.utils import OpenEdxEventsTestMixin +from openedx_learning.api import authoring as authoring_api from .. import api from ..models import ContentLibrary @@ -264,6 +266,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe ENABLED_OPENEDX_EVENTS = [ CONTENT_OBJECT_ASSOCIATIONS_CHANGED.event_type, LIBRARY_COLLECTION_CREATED.event_type, + LIBRARY_COLLECTION_DELETED.event_type, LIBRARY_COLLECTION_UPDATED.event_type, ] @@ -386,6 +389,29 @@ def test_update_library_collection_wrong_library(self): self.col2.key, ) + def test_delete_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_DELETED.connect(event_receiver) + + authoring_api.delete_collection( + self.lib1.learning_package_id, + self.col1.key, + hard_delete=True, + ) + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_DELETED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + def test_update_library_collection_components(self): assert not list(self.col1.entities.all()) @@ -429,11 +455,11 @@ def test_update_library_collection_components_event(self): assert event_receiver.call_count == 3 self.assertDictContainsSubset( { - "signal": LIBRARY_COLLECTION_UPDATED, + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, - "library_collection": LibraryCollectionData( - self.lib1.library_key, - collection_key="COL1", + "content_object": ContentObjectChangedData( + object_id=self.lib1_problem_block["id"], + changes=["collections"], ), }, event_receiver.call_args_list[0].kwargs, @@ -443,7 +469,7 @@ def test_update_library_collection_components_event(self): "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, "content_object": ContentObjectChangedData( - object_id=self.lib1_problem_block["id"], + object_id=self.lib1_html_block["id"], changes=["collections"], ), }, @@ -451,11 +477,11 @@ def test_update_library_collection_components_event(self): ) self.assertDictContainsSubset( { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "signal": LIBRARY_COLLECTION_UPDATED, "sender": None, - "content_object": ContentObjectChangedData( - object_id=self.lib1_html_block["id"], - changes=["collections"], + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", ), }, event_receiver.call_args_list[2].kwargs, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py index bc600759b5b3..43c1627c2c76 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -17,6 +17,7 @@ URL_PREFIX = '/api/libraries/v2/{lib_key}/' URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' +URL_LIB_COLLECTION_RESTORE = URL_LIB_COLLECTIONS + '{collection_key}/restore/' URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'components/' @@ -330,15 +331,33 @@ def test_update_invalid_library_collection(self): def test_delete_library_collection(self): """ - Test deleting a Content Library Collection - - Note: Currently not implemented and should return a 405 + Test soft-deleting and restoring a Content Library Collection """ resp = self.client.delete( URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) ) + assert resp.status_code == 204 - assert resp.status_code == 405 + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + assert resp.status_code == 404 + + resp = self.client.post( + URL_LIB_COLLECTION_RESTORE.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + assert resp.status_code == 204 + + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + # Check that correct Content Library Collection data retrieved + expected_collection = { + "title": "Collection 3", + "description": "Description for Collection 3", + } + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_collection) def test_get_components(self): """ diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/views_collections.py index 2f40a1788628..b6c1c999ba94 100644 --- a/openedx/core/djangoapps/content_libraries/views_collections.py +++ b/openedx/core/djangoapps/content_libraries/views_collections.py @@ -11,7 +11,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED +from rest_framework.status import HTTP_204_NO_CONTENT from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api @@ -163,13 +163,31 @@ def partial_update(self, request, *args, **kwargs) -> Response: @convert_exceptions def destroy(self, request, *args, **kwargs) -> Response: """ - Deletes a Collection that belongs to a Content Library - - Note: (currently not allowed) + Soft-deletes a Collection that belongs to a Content Library """ - # TODO: Implement the deletion logic and emit event signal + collection = super().get_object() + assert collection.learning_package_id + authoring_api.delete_collection( + collection.learning_package_id, + collection.key, + hard_delete=False, + ) + return Response(None, status=HTTP_204_NO_CONTENT) - return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) + @convert_exceptions + @action(detail=True, methods=['post'], url_path='restore', url_name='collection-restore') + def restore(self, request, *args, **kwargs) -> Response: + """ + Restores a soft-deleted Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + assert content_library.learning_package_id + collection_key = kwargs["key"] + authoring_api.restore_collection( + content_library.learning_package_id, + collection_key, + ) + return Response(None, status=HTTP_204_NO_CONTENT) @convert_exceptions @action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update') diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 5afbe5f94e40..43f000a1e8ba 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -93,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.11.5 +openedx-learning==0.13.0 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 097d21524190..b725f8e0c2a4 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -824,7 +824,7 @@ openedx-filters==1.9.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.11.5 +openedx-learning==0.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 3fa131288294..058214c647b7 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1373,7 +1373,7 @@ openedx-filters==1.9.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.5 +openedx-learning==0.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 1c12d28955c6..c20c28c2e443 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -983,7 +983,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.5 +openedx-learning==0.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 6e36d98f04d8..ab0d190b5d8e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1034,7 +1034,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.5 +openedx-learning==0.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From d4dbc354bc8aeb62fe229c9c14236ffd8b4f655e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 25 Sep 2024 16:32:35 -0300 Subject: [PATCH 53/67] feat: return modified field on get component endpoint (#35508) --- .../content_libraries/serializers.py | 1 + .../tests/test_content_libraries.py | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index e9e04646ace4..51ba55cd6b48 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -154,6 +154,7 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer): last_draft_created_by = serializers.CharField(read_only=True) has_unpublished_changes = serializers.BooleanField(read_only=True) created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + modified = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) # When creating a new XBlock in a library, the slug becomes the ID part of # the definition key and usage key: diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 677178bb3b31..d995a2c79683 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1,18 +1,15 @@ """ Tests for Learning-Core-based Content Libraries """ -from unittest.mock import Mock, patch +from datetime import datetime, timezone from unittest import skip +from unittest.mock import Mock, patch +from uuid import uuid4 import ddt -from datetime import datetime, timezone -from uuid import uuid4 from django.contrib.auth.models import Group from django.test.client import Client from freezegun import freeze_time -from organizations.models import Organization -from rest_framework.test import APITestCase - from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData from openedx_events.content_authoring.signals import ( @@ -21,20 +18,23 @@ CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, - LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_UPDATED ) from openedx_events.tests.utils import OpenEdxEventsTestMixin +from organizations.models import Organization +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content_libraries.constants import CC_4_BY, COMPLEX, PROBLEM, VIDEO from openedx.core.djangoapps.content_libraries.tests.base import ( - ContentLibrariesRestApiTest, + URL_BLOCK_GET_HANDLER_URL, URL_BLOCK_METADATA_URL, URL_BLOCK_RENDER_VIEW, - URL_BLOCK_GET_HANDLER_URL, URL_BLOCK_XBLOCK_HANDLER, + ContentLibrariesRestApiTest ) -from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM, CC_4_BY from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangolib.testing.utils import skip_unless_cms -from common.djangoapps.student.tests.factories import UserFactory @skip_unless_cms @@ -1049,6 +1049,9 @@ def test_library_paste_clipboard(self): self.assertDictContainsEntries(self._get_library_block(paste_data["id"]), { **block_data, "last_draft_created_by": None, + "last_draft_created": paste_data["last_draft_created"], + "created": paste_data["created"], + "modified": paste_data["modified"], "id": f"lb:CL-TEST:test_lib_paste_clipboard:problem:{pasted_block_id}", }) From ae0c295ead308820964f600cf49fe975907af6f7 Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Wed, 25 Sep 2024 16:09:10 -0400 Subject: [PATCH 54/67] feat: pluggable url for idv location (#35494) * Adds an extension point when generating the url for id verification --- lms/djangoapps/verify_student/services.py | 6 +++- .../verify_student/tests/test_services.py | 33 ++++++++++++++++++- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 6 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index f1c5543e8536..1a2d145e892a 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.utils.timezone import now from django.utils.translation import gettext as _ +from openedx_filters.learning.filters import IDVPageURLRequested from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import User @@ -244,7 +245,10 @@ def get_verify_location(cls, course_id=None): location = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' - return location + + # .. filter_implemented_name: IDVPageURLRequested + # .. filter_type: org.openedx.learning.idv.page.url.requested.v1 + return IDVPageURLRequested.run_filter(location) @classmethod def get_verification_details_by_id(cls, attempt_id): diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 5351e3ede699..d57993d368af 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -9,10 +9,11 @@ import ddt from django.conf import settings -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils.timezone import now from django.utils.translation import gettext as _ from freezegun import freeze_time +from openedx_filters import PipelineStep from pytz import utc from common.djangoapps.student.tests.factories import UserFactory @@ -33,6 +34,16 @@ } +class TestIdvPageUrlRequestedPipelineStep(PipelineStep): + """ Utility function to test a configured pipeline step """ + TEST_URL = 'example.com/verify' + + def run_filter(self, url): # pylint: disable=arguments-differ + return { + "url": self.TEST_URL + } + + @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @ddt.ddt class TestIDVerificationService(ModuleStoreTestCase): @@ -167,6 +178,26 @@ def test_get_verify_location_from_string(self): expected_path = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' assert path == (expected_path + '?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course') + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.idv.page.url.requested.v1": { + "pipeline": [ + "lms.djangoapps.verify_student.tests.test_services.TestIdvPageUrlRequestedPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_get_verify_location_with_filter_step(self): + """ + Test IDV flow location can be customized with an openedx filter + """ + url = IDVerificationService.get_verify_location() + assert url == TestIdvPageUrlRequestedPipelineStep.TEST_URL + + url = IDVerificationService.get_verify_location('course-v1:edX+DemoX+Demo_Course') + assert url == TestIdvPageUrlRequestedPipelineStep.TEST_URL + def test_get_expiration_datetime(self): """ Test that the latest expiration datetime is returned if there are multiple records diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b725f8e0c2a4..d58ef131b3fd 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -819,7 +819,7 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 058214c647b7..f7bf535c7e52 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1367,7 +1367,7 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.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 c20c28c2e443..a038ab52471b 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -978,7 +978,7 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index ab0d190b5d8e..022feca9fcb3 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1029,7 +1029,7 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock From b708f90ee00e0a1154bc57104e4c67cf97caefa1 Mon Sep 17 00:00:00 2001 From: katrinan029 Date: Wed, 25 Sep 2024 20:57:21 +0000 Subject: [PATCH 55/67] chore: version bump --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 43f000a1e8ba..3b73c62955f6 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.15 +edx-enterprise==4.25.16 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index d58ef131b3fd..791ee21c359c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.15 +edx-enterprise==4.25.16 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index f7bf535c7e52..5a8bb199cc62 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.15 +edx-enterprise==4.25.16 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index a038ab52471b..4beb084733d6 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.15 +edx-enterprise==4.25.16 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 022feca9fcb3..04bc237de44d 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.15 +edx-enterprise==4.25.16 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 45d328f9fd75b0d8a761de419df1e25dd32a435a Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Thu, 26 Sep 2024 13:15:28 +0000 Subject: [PATCH 56/67] fix: Delete flaky test `test_get_user_group_id_for_partition` (#35545) This test failed on 2024-08-06 and 2024-09-24 but passed on re-run. Deleted according to flaky test process: https://openedx.atlassian.net/wiki/spaces/AC/pages/4306337795/Flaky+Test+Process Flaky test ticket: https://2u-internal.atlassian.net/browse/CR-7071 --- xmodule/partitions/tests/test_partitions.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/xmodule/partitions/tests/test_partitions.py b/xmodule/partitions/tests/test_partitions.py index fde6afd3141b..41f26b14db52 100644 --- a/xmodule/partitions/tests/test_partitions.py +++ b/xmodule/partitions/tests/test_partitions.py @@ -462,21 +462,6 @@ class TestPartitionService(PartitionServiceBaseClass): Test getting a user's group out of a partition """ - def test_get_user_group_id_for_partition(self): - # assign the first group to be returned - user_partition_id = self.user_partition.id - groups = self.user_partition.groups - self.user_partition.scheme.current_group = groups[0] - - # get a group assigned to the user - group1_id = self.partition_service.get_user_group_id_for_partition(self.user, user_partition_id) - assert group1_id == groups[0].id - - # switch to the second group and verify that it is returned for the user - self.user_partition.scheme.current_group = groups[1] - group2_id = self.partition_service.get_user_group_id_for_partition(self.user, user_partition_id) - assert group2_id == groups[1].id - def test_caching(self): username = "psvc_cache_user" user_partition_id = self.user_partition.id From f5e3635504c6390f764a503a6ee5e3858e1613d9 Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Thu, 26 Sep 2024 10:13:02 -0400 Subject: [PATCH 57/67] chore: Upgrade Python requirements --- requirements/edx-sandbox/base.txt | 18 +- requirements/edx/base.txt | 140 +++++++------- requirements/edx/coverage.txt | 2 +- requirements/edx/development.txt | 175 +++++++++--------- requirements/edx/doc.txt | 139 +++++++------- requirements/edx/paver.txt | 12 +- requirements/edx/semgrep.txt | 8 +- requirements/edx/testing.txt | 161 ++++++++-------- requirements/pip-tools.txt | 2 +- requirements/pip.txt | 2 +- .../structures_pruning/requirements/base.txt | 6 +- .../requirements/testing.txt | 8 +- scripts/user_retirement/requirements/base.txt | 47 +++-- .../user_retirement/requirements/testing.txt | 53 +++--- scripts/xblock/requirements.txt | 6 +- 15 files changed, 384 insertions(+), 395 deletions(-) diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index 4a7b0c0a7d35..1d94a4649a5f 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -4,7 +4,7 @@ # # make upgrade # -cffi==1.17.0 +cffi==1.17.1 # via cryptography chem==1.3.0 # via -r requirements/edx-sandbox/base.in @@ -14,17 +14,17 @@ click==8.1.6 # nltk codejail-includes==1.0.0 # via -r requirements/edx-sandbox/base.in -contourpy==1.2.1 +contourpy==1.3.0 # via matplotlib -cryptography==43.0.0 +cryptography==43.0.1 # via -r requirements/edx-sandbox/base.in cycler==0.12.1 # via matplotlib -fonttools==4.53.1 +fonttools==4.54.1 # via matplotlib joblib==1.4.2 # via nltk -kiwisolver==1.4.5 +kiwisolver==1.4.7 # via matplotlib lxml==4.9.4 # via @@ -61,7 +61,7 @@ pillow==10.4.0 # via matplotlib pycparser==2.22 # via cffi -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx-sandbox/base.in # chem @@ -71,9 +71,9 @@ python-dateutil==2.9.0.post0 # via matplotlib random2==1.0.2 # via -r requirements/edx-sandbox/base.in -regex==2024.7.24 +regex==2024.9.11 # via nltk -scipy==1.14.0 +scipy==1.14.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.2 +sympy==1.13.3 # via # -r requirements/edx-sandbox/base.in # openedx-calc diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 791ee21c359c..5b98763a2e0f 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -10,7 +10,7 @@ acid-xblock==0.3.1 # via -r requirements/edx/kernel.in aiohappyeyeballs==2.4.0 # via aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # geoip2 # openai @@ -58,7 +58,7 @@ bcrypt==4.2.0 # via paramiko beautifulsoup4==4.12.3 # via pynliner -billiard==4.2.0 +billiard==4.2.1 # via celery bleach[css]==6.1.0 # via @@ -70,13 +70,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/kernel.in # boto3 @@ -99,14 +99,14 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/paver.txt # elasticsearch # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # cryptography # pynacl @@ -146,7 +146,7 @@ codejail-includes==1.0.0 # via -r requirements/edx/kernel.in crowdsourcehinter-xblock==0.7 # via -r requirements/edx/bundled.in -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/kernel.in # django-fernet-fields-v2 @@ -168,7 +168,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -285,7 +285,7 @@ django-js-asset==2.2.0 # via django-mptt django-method-override==1.0.4 # via -r requirements/edx/kernel.in -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/kernel.in # django-user-tasks @@ -316,7 +316,7 @@ django-oauth-toolkit==1.7.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via edx-enterprise django-pipeline==3.1.0 # via -r requirements/edx/kernel.in @@ -328,7 +328,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via -r requirements/edx/bundled.in django-simple-history==3.4.0 # via @@ -391,7 +391,7 @@ dnspython==2.6.1 # via # -r requirements/edx/paver.txt # pymongo -done-xblock==2.3.0 +done-xblock==2.4.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions @@ -403,11 +403,11 @@ drf-yasg==1.21.7 # edx-api-doc-tools edx-ace==1.11.2 # via -r requirements/edx/kernel.in -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/kernel.in # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via -r requirements/edx/kernel.in edx-braze-client==0.2.5 # via @@ -429,7 +429,7 @@ edx-celeryutils==1.3.0 # super-csv edx-codejail==3.4.1 # via -r requirements/edx/kernel.in -edx-completion==4.6.7 +edx-completion==4.7.1 # via -r requirements/edx/kernel.in edx-django-release-util==1.4.0 # via @@ -455,7 +455,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -482,7 +482,7 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/kernel.in -edx-name-affirmation==2.4.1 +edx-name-affirmation==2.4.2 # via -r requirements/edx/kernel.in edx-opaque-keys[django]==2.11.0 # via @@ -506,9 +506,9 @@ edx-proctoring==4.18.1 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -517,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.7 +edx-submissions==3.8.0 # via # -r requirements/edx/kernel.in # ora2 @@ -541,7 +541,7 @@ edx-when==2.5.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via -r requirements/edx/kernel.in elasticsearch==7.13.4 # via @@ -558,9 +558,9 @@ event-tracking==3.0.0 # edx-completion # edx-proctoring # edx-search -fastavro==1.9.5 +fastavro==1.9.7 # via openedx-events -filelock==3.15.4 +filelock==3.16.1 # via snowflake-connector-python firebase-admin==6.5.0 # via edx-ace @@ -584,16 +584,16 @@ geoip2==4.8.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # firebase-admin # google-api-python-client # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # google-api-core # google-api-python-client @@ -607,25 +607,25 @@ google-cloud-core==2.4.1 # via # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via firebase-admin google-cloud-storage==2.18.2 # via firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # google-cloud-storage # google-resumable-media google-resumable-media==2.7.2 # via google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # google-api-core # grpcio-status -grpcio==1.65.5 +grpcio==1.66.1 # via # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via google-api-core gunicorn==23.0.0 # via -r requirements/edx/kernel.in @@ -641,14 +641,14 @@ httplib2==0.22.0 # google-auth-httplib2 icalendar==5.0.13 # via -r requirements/edx/kernel.in -idna==3.7 +idna==3.10 # via # -r requirements/edx/paver.txt # optimizely-sdk # requests # snowflake-connector-python # yarl -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via -r requirements/edx/kernel.in inflection==0.5.1 # via @@ -668,7 +668,7 @@ jmespath==1.0.1 # botocore joblib==1.4.2 # via nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via edx-enterprise jsonfield==3.1.0 # via @@ -689,7 +689,7 @@ jwcrypto==1.5.6 # via # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via celery laboratory==1.0.2 # via -r requirements/edx/kernel.in @@ -747,23 +747,23 @@ markupsafe==2.1.5 # xblock maxminddb==2.6.2 # via geoip2 -meilisearch==0.31.4 +meilisearch==0.31.5 # via -r requirements/edx/kernel.in mock==5.1.0 # via -r requirements/edx/paver.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via -r requirements/edx/kernel.in monotonic==1.6 # via # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via cssutils mpmath==1.3.0 # via sympy -msgpack==1.0.8 +msgpack==1.1.0 # via cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # aiohttp # yarl @@ -799,11 +799,11 @@ openai==0.28.1 # via # -c requirements/edx/../constraints.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via -r requirements/edx/kernel.in openedx-calc==3.1.0 # via -r requirements/edx/kernel.in -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # lti-consumer-xblock # xblock @@ -811,7 +811,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -844,7 +844,7 @@ packaging==24.1 # snowflake-connector-python pansi==2020.7.3 # via py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via edx-enterprise path==16.11.0 # via @@ -860,7 +860,7 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/paver.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/paver.txt # stevedore @@ -874,17 +874,17 @@ pillow==10.4.0 # edx-enterprise # edx-organizations # edxval -platformdirs==4.2.2 +platformdirs==4.3.6 # via snowflake-connector-python polib==1.2.0 # via edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via click-repl proto-plus==1.24.0 # via # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # google-api-core # google-cloud-firestore @@ -899,12 +899,12 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -pyasn1==0.6.0 +pyasn1==0.6.1 # via # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via google-auth pycountry==24.6.1 # via -r requirements/edx/kernel.in @@ -916,9 +916,9 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via camel-converter -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via pydantic pygments==2.18.0 # via @@ -966,7 +966,7 @@ pyopenssl==24.2.1 # via # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # chem # httplib2 @@ -1004,10 +1004,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/kernel.in - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1037,7 +1036,7 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/kernel.in -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via -r requirements/edx/bundled.in redis==5.0.8 # via @@ -1047,7 +1046,7 @@ referencing==0.35.1 # via # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via nltk requests==2.32.3 # via @@ -1086,7 +1085,7 @@ rpds-py==0.20.0 # referencing rsa==4.9 # via google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -1096,7 +1095,7 @@ s3transfer==0.10.2 # via boto3 sailthru-client==2.2.3 # via edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # chem # openedx-calc @@ -1144,8 +1143,7 @@ slumber==0.7.1 # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise - # edx-rest-api-client -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via edx-enterprise social-auth-app-django==5.4.1 # via @@ -1157,7 +1155,7 @@ social-auth-core==4.5.4 # -r requirements/edx/kernel.in # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki @@ -1171,7 +1169,7 @@ sqlparse==0.5.1 # via django staff-graded-xblock==2.3.0 # via -r requirements/edx/bundled.in -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -1182,7 +1180,7 @@ stevedore==5.2.0 # edx-opaque-keys super-csv==3.2.0 # via edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via openedx-calc testfixtures==8.3.0 # via edx-enterprise @@ -1206,8 +1204,10 @@ typing-extensions==4.12.2 # pydantic-core # pylti1p3 # snowflake-connector-python -tzdata==2024.1 - # via celery +tzdata==2024.2 + # via + # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/kernel.in @@ -1217,7 +1217,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt @@ -1236,7 +1236,7 @@ voluptuous==0.15.2 # via ora2 walrus==0.9.4 # via edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/paver.txt wcwidth==0.2.13 # via prompt-toolkit @@ -1290,9 +1290,9 @@ xmlsec==1.3.13 # python3-saml xss-utils==0.6.0 # via -r requirements/edx/kernel.in -yarl==1.9.4 +yarl==1.12.1 # via aiohttp -zipp==3.20.0 +zipp==3.20.2 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt index a004eeeb9ffa..a1faf5e74025 100644 --- a/requirements/edx/coverage.txt +++ b/requirements/edx/coverage.txt @@ -8,7 +8,7 @@ chardet==5.2.0 # via diff-cover coverage==7.6.1 # via -r requirements/edx/coverage.in -diff-cover==9.1.1 +diff-cover==9.2.0 # via -r requirements/edx/coverage.in jinja2==3.1.4 # via diff-cover diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5a8bb199cc62..5fc874dcf9f3 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -21,7 +21,7 @@ aiohappyeyeballs==2.4.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -60,7 +60,7 @@ annotated-types==0.7.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pydantic -anyio==4.4.0 +anyio==4.6.0 # via # -r requirements/edx/testing.txt # starlette @@ -121,7 +121,7 @@ beautifulsoup4==4.12.3 # -r requirements/edx/testing.txt # pydata-sphinx-theme # pynliner -billiard==4.2.0 +billiard==4.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -140,14 +140,14 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -157,7 +157,7 @@ bridgekeeper==0.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -build==1.2.1 +build==1.2.2 # via # -r requirements/edx/../pip-tools.txt # pip-tools @@ -188,7 +188,7 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -196,7 +196,7 @@ certifi==2024.7.4 # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -286,7 +286,7 @@ crowdsourcehinter-xblock==0.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -311,7 +311,7 @@ cssutils==2.11.1 # pynliner ddt==1.7.2 # via -r requirements/edx/testing.txt -deepmerge==1.1.1 +deepmerge==2.0 # via # -r requirements/edx/doc.txt # sphinxcontrib-openapi @@ -323,7 +323,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.1.1 +diff-cover==9.2.0 # via -r requirements/edx/testing.txt dill==0.3.8 # via @@ -333,7 +333,7 @@ distlib==0.3.8 # via # -r requirements/edx/testing.txt # virtualenv -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -482,7 +482,7 @@ django-method-override==1.0.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -521,7 +521,7 @@ django-oauth-toolkit==1.7.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -544,7 +544,7 @@ django-sekizai==4.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -576,7 +576,7 @@ django-stubs==1.16.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/development.in # djangorestframework-stubs -django-stubs-ext==5.0.4 +django-stubs-ext==5.1.0 # via django-stubs django-user-tasks==3.2.0 # via @@ -638,7 +638,7 @@ docutils==0.21.2 # pydata-sphinx-theme # sphinx # sphinx-mdinclude -done-xblock==2.3.0 +done-xblock==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -661,12 +661,12 @@ edx-ace==1.11.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -696,7 +696,7 @@ edx-codejail==3.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-completion==4.6.7 +edx-completion==4.7.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -728,7 +728,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -760,13 +760,13 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/edx/testing.txt edx-milestones==0.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-name-affirmation==2.4.1 +edx-name-affirmation==2.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -795,12 +795,12 @@ edx-proctoring==4.18.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -814,7 +814,7 @@ edx-sga==0.25.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-submissions==3.7.7 +edx-submissions==3.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -846,7 +846,7 @@ edx-when==2.5.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -879,20 +879,20 @@ execnet==2.1.1 # pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.txt -faker==27.0.0 +faker==30.0.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.112.1 +fastapi==0.115.0 # via # -r requirements/edx/testing.txt # pact-python -fastavro==1.9.5 +fastavro==1.9.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-events -filelock==3.15.4 +filelock==3.16.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -943,7 +943,7 @@ glob2==0.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -952,12 +952,12 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -978,7 +978,7 @@ google-cloud-core==2.4.1 # -r requirements/edx/testing.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -988,7 +988,7 @@ google-cloud-storage==2.18.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -999,7 +999,7 @@ google-resumable-media==2.7.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1009,13 +1009,13 @@ grimp==3.4.1 # via # -r requirements/edx/testing.txt # import-linter -grpcio==1.65.5 +grpcio==1.66.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1049,7 +1049,7 @@ icalendar==5.0.13 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -idna==3.7 +idna==3.10 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1064,7 +1064,7 @@ imagesize==1.4.1 # sphinx import-linter==2.0 # via -r requirements/edx/testing.txt -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1114,7 +1114,7 @@ joblib==1.4.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1147,7 +1147,7 @@ jwcrypto==1.5.6 # -r requirements/edx/testing.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1236,7 +1236,7 @@ mccabe==0.7.0 # via # -r requirements/edx/testing.txt # pylint -meilisearch==0.31.4 +meilisearch==0.31.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1248,7 +1248,7 @@ mock==5.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1258,7 +1258,7 @@ monotonic==1.6 # -r requirements/edx/testing.txt # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1268,18 +1268,18 @@ mpmath==1.3.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # sympy -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp # yarl -mypy==1.11.1 +mypy==1.11.2 # via # -r requirements/edx/development.in # django-stubs @@ -1336,7 +1336,7 @@ openai==0.28.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1344,7 +1344,7 @@ openedx-calc==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1413,7 +1413,7 @@ pansi==2020.7.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1436,7 +1436,7 @@ paver==1.3.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1463,7 +1463,7 @@ pillow==10.4.0 # edxval pip-tools==7.4.1 # via -r requirements/edx/../pip-tools.txt -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1482,7 +1482,7 @@ polib==1.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1493,7 +1493,7 @@ proto-plus==1.24.0 # -r requirements/edx/testing.txt # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1516,14 +1516,14 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1548,13 +1548,13 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # camel-converter # fastapi -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1654,14 +1654,14 @@ pyopenssl==24.2.1 # -r requirements/edx/testing.txt # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # chem # httplib2 # openedx-calc -pyproject-api==1.7.1 +pyproject-api==1.8.0 # via # -r requirements/edx/testing.txt # tox @@ -1670,7 +1670,7 @@ pyproject-hooks==1.1.0 # -r requirements/edx/../pip-tools.txt # build # pip-tools -pyquery==2.0.0 +pyquery==2.0.1 # via -r requirements/edx/testing.txt pyrsistent==0.20.0 # via @@ -1682,7 +1682,7 @@ pysrt==1.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -pytest==8.3.2 +pytest==8.3.3 # via # -r requirements/edx/testing.txt # pylint-pytest @@ -1697,7 +1697,7 @@ pytest-attrib==0.1.3 # via -r requirements/edx/testing.txt pytest-cov==5.0.0 # via -r requirements/edx/testing.txt -pytest-django==4.8.0 +pytest-django==4.9.0 # via -r requirements/edx/testing.txt pytest-json-report==1.5.0 # via -r requirements/edx/testing.txt @@ -1753,11 +1753,10 @@ python3-saml==1.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1795,7 +1794,7 @@ random2==1.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1810,7 +1809,7 @@ referencing==0.35.1 # -r requirements/edx/testing.txt # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1862,7 +1861,7 @@ rsa==4.9 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1879,7 +1878,7 @@ sailthru-client==2.2.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1942,7 +1941,6 @@ slumber==0.7.1 # -r requirements/edx/testing.txt # edx-bulk-grades # edx-enterprise - # edx-rest-api-client smmap==5.0.1 # via # -r requirements/edx/doc.txt @@ -1955,7 +1953,7 @@ snowballstemmer==2.2.0 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1972,7 +1970,7 @@ social-auth-core==4.5.4 # -r requirements/edx/testing.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2050,11 +2048,11 @@ staff-graded-xblock==2.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -starlette==0.38.2 +starlette==0.38.6 # via # -r requirements/edx/testing.txt # fastapi -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2068,7 +2066,7 @@ super-csv==3.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2096,7 +2094,7 @@ tomlkit==0.13.2 # -r requirements/edx/testing.txt # pylint # snowflake-connector-python -tox==4.18.0 +tox==4.20.0 # via -r requirements/edx/testing.txt tqdm==4.66.5 # via @@ -2104,9 +2102,9 @@ tqdm==4.66.5 # -r requirements/edx/testing.txt # nltk # openai -types-pytz==2024.1.0.20240417 +types-pytz==2024.2.0.20240913 # via django-stubs -types-pyyaml==6.0.12.20240808 +types-pyyaml==6.0.12.20240917 # via # django-stubs # djangorestframework-stubs @@ -2133,11 +2131,12 @@ typing-extensions==4.12.2 # pydata-sphinx-theme # pylti1p3 # snowflake-connector-python -tzdata==2024.1 +tzdata==2024.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/doc.txt @@ -2152,7 +2151,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -2176,7 +2175,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.26.3 +virtualenv==20.26.5 # via # -r requirements/edx/testing.txt # tox @@ -2185,14 +2184,14 @@ voluptuous==0.15.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -vulture==2.11 +vulture==2.12 # via -r requirements/edx/development.in walrus==0.9.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via # -r requirements/edx/development.in # -r requirements/edx/doc.txt @@ -2276,13 +2275,13 @@ xss-utils==0.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -yarl==1.9.4 +yarl==1.12.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp # pact-python -zipp==3.20.0 +zipp==3.20.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 4beb084733d6..271ee7fe4d92 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -14,7 +14,7 @@ aiohappyeyeballs==2.4.0 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # -r requirements/edx/base.txt # geoip2 @@ -87,7 +87,7 @@ beautifulsoup4==4.12.3 # -r requirements/edx/base.txt # pydata-sphinx-theme # pynliner -billiard==4.2.0 +billiard==4.2.1 # via # -r requirements/edx/base.txt # celery @@ -102,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/base.txt # boto3 @@ -137,14 +137,14 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/base.txt # elasticsearch # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # -r requirements/edx/base.txt # cryptography @@ -196,7 +196,7 @@ codejail-includes==1.0.0 # via -r requirements/edx/base.txt crowdsourcehinter-xblock==0.7 # via -r requirements/edx/base.txt -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/base.txt # django-fernet-fields-v2 @@ -213,7 +213,7 @@ cssutils==2.11.1 # via # -r requirements/edx/base.txt # pynliner -deepmerge==1.1.1 +deepmerge==2.0 # via sphinxcontrib-openapi defusedxml==0.7.1 # via @@ -222,7 +222,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -349,7 +349,7 @@ django-js-asset==2.2.0 # django-mptt django-method-override==1.0.4 # via -r requirements/edx/base.txt -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/base.txt # django-user-tasks @@ -382,7 +382,7 @@ django-oauth-toolkit==1.7.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -398,7 +398,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via -r requirements/edx/base.txt django-simple-history==3.4.0 # via @@ -468,7 +468,7 @@ docutils==0.21.2 # pydata-sphinx-theme # sphinx # sphinx-mdinclude -done-xblock==2.3.0 +done-xblock==2.4.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 # via @@ -483,11 +483,11 @@ drf-yasg==1.21.7 # edx-api-doc-tools edx-ace==1.11.2 # via -r requirements/edx/base.txt -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/base.txt # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via -r requirements/edx/base.txt edx-braze-client==0.2.5 # via @@ -509,7 +509,7 @@ edx-celeryutils==1.3.0 # super-csv edx-codejail==3.4.1 # via -r requirements/edx/base.txt -edx-completion==4.6.7 +edx-completion==4.7.1 # via -r requirements/edx/base.txt edx-django-release-util==1.4.0 # via @@ -535,7 +535,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/base.txt # edx-completion @@ -562,7 +562,7 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.1 +edx-name-affirmation==2.4.2 # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via @@ -585,11 +585,11 @@ edx-proctoring==4.18.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -598,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.7 +edx-submissions==3.8.0 # via # -r requirements/edx/base.txt # ora2 @@ -624,7 +624,7 @@ edx-when==2.5.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -644,11 +644,11 @@ event-tracking==3.0.0 # edx-completion # edx-proctoring # edx-search -fastavro==1.9.5 +fastavro==1.9.7 # via # -r requirements/edx/base.txt # openedx-events -filelock==3.15.4 +filelock==3.16.1 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -683,7 +683,7 @@ gitpython==3.1.43 # via -r requirements/edx/doc.in glob2==0.7 # via -r requirements/edx/base.txt -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -691,11 +691,11 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # -r requirements/edx/base.txt # google-api-core @@ -713,7 +713,7 @@ google-cloud-core==2.4.1 # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -721,7 +721,7 @@ google-cloud-storage==2.18.2 # via # -r requirements/edx/base.txt # firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -730,17 +730,17 @@ google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio==1.65.5 +grpcio==1.66.1 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via # -r requirements/edx/base.txt # google-api-core @@ -759,7 +759,7 @@ httplib2==0.22.0 # google-auth-httplib2 icalendar==5.0.13 # via -r requirements/edx/base.txt -idna==3.7 +idna==3.10 # via # -r requirements/edx/base.txt # optimizely-sdk @@ -768,7 +768,7 @@ idna==3.7 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via @@ -799,7 +799,7 @@ joblib==1.4.2 # via # -r requirements/edx/base.txt # nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -827,7 +827,7 @@ jwcrypto==1.5.6 # -r requirements/edx/base.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via # -r requirements/edx/base.txt # celery @@ -891,20 +891,20 @@ maxminddb==2.6.2 # via # -r requirements/edx/base.txt # geoip2 -meilisearch==0.31.4 +meilisearch==0.31.5 # via -r requirements/edx/base.txt mistune==3.0.2 # via sphinx-mdinclude mock==5.1.0 # via -r requirements/edx/base.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via -r requirements/edx/base.txt monotonic==1.6 # via # -r requirements/edx/base.txt # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r requirements/edx/base.txt # cssutils @@ -912,11 +912,11 @@ mpmath==1.3.0 # via # -r requirements/edx/base.txt # sympy -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/edx/base.txt # cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -957,11 +957,11 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via -r requirements/edx/base.txt openedx-calc==3.1.0 # via -r requirements/edx/base.txt -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock @@ -970,7 +970,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1008,7 +1008,7 @@ pansi==2020.7.3 # via # -r requirements/edx/base.txt # py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1026,7 +1026,7 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/base.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/base.txt # stevedore @@ -1044,7 +1044,7 @@ pillow==10.4.0 # edx-enterprise # edx-organizations # edxval -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -1052,7 +1052,7 @@ polib==1.2.0 # via # -r requirements/edx/base.txt # edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via # -r requirements/edx/base.txt # click-repl @@ -1061,7 +1061,7 @@ proto-plus==1.24.0 # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # -r requirements/edx/base.txt # google-api-core @@ -1077,13 +1077,13 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r requirements/edx/base.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r requirements/edx/base.txt # google-auth @@ -1099,11 +1099,11 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via # -r requirements/edx/base.txt # camel-converter -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via # -r requirements/edx/base.txt # pydantic @@ -1162,7 +1162,7 @@ pyopenssl==24.2.1 # -r requirements/edx/base.txt # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx/base.txt # chem @@ -1209,10 +1209,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/base.txt - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1243,7 +1242,7 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/base.txt -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via -r requirements/edx/base.txt redis==5.0.8 # via @@ -1254,7 +1253,7 @@ referencing==0.35.1 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via # -r requirements/edx/base.txt # nltk @@ -1299,7 +1298,7 @@ rsa==4.9 # via # -r requirements/edx/base.txt # google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1313,7 +1312,7 @@ sailthru-client==2.2.3 # via # -r requirements/edx/base.txt # edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx/base.txt # chem @@ -1364,12 +1363,11 @@ slumber==0.7.1 # -r requirements/edx/base.txt # edx-bulk-grades # edx-enterprise - # edx-rest-api-client smmap==5.0.1 # via gitdb snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1383,7 +1381,7 @@ social-auth-core==4.5.4 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1438,7 +1436,7 @@ sqlparse==0.5.1 # django staff-graded-xblock==2.3.0 # via -r requirements/edx/base.txt -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/base.txt # code-annotations @@ -1450,7 +1448,7 @@ super-csv==3.2.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx/base.txt # openedx-calc @@ -1486,10 +1484,11 @@ typing-extensions==4.12.2 # pydata-sphinx-theme # pylti1p3 # snowflake-connector-python -tzdata==2024.1 +tzdata==2024.2 # via # -r requirements/edx/base.txt # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/base.txt @@ -1500,7 +1499,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1524,7 +1523,7 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/base.txt wcwidth==0.2.13 # via @@ -1583,11 +1582,11 @@ xmlsec==1.3.13 # python3-saml xss-utils==0.6.0 # via -r requirements/edx/base.txt -yarl==1.9.4 +yarl==1.12.1 # via # -r requirements/edx/base.txt # aiohttp -zipp==3.20.0 +zipp==3.20.2 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt index d86acae05f4f..a0b1896919d4 100644 --- a/requirements/edx/paver.txt +++ b/requirements/edx/paver.txt @@ -4,7 +4,7 @@ # # make upgrade # -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==2.0.12 # via @@ -14,7 +14,7 @@ dnspython==2.6.1 # via pymongo edx-opaque-keys==2.11.0 # via -r requirements/edx/paver.in -idna==3.7 +idna==3.10 # via requests lazy==1.6 # via -r requirements/edx/paver.in @@ -32,7 +32,7 @@ path==16.11.0 # -r requirements/edx/paver.in paver==1.3.4 # via -r requirements/edx/paver.in -pbr==6.0.0 +pbr==6.1.0 # via stevedore psutil==6.0.0 # via -r requirements/edx/paver.in @@ -51,17 +51,17 @@ six==1.16.0 # via # libsass # paver -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/paver.in # edx-opaque-keys typing-extensions==4.12.2 # via edx-opaque-keys -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # requests -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/paver.in wrapt==1.16.0 # via -r requirements/edx/paver.in diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 292f1319048d..102289def277 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -17,7 +17,7 @@ boltons==21.0.0 # semgrep bracex==2.5 # via wcmatch -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==2.0.12 # via @@ -38,7 +38,7 @@ face==22.0.0 # via glom glom==22.1.0 # via semgrep -idna==3.7 +idna==3.10 # via requests jsonschema==4.23.0 # via semgrep @@ -60,7 +60,7 @@ referencing==0.35.1 # jsonschema-specifications requests==2.32.3 # via semgrep -rich==13.7.1 +rich==13.8.1 # via semgrep rpds-py==0.20.0 # via @@ -76,7 +76,7 @@ tomli==2.0.1 # via semgrep typing-extensions==4.12.2 # via semgrep -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # requests diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 04bc237de44d..9ffe0fed904d 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -12,7 +12,7 @@ aiohappyeyeballs==2.4.0 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # -r requirements/edx/base.txt # geoip2 @@ -39,7 +39,7 @@ annotated-types==0.7.0 # via # -r requirements/edx/base.txt # pydantic -anyio==4.4.0 +anyio==4.6.0 # via starlette appdirs==1.4.4 # via @@ -87,7 +87,7 @@ beautifulsoup4==4.12.3 # -r requirements/edx/base.txt # -r requirements/edx/testing.in # pynliner -billiard==4.2.0 +billiard==4.2.1 # via # -r requirements/edx/base.txt # celery @@ -102,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/base.txt # boto3 @@ -138,14 +138,14 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/base.txt # elasticsearch # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # -r requirements/edx/base.txt # cryptography @@ -215,7 +215,7 @@ coverage[toml]==7.6.1 # pytest-cov crowdsourcehinter-xblock==0.7 # via -r requirements/edx/base.txt -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/base.txt # django-fernet-fields-v2 @@ -245,13 +245,13 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.1.1 +diff-cover==9.2.0 # via -r requirements/edx/coverage.txt dill==0.3.8 # via pylint distlib==0.3.8 # via virtualenv -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -378,7 +378,7 @@ django-js-asset==2.2.0 # django-mptt django-method-override==1.0.4 # via -r requirements/edx/base.txt -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/base.txt # django-user-tasks @@ -411,7 +411,7 @@ django-oauth-toolkit==1.7.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -427,7 +427,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via -r requirements/edx/base.txt django-simple-history==3.4.0 # via @@ -492,7 +492,7 @@ dnspython==2.6.1 # via # -r requirements/edx/base.txt # pymongo -done-xblock==2.3.0 +done-xblock==2.4.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 # via @@ -507,11 +507,11 @@ drf-yasg==1.21.7 # edx-api-doc-tools edx-ace==1.11.2 # via -r requirements/edx/base.txt -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/base.txt # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via -r requirements/edx/base.txt edx-braze-client==0.2.5 # via @@ -533,7 +533,7 @@ edx-celeryutils==1.3.0 # super-csv edx-codejail==3.4.1 # via -r requirements/edx/base.txt -edx-completion==4.6.7 +edx-completion==4.7.1 # via -r requirements/edx/base.txt edx-django-release-util==1.4.0 # via @@ -559,7 +559,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/base.txt # edx-completion @@ -584,11 +584,11 @@ edx-i18n-tools==1.5.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # ora2 -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/edx/testing.in edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.1 +edx-name-affirmation==2.4.2 # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via @@ -611,11 +611,11 @@ edx-proctoring==4.18.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -624,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.7 +edx-submissions==3.8.0 # via # -r requirements/edx/base.txt # ora2 @@ -650,7 +650,7 @@ edx-when==2.5.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -674,15 +674,15 @@ execnet==2.1.1 # via pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.in -faker==27.0.0 +faker==30.0.0 # via factory-boy -fastapi==0.112.1 +fastapi==0.115.0 # via pact-python -fastavro==1.9.5 +fastavro==1.9.7 # via # -r requirements/edx/base.txt # openedx-events -filelock==3.15.4 +filelock==3.16.1 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -717,7 +717,7 @@ geoip2==4.8.0 # via -r requirements/edx/base.txt glob2==0.7 # via -r requirements/edx/base.txt -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -725,11 +725,11 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # -r requirements/edx/base.txt # google-api-core @@ -747,7 +747,7 @@ google-cloud-core==2.4.1 # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -755,7 +755,7 @@ google-cloud-storage==2.18.2 # via # -r requirements/edx/base.txt # firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -764,19 +764,19 @@ google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status grimp==3.4.1 # via import-linter -grpcio==1.65.5 +grpcio==1.66.1 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via # -r requirements/edx/base.txt # google-api-core @@ -799,7 +799,7 @@ httpretty==1.1.4 # via -r requirements/edx/testing.in icalendar==5.0.13 # via -r requirements/edx/base.txt -idna==3.7 +idna==3.10 # via # -r requirements/edx/base.txt # anyio @@ -809,7 +809,7 @@ idna==3.7 # yarl import-linter==2.0 # via -r requirements/edx/testing.in -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via @@ -847,7 +847,7 @@ joblib==1.4.2 # via # -r requirements/edx/base.txt # nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -874,7 +874,7 @@ jwcrypto==1.5.6 # -r requirements/edx/base.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via # -r requirements/edx/base.txt # celery @@ -944,18 +944,18 @@ maxminddb==2.6.2 # geoip2 mccabe==0.7.0 # via pylint -meilisearch==0.31.4 +meilisearch==0.31.5 # via -r requirements/edx/base.txt mock==5.1.0 # via -r requirements/edx/base.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via -r requirements/edx/base.txt monotonic==1.6 # via # -r requirements/edx/base.txt # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r requirements/edx/base.txt # cssutils @@ -963,11 +963,11 @@ mpmath==1.3.0 # via # -r requirements/edx/base.txt # sympy -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/edx/base.txt # cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -1008,11 +1008,11 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via -r requirements/edx/base.txt openedx-calc==3.1.0 # via -r requirements/edx/base.txt -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock @@ -1021,7 +1021,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1062,7 +1062,7 @@ pansi==2020.7.3 # via # -r requirements/edx/base.txt # py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1080,7 +1080,7 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/base.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/base.txt # stevedore @@ -1096,7 +1096,7 @@ pillow==10.4.0 # edx-enterprise # edx-organizations # edxval -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r requirements/edx/base.txt # pylint @@ -1114,7 +1114,7 @@ polib==1.2.0 # -r requirements/edx/base.txt # -r requirements/edx/testing.in # edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via # -r requirements/edx/base.txt # click-repl @@ -1123,7 +1123,7 @@ proto-plus==1.24.0 # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # -r requirements/edx/base.txt # google-api-core @@ -1143,13 +1143,13 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r requirements/edx/base.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r requirements/edx/base.txt # google-auth @@ -1169,12 +1169,12 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via # -r requirements/edx/base.txt # camel-converter # fastapi -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via # -r requirements/edx/base.txt # pydantic @@ -1247,15 +1247,15 @@ pyopenssl==24.2.1 # -r requirements/edx/base.txt # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx/base.txt # chem # httplib2 # openedx-calc -pyproject-api==1.7.1 +pyproject-api==1.8.0 # via tox -pyquery==2.0.0 +pyquery==2.0.1 # via -r requirements/edx/testing.in pyrsistent==0.20.0 # via @@ -1265,7 +1265,7 @@ pysrt==1.1.2 # via # -r requirements/edx/base.txt # edxval -pytest==8.3.2 +pytest==8.3.3 # via # -r requirements/edx/testing.in # pylint-pytest @@ -1280,7 +1280,7 @@ pytest-attrib==0.1.3 # via -r requirements/edx/testing.in pytest-cov==5.0.0 # via -r requirements/edx/testing.in -pytest-django==4.8.0 +pytest-django==4.9.0 # via -r requirements/edx/testing.in pytest-json-report==1.5.0 # via -r requirements/edx/testing.in @@ -1327,10 +1327,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/base.txt - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1360,7 +1359,7 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/base.txt -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via -r requirements/edx/base.txt redis==5.0.8 # via @@ -1371,7 +1370,7 @@ referencing==0.35.1 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via # -r requirements/edx/base.txt # nltk @@ -1416,7 +1415,7 @@ rsa==4.9 # via # -r requirements/edx/base.txt # google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1430,7 +1429,7 @@ sailthru-client==2.2.3 # via # -r requirements/edx/base.txt # edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx/base.txt # chem @@ -1484,10 +1483,9 @@ slumber==0.7.1 # -r requirements/edx/base.txt # edx-bulk-grades # edx-enterprise - # edx-rest-api-client sniffio==1.3.1 # via anyio -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1501,7 +1499,7 @@ social-auth-core==4.5.4 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1519,9 +1517,9 @@ sqlparse==0.5.1 # django staff-graded-xblock==2.3.0 # via -r requirements/edx/base.txt -starlette==0.38.2 +starlette==0.38.6 # via fastapi -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/base.txt # code-annotations @@ -1533,7 +1531,7 @@ super-csv==3.2.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx/base.txt # openedx-calc @@ -1555,7 +1553,7 @@ tomlkit==0.13.2 # -r requirements/edx/base.txt # pylint # snowflake-connector-python -tox==4.18.0 +tox==4.20.0 # via -r requirements/edx/testing.in tqdm==4.66.5 # via @@ -1575,10 +1573,11 @@ typing-extensions==4.12.2 # pydantic-core # pylti1p3 # snowflake-connector-python -tzdata==2024.1 +tzdata==2024.2 # via # -r requirements/edx/base.txt # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/base.txt @@ -1591,7 +1590,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1609,7 +1608,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.26.3 +virtualenv==20.26.5 # via tox voluptuous==0.15.2 # via @@ -1619,7 +1618,7 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/base.txt wcwidth==0.2.13 # via @@ -1680,12 +1679,12 @@ xmlsec==1.3.13 # python3-saml xss-utils==0.6.0 # via -r requirements/edx/base.txt -yarl==1.9.4 +yarl==1.12.1 # via # -r requirements/edx/base.txt # aiohttp # pact-python -zipp==3.20.0 +zipp==3.20.2 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index f7b35489c353..5bcb2aa55084 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,7 +4,7 @@ # # make upgrade # -build==1.2.1 +build==1.2.2 # via pip-tools click==8.1.6 # via diff --git a/requirements/pip.txt b/requirements/pip.txt index f0cf3d109992..36c777e21656 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==73.0.0 +setuptools==75.1.0 # via -r requirements/pip.in diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt index 828a81a8d4ed..b80c660b8749 100644 --- a/scripts/structures_pruning/requirements/base.txt +++ b/scripts/structures_pruning/requirements/base.txt @@ -13,16 +13,16 @@ click-log==0.4.0 # via -r scripts/structures_pruning/requirements/base.in dnspython==2.6.1 # via pymongo -edx-opaque-keys==2.10.0 +edx-opaque-keys==2.11.0 # via -r scripts/structures_pruning/requirements/base.in -pbr==6.0.0 +pbr==6.1.0 # via stevedore pymongo==4.4.0 # via # -c scripts/structures_pruning/requirements/../../../requirements/constraints.txt # -r scripts/structures_pruning/requirements/base.in # edx-opaque-keys -stevedore==5.2.0 +stevedore==5.3.0 # via edx-opaque-keys typing-extensions==4.12.2 # via edx-opaque-keys diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt index 47648d50fddb..8be2e15973d0 100644 --- a/scripts/structures_pruning/requirements/testing.txt +++ b/scripts/structures_pruning/requirements/testing.txt @@ -16,13 +16,13 @@ dnspython==2.6.1 # via # -r scripts/structures_pruning/requirements/base.txt # pymongo -edx-opaque-keys==2.10.0 +edx-opaque-keys==2.11.0 # via -r scripts/structures_pruning/requirements/base.txt iniconfig==2.0.0 # via pytest packaging==24.1 # via pytest -pbr==6.0.0 +pbr==6.1.0 # via # -r scripts/structures_pruning/requirements/base.txt # stevedore @@ -32,9 +32,9 @@ pymongo==4.4.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys -pytest==8.3.2 +pytest==8.3.3 # via -r scripts/structures_pruning/requirements/testing.in -stevedore==5.2.0 +stevedore==5.3.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 3e5ed4738070..2fb9d4543e21 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -10,17 +10,17 @@ attrs==24.2.0 # via zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.in -boto3==1.35.1 +boto3==1.35.27 # via -r scripts/user_retirement/requirements/base.in -botocore==1.35.1 +botocore==1.35.27 # via # boto3 # s3transfer cachetools==5.5.0 # via google-auth -certifi==2024.7.4 +certifi==2024.8.30 # via requests -cffi==1.17.0 +cffi==1.17.1 # via # cryptography # pynacl @@ -33,9 +33,9 @@ click==8.1.6 # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # -r scripts/user_retirement/requirements/base.in # edx-django-utils -cryptography==43.0.0 +cryptography==43.0.1 # via pyjwt -django==4.2.15 +django==4.2.16 # via # -c scripts/user_retirement/requirements/../../../requirements/common_constraints.txt # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt @@ -48,26 +48,26 @@ django-waffle==4.1.0 # via edx-django-utils edx-django-utils==5.15.0 # via edx-rest-api-client -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via -r scripts/user_retirement/requirements/base.in -google-api-core==2.19.1 +google-api-core==2.20.0 # via google-api-python-client -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via -r scripts/user_retirement/requirements/base.in -google-auth==2.34.0 +google-auth==2.35.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 google-auth-httplib2==0.2.0 # via google-api-python-client -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via google-api-core httplib2==0.22.0 # via # google-api-python-client # google-auth-httplib2 -idna==3.7 +idna==3.10 # via requests isodate==0.6.1 # via zeep @@ -81,28 +81,28 @@ lxml==4.9.4 # via # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # zeep -more-itertools==10.4.0 +more-itertools==10.5.0 # via simple-salesforce newrelic==9.13.0 # via edx-django-utils -pbr==6.0.0 +pbr==6.1.0 # via stevedore -platformdirs==4.2.2 +platformdirs==4.3.6 # via zeep proto-plus==1.24.0 # via google-api-core -protobuf==5.27.3 +protobuf==5.28.2 # via # google-api-core # googleapis-common-protos # proto-plus psutil==6.0.0 # via edx-django-utils -pyasn1==0.6.0 +pyasn1==0.6.1 # via # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via google-auth pycparser==2.22 # via cffi @@ -112,11 +112,11 @@ pyjwt[crypto]==2.9.0 # simple-salesforce pynacl==1.5.0 # via edx-django-utils -pyparsing==3.1.2 +pyparsing==3.1.4 # via httplib2 python-dateutil==2.9.0.post0 # via botocore -pytz==2024.1 +pytz==2024.2 # via # jenkinsapi # zeep @@ -131,7 +131,6 @@ requests==2.32.3 # requests-file # requests-toolbelt # simple-salesforce - # slumber # zeep requests-file==2.1.0 # via zeep @@ -150,11 +149,9 @@ six==1.16.0 # isodate # jenkinsapi # python-dateutil -slumber==0.7.1 - # via edx-rest-api-client sqlparse==0.5.1 # via django -stevedore==5.2.0 +stevedore==5.3.0 # via edx-django-utils typing-extensions==4.12.2 # via simple-salesforce @@ -162,7 +159,7 @@ unicodecsv==0.14.1 # via -r scripts/user_retirement/requirements/base.in uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # botocore diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index f7464dfa0602..73795fc6223e 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -14,11 +14,11 @@ attrs==24.2.0 # zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r scripts/user_retirement/requirements/base.txt # moto -botocore==1.35.1 +botocore==1.35.27 # via # -r scripts/user_retirement/requirements/base.txt # boto3 @@ -28,11 +28,11 @@ cachetools==5.5.0 # via # -r scripts/user_retirement/requirements/base.txt # google-auth -certifi==2024.7.4 +certifi==2024.8.30 # via # -r scripts/user_retirement/requirements/base.txt # requests -cffi==1.17.0 +cffi==1.17.1 # via # -r scripts/user_retirement/requirements/base.txt # cryptography @@ -45,14 +45,14 @@ click==8.1.6 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -cryptography==43.0.0 +cryptography==43.0.1 # via # -r scripts/user_retirement/requirements/base.txt # moto # pyjwt ddt==1.7.2 # via -r scripts/user_retirement/requirements/testing.in -django==4.2.15 +django==4.2.16 # via # -r scripts/user_retirement/requirements/base.txt # django-crum @@ -70,15 +70,15 @@ edx-django-utils==5.15.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-rest-api-client -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via -r scripts/user_retirement/requirements/base.txt -google-api-core==2.19.1 +google-api-core==2.20.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via -r scripts/user_retirement/requirements/base.txt -google-auth==2.34.0 +google-auth==2.35.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -88,7 +88,7 @@ google-auth-httplib2==0.2.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -97,7 +97,7 @@ httplib2==0.22.0 # -r scripts/user_retirement/requirements/base.txt # google-api-python-client # google-auth-httplib2 -idna==3.7 +idna==3.10 # via # -r scripts/user_retirement/requirements/base.txt # requests @@ -126,7 +126,7 @@ markupsafe==2.1.5 # werkzeug mock==5.1.0 # via -r scripts/user_retirement/requirements/testing.in -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r scripts/user_retirement/requirements/base.txt # simple-salesforce @@ -138,11 +138,11 @@ newrelic==9.13.0 # edx-django-utils packaging==24.1 # via pytest -pbr==6.0.0 +pbr==6.1.0 # via # -r scripts/user_retirement/requirements/base.txt # stevedore -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r scripts/user_retirement/requirements/base.txt # zeep @@ -152,7 +152,7 @@ proto-plus==1.24.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core -protobuf==5.27.3 +protobuf==5.28.2 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -162,12 +162,12 @@ psutil==6.0.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r scripts/user_retirement/requirements/base.txt # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r scripts/user_retirement/requirements/base.txt # google-auth @@ -184,18 +184,18 @@ pynacl==1.5.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r scripts/user_retirement/requirements/base.txt # httplib2 -pytest==8.3.2 +pytest==8.3.3 # via -r scripts/user_retirement/requirements/testing.in python-dateutil==2.9.0.post0 # via # -r scripts/user_retirement/requirements/base.txt # botocore # moto -pytz==2024.1 +pytz==2024.2 # via # -r scripts/user_retirement/requirements/base.txt # jenkinsapi @@ -216,7 +216,6 @@ requests==2.32.3 # requests-toolbelt # responses # simple-salesforce - # slumber # zeep requests-file==2.1.0 # via @@ -250,15 +249,11 @@ six==1.16.0 # isodate # jenkinsapi # python-dateutil -slumber==0.7.1 - # via - # -r scripts/user_retirement/requirements/base.txt - # edx-rest-api-client sqlparse==0.5.1 # via # -r scripts/user_retirement/requirements/base.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils @@ -272,13 +267,13 @@ uritemplate==4.1.1 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -r scripts/user_retirement/requirements/base.txt # botocore # requests # responses -werkzeug==3.0.3 +werkzeug==3.0.4 # via moto xmltodict==0.13.0 # via moto diff --git a/scripts/xblock/requirements.txt b/scripts/xblock/requirements.txt index 5b7f07242e6d..81ed56ea694a 100644 --- a/scripts/xblock/requirements.txt +++ b/scripts/xblock/requirements.txt @@ -4,17 +4,17 @@ # # make upgrade # -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==2.0.12 # via # -c scripts/xblock/../../requirements/constraints.txt # requests -idna==3.7 +idna==3.10 # via requests requests==2.32.3 # via -r scripts/xblock/requirements.in -urllib3==1.26.19 +urllib3==1.26.20 # via # -c scripts/xblock/../../requirements/constraints.txt # requests From 740921ae21b158acbd18ebbfc3f7bc8c5fe674ab Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 25 Sep 2024 12:48:49 -0400 Subject: [PATCH 58/67] build: Manually pull some RTD Context. See https://about.readthedocs.com/blog/2024/07/addons-by-default/ for details but essentially RTD is changing how it's building docs and this will let us handle the change gracefully. --- docs/conf.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index f37fc32f6160..01280c6cd214 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -258,6 +258,16 @@ epub_exclude_files = ['search.html'] +# -- Read the Docs Specific Configuration +# Define the canonical URL if you are using a custom domain on Read the Docs +html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") + +# Tell Jinja2 templates the build is running on Read the Docs +if os.environ.get("READTHEDOCS", "") == "True": + if "html_context" not in globals(): + html_context = {} + html_context["READTHEDOCS"] = True + # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- From 8f47c0b2744801b667d752ab5f066253b8f74582 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 26 Sep 2024 09:34:58 -0700 Subject: [PATCH 59/67] fix: whitespace issues in some capa problem index_dictionary content (#35543) --- .../content/search/tests/test_api.py | 4 +- .../content/search/tests/test_handlers.py | 2 +- xmodule/capa_block.py | 6 +- xmodule/tests/test_capa_block.py | 57 ++++++++++++------- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 2f43896c197e..4c6227af309f 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -141,7 +141,7 @@ def setUp(self): "context_key": "lib:org1:lib", "org": "org1", "breadcrumbs": [{"display_name": "Library"}], - "content": {"problem_types": [], "capa_content": " "}, + "content": {"problem_types": [], "capa_content": ""}, "type": "library_block", "access_id": lib_access.id, "last_published": None, @@ -157,7 +157,7 @@ def setUp(self): "context_key": "lib:org1:lib", "org": "org1", "breadcrumbs": [{"display_name": "Library"}], - "content": {"problem_types": [], "capa_content": " "}, + "content": {"problem_types": [], "capa_content": ""}, "type": "library_block", "access_id": lib_access.id, "last_published": None, diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py index 8a6627e3902d..bdc4814d1c8f 100644 --- a/openedx/core/djangoapps/content/search/tests/test_handlers.py +++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py @@ -148,7 +148,7 @@ def test_create_delete_library_block(self, meilisearch_client): "context_key": "lib:orgA:lib_a", "org": "orgA", "breadcrumbs": [{"display_name": "Library Org A"}], - "content": {"problem_types": [], "capa_content": " "}, + "content": {"problem_types": [], "capa_content": ""}, "access_id": lib_access.id, "last_published": None, "created": created_date.timestamp(), diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 54ca0cbc312f..c1c650144b05 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -616,11 +616,15 @@ def index_dictionary(self): "", capa_content ) + # Strip out all other tags, leaving their content. But we want spaces between adjacent tags, so that + #
Option A
Option B
+ # becomes "Option A Option B" not "Option AOption B" (these will appear in search results) + capa_content = re.sub(r"<([^>]+)>", r" <\2>", capa_content) capa_content = re.sub( r"(\s| |//)+", " ", nh3.clean(capa_content, tags=set()) - ) + ).strip() capa_body = { "capa_content": capa_content, diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index d1c01e109718..c81b137f1b23 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -3290,7 +3290,7 @@ def test_response_types_ignores_non_response_tags(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['multiplechoiceresponse'], - 'content': {'display_name': name, 'capa_content': ' Label Some comment Apple Banana Chocolate Donut '}} + 'content': {'display_name': name, 'capa_content': 'Label Some comment Apple Banana Chocolate Donut'}} def test_response_types_multiple_tags(self): xml = textwrap.dedent(""" @@ -3328,7 +3328,7 @@ def test_response_types_multiple_tags(self): 'problem_types': {"optionresponse", "multiplechoiceresponse"}, 'content': { 'display_name': name, - 'capa_content': " Label Some comment Donut Buggy '1','2' " + 'capa_content': "Label Some comment Donut Buggy '1','2'" }, } ) @@ -3369,7 +3369,7 @@ def test_solutions_not_indexed(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': [], - 'content': {'display_name': name, 'capa_content': ' '}} + 'content': {'display_name': name, 'capa_content': ''}} def test_indexing_checkboxes(self): name = "Checkboxes" @@ -3390,7 +3390,7 @@ def test_indexing_checkboxes(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['choiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_dropdown(self): name = "Dropdown" @@ -3405,7 +3405,7 @@ def test_indexing_dropdown(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['optionresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_multiple_choice(self): name = "Multiple Choice" @@ -3424,7 +3424,7 @@ def test_indexing_multiple_choice(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['multiplechoiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_numerical_input(self): name = "Numerical Input" @@ -3446,7 +3446,7 @@ def test_indexing_numerical_input(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['numericalresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_text_input(self): name = "Text Input" @@ -3465,7 +3465,7 @@ def test_indexing_text_input(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['stringresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_non_latin_problem(self): sample_text_input_problem_xml = textwrap.dedent(""" @@ -3476,7 +3476,7 @@ def test_indexing_non_latin_problem(self): """) name = "Non latin Input" block = self._create_block(sample_text_input_problem_xml, name=name) - capa_content = " Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL " + capa_content = "Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL" block_dict = block.index_dictionary() assert block_dict['content']['capa_content'] == smart_str(capa_content) @@ -3503,7 +3503,7 @@ def test_indexing_checkboxes_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['choiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_dropdown_with_hints_and_feedback(self): name = "Dropdown with Hints and Feedback" @@ -3523,7 +3523,7 @@ def test_indexing_dropdown_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['optionresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_multiple_choice_with_hints_and_feedback(self): name = "Multiple Choice with Hints and Feedback" @@ -3543,7 +3543,7 @@ def test_indexing_multiple_choice_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['multiplechoiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_numerical_input_with_hints_and_feedback(self): name = "Numerical Input with Hints and Feedback" @@ -3561,7 +3561,7 @@ def test_indexing_numerical_input_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['numericalresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_text_input_with_hints_and_feedback(self): name = "Text Input with Hints and Feedback" @@ -3579,7 +3579,7 @@ def test_indexing_text_input_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['stringresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_problem_with_html_tags(self): sample_problem_xml = textwrap.dedent(""" @@ -3598,14 +3598,33 @@ def test_indexing_problem_with_html_tags(self): """) name = "Mixed business" block = self._create_block(sample_problem_xml, name=name) - capa_content = textwrap.dedent(""" - This has HTML comment in it. - HTML end. - """) + capa_content = "This has HTML comment in it. HTML end." assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': [], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content}} + + def test_indexing_problem_with_no_whitespace_between_tags(self): + """ + The new (MFE) visual editor for capa problems renders the OLX without spaces between the tags. + We want to make sure the index description is still readable and has whitespace. + """ + sample_problem_xml = ( + "" + "
Question text here.
" + "
Option A
" + "
Option B
" + "
" + "
" + ) + name = "No spaces" + block = self._create_block(sample_problem_xml, name=name) + capa_content = "Question text here. Option A Option B" + assert block.index_dictionary() == { + 'content_type': ProblemBlock.INDEX_CONTENT_TYPE, + 'problem_types': ['choiceresponse'], + 'content': {'display_name': name, 'capa_content': capa_content}, + } def test_invalid_xml_handling(self): """ From 67b490cab46c6725528af2d8b0e53be8a45e79e0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 26 Sep 2024 09:35:17 -0700 Subject: [PATCH 60/67] fix: suppress errors+warnings when video is used in a content library (#35544) --- .../contentstore/views/transcripts_ajax.py | 3 +++ xmodule/video_block/transcripts_utils.py | 2 +- xmodule/video_block/video_block.py | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cms/djangoapps/contentstore/views/transcripts_ajax.py b/cms/djangoapps/contentstore/views/transcripts_ajax.py index 892b76caae72..8cb7f455013b 100644 --- a/cms/djangoapps/contentstore/views/transcripts_ajax.py +++ b/cms/djangoapps/contentstore/views/transcripts_ajax.py @@ -649,6 +649,9 @@ def _get_item(request, data): Returns the item. """ usage_key = UsageKey.from_string(data.get('locator')) + if not usage_key.context_key.is_course: + # TODO: implement transcript support for learning core / content libraries. + raise TranscriptsRequestValidationException(_('Transcripts are not yet supported in content libraries.')) # This is placed before has_course_author_access() to validate the location, # because has_course_author_access() raises r if location is invalid. item = modulestore().get_item(usage_key) diff --git a/xmodule/video_block/transcripts_utils.py b/xmodule/video_block/transcripts_utils.py index e41f925295f0..132b8cff1e14 100644 --- a/xmodule/video_block/transcripts_utils.py +++ b/xmodule/video_block/transcripts_utils.py @@ -1074,7 +1074,7 @@ def get_transcript_from_learning_core(video_block, language, output_format, tran """ # TODO: Update to use Learning Core data models once static assets support # has been added. - raise NotImplementedError("Transcripts not supported.") + raise NotFoundError("No transcript - transcripts not supported yet by learning core components.") def get_transcript(video, lang=None, output_format=Transcript.SRT, youtube_id=None): diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index b4fddb63fa7a..782645c373b0 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -482,7 +482,7 @@ def get_html(self, view=STUDENT_VIEW, context=None): # lint-amnesty, pylint: di 'hide_downloads': is_public_view or is_embed, 'id': self.location.html_id(), 'block_id': str(self.location), - 'course_id': str(self.location.course_key), + 'course_id': str(self.context_key), 'video_id': str(self.edx_video_id), 'user_id': self.get_user_id(), 'is_embed': is_embed, @@ -510,8 +510,10 @@ def get_course_video_sharing_override(self): """ Return course video sharing options override or None """ + if not self.context_key.is_course: + return False # Only courses support this feature at all (not libraries) try: - course = get_course_by_id(self.course_id) + course = get_course_by_id(self.context_key) return getattr(course, 'video_sharing_options', None) # In case the course / modulestore does something weird @@ -523,11 +525,13 @@ def is_public_sharing_enabled(self): """ Is public sharing enabled for this video? """ + if not self.context_key.is_course: + return False # Only courses support this feature at all (not libraries) try: # Video share feature must be enabled for sharing settings to take effect - feature_enabled = PUBLIC_VIDEO_SHARE.is_enabled(self.location.course_key) + feature_enabled = PUBLIC_VIDEO_SHARE.is_enabled(self.context_key) except Exception as err: # pylint: disable=broad-except - log.exception(f"Error retrieving course for course ID: {self.location.course_key}") + log.exception(f"Error retrieving course for course ID: {self.context_key}") return False if not feature_enabled: return False @@ -552,11 +556,13 @@ def is_transcript_feedback_enabled(self): """ Is transcript feedback enabled for this video? """ + if not self.context_key.is_course: + return False # Only courses support this feature at all (not libraries) try: # Video transcript feedback must be enabled in order to show the widget - feature_enabled = TRANSCRIPT_FEEDBACK.is_enabled(self.location.course_key) + feature_enabled = TRANSCRIPT_FEEDBACK.is_enabled(self.context_key) except Exception as err: # pylint: disable=broad-except - log.exception(f"Error retrieving course for course ID: {self.location.course_key}") + log.exception(f"Error retrieving course for course ID: {self.context_key}") return False return feature_enabled From ad78699605a7923e00c1d125b08b20379e1c4d5e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 26 Sep 2024 12:46:10 -0400 Subject: [PATCH 61/67] test: Use the correct exception. This test doesn't actually care about the type of the exception but use the Requests exception that you're likely to get instead of the edx-restapi-client/slumber one from before we dropped them. --- .../djangoapps/user_api/accounts/tests/test_settings_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py index 874497664d9d..badee6e87554 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py @@ -9,7 +9,7 @@ from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse -from edx_rest_api_client import exceptions +from requests import exceptions from edx_toggles.toggles.testutils import override_waffle_flag from lms.djangoapps.commerce.models import CommerceConfiguration @@ -210,7 +210,7 @@ def test_commerce_order_detail(self): assert order_detail[i] == expected def test_commerce_order_detail_exception(self): - with mock_get_orders(exception=exceptions.HttpNotFoundError): + with mock_get_orders(exception=exceptions.HTTPError): order_detail = get_user_orders(self.user) assert not order_detail From 2a3a0c489d81fc42356dae7e143be860f1c887dd Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 26 Sep 2024 12:59:15 -0400 Subject: [PATCH 62/67] test: Update a test based on changes to pytz. pytz dropped the Asia/Almaty timezone according to IANA https://github.com/stub42/pytz/commit/640c9bd426a3e62f12e7d5424d936b91dc442d93#diff-16061815f611262054e469307ca063a4ef47e158a97784f1e91d254f074324bfR72 --- openedx/core/djangoapps/user_api/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 981cc52dfd8f..75740cf5d2b6 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -635,7 +635,7 @@ def _assert_time_zone_is_valid(self, time_zone_info): assert time_zone_info['description'] == get_display_time_zone(time_zone_name) # The time zones count may need to change each time we upgrade pytz - @ddt.data((ALL_TIME_ZONES_URI, 433), + @ddt.data((ALL_TIME_ZONES_URI, 432), (COUNTRY_TIME_ZONES_URI, 23)) @ddt.unpack def test_get_basic(self, country_uri, expected_count): From dc27196f17573c8d2e35b8bdaf0b9054ddf4daa8 Mon Sep 17 00:00:00 2001 From: Jillian Date: Fri, 27 Sep 2024 03:36:03 +0930 Subject: [PATCH 63/67] chore: bumps openedx-learning to 0.13.1 (#35547) --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 3b73c62955f6..a81772df2ada 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -93,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.13.0 +openedx-learning==0.13.1 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 791ee21c359c..19aa21d6e795 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -824,7 +824,7 @@ openedx-filters==1.10.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.13.0 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5a8bb199cc62..b6067a6aa092 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1373,7 +1373,7 @@ openedx-filters==1.10.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.13.0 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 4beb084733d6..420266fb6ad3 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -983,7 +983,7 @@ openedx-filters==1.10.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.13.0 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 04bc237de44d..717c8fda2aad 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1034,7 +1034,7 @@ openedx-filters==1.10.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.13.0 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 338d58dd537cce9da83992991e2ab4925f7a787f Mon Sep 17 00:00:00 2001 From: katrinan029 Date: Thu, 26 Sep 2024 18:30:03 +0000 Subject: [PATCH 64/67] chore: version bump --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index a81772df2ada..71e09f532a52 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.16 +edx-enterprise==4.25.17 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ad07106dda39..67facfbfb742 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.16 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 647bc64c466d..837fade8889d 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.16 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index bb761307f461..8444539edf4f 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -547,7 +547,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.16 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index ce4850b78292..35d00344feb9 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -571,7 +571,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.16 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 624bef547c66da17048e29ff5d80126a665f1a0d Mon Sep 17 00:00:00 2001 From: katrinan029 Date: Thu, 26 Sep 2024 19:25:05 +0000 Subject: [PATCH 65/67] fix: bump version --- requirements/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 71e09f532a52..32516e724108 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.17 +edx-enterprise==4.25.17 # Stay on LTS version, remove once this is added to common constraint Django<5.0 From a2bc9e8d806e2ff81f1eacaabec73d5483511ba4 Mon Sep 17 00:00:00 2001 From: katrinan029 Date: Thu, 26 Sep 2024 19:25:52 +0000 Subject: [PATCH 66/67] chore: version bump --- requirements/constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 32516e724108..71e09f532a52 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.17 +edx-enterprise==4.25.17 # Stay on LTS version, remove once this is added to common constraint Django<5.0 From dac0309b0ff590ebaba5cf52e8fe518bd0c38270 Mon Sep 17 00:00:00 2001 From: Isaac Lee <124631592+ilee2u@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:58:01 -0400 Subject: [PATCH 67/67] fix: add should_display_status_to_user method and status_changed field to VerificationAttempt model (#35514) * fix: add placeholder should_display_status_to_user * fix: have VerificationAttempt inherit StatusModel - should_display_status_to_user now returns False * chore: makemigrations * feat: status_changed field added * temp: idea to add should_display_status_to_user * feat: add should_display_status_to_user * fix: correct call in helpers+services * chore: lint+test fix * fix: default hide_status_user as False * chore: rename field call to STATUS * chore: remove extra status field - comment cleanup * temp: lint + comment out created status for now * fix: revamp status_changed for back-compat * fix: override save for status_changed * fix: replace created/updated instead of status - also made migrations * fix: squash commits - also remove extra updated_at property * chore: nits --- lms/djangoapps/verify_student/api.py | 11 +++- ...ve_verificationattempt_created_and_more.py | 50 +++++++++++++++++++ lms/djangoapps/verify_student/models.py | 24 ++++++--- lms/djangoapps/verify_student/services.py | 4 +- 4 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py index 941dd60453d4..7b8310fde030 100644 --- a/lms/djangoapps/verify_student/api.py +++ b/lms/djangoapps/verify_student/api.py @@ -54,7 +54,13 @@ def send_approval_email(attempt): send_verification_approved_email(context=email_context) -def create_verification_attempt(user: User, name: str, status: str, expiration_datetime: Optional[datetime] = None): +def create_verification_attempt( + user: User, + name: str, + status: str, + expiration_datetime: Optional[datetime] = None, + hide_status_from_user: Optional[bool] = False, +): """ Create a verification attempt. @@ -74,6 +80,7 @@ def create_verification_attempt(user: User, name: str, status: str, expiration_d name=name, status=status, expiration_datetime=expiration_datetime, + hide_status_from_user=hide_status_from_user, ) emit_idv_attempt_created_event( @@ -129,7 +136,7 @@ def update_verification_attempt( 'Status must be one of: %(status_list)s', { 'status': status, - 'status_list': VerificationAttempt.STATUS_CHOICES, + 'status_list': VerificationAttempt.STATUS, }, ) raise VerificationAttemptInvalidStatus diff --git a/lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py b/lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py new file mode 100644 index 000000000000..d972dba3dbbd --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.15 on 2024-09-26 20:08 + +from django.db import migrations, models +import django.utils.timezone +import lms.djangoapps.verify_student.statuses +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('verify_student', '0015_verificationattempt'), + ] + + operations = [ + migrations.RemoveField( + model_name='verificationattempt', + name='created', + ), + migrations.RemoveField( + model_name='verificationattempt', + name='modified', + ), + migrations.AddField( + model_name='verificationattempt', + name='created_at', + field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='verificationattempt', + name='hide_status_from_user', + field=models.BooleanField(default=False, null=True), + ), + migrations.AddField( + model_name='verificationattempt', + name='status_changed', + field=model_utils.fields.MonitorField(default=django.utils.timezone.now, monitor='status', verbose_name='status changed'), + ), + migrations.AddField( + model_name='verificationattempt', + name='updated_at', + field=models.DateTimeField(auto_now=True, db_index=True), + ), + migrations.AlterField( + model_name='verificationattempt', + name='status', + field=model_utils.fields.StatusField(choices=[(lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['CREATED'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['CREATED']), (lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['PENDING'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['PENDING']), (lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['APPROVED'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['APPROVED']), (lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['DENIED'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['DENIED'])], default=lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['CREATED'], max_length=100, no_check_for_status=True, verbose_name='status'), + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 23729c99a0b9..9a0ac369640a 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -15,9 +15,11 @@ import logging import os.path import uuid + from datetime import timedelta from email.utils import formatdate + import requests from config_models.models import ConfigurationModel from django.conf import settings @@ -1214,7 +1216,7 @@ def __str__(self): return str(self.arguments) -class VerificationAttempt(TimeStampedModel): +class VerificationAttempt(StatusModel): """ The model represents impelementation-agnostic information about identity verification (IDV) attempts. @@ -1224,23 +1226,29 @@ class VerificationAttempt(TimeStampedModel): user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) name = models.CharField(blank=True, max_length=255) - STATUS_CHOICES = [ + 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, ) - @property - def updated_at(self): - """Backwards compatibility with existing IDVerification models""" - return self.modified + hide_status_from_user = models.BooleanField( + default=False, + null=True, + ) + + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + + def should_display_status_to_user(self): + """When called, returns true or false based on the type of VerificationAttempt""" + return not self.hide_status_from_user @classmethod def retire_user(cls, user_id): diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 1a2d145e892a..f0d8a8631482 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -76,7 +76,7 @@ def verifications_for_user(cls, user): Return a list of all verifications associated with the given user. """ verifications = [] - for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created'), + for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created_at'), SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), SSOVerification.objects.filter(user=user).order_by('-created_at'), ManualVerification.objects.filter(user=user).order_by('-created_at')): @@ -97,7 +97,7 @@ def get_verified_user_ids(cls, users): VerificationAttempt.objects.filter(**{ 'user__in': users, 'status': 'approved', - 'created__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + 'created_at__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) }).values_list('user_id', flat=True), SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), SSOVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True),