From f14d279dadf66336c519368424c37dad2acc6d88 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 25 Jan 2024 22:13:02 +0000 Subject: [PATCH 01/12] feat: data migration for some ancient legacy problems one time data migration for certificates that ended up in a broken state (not marked is unavailable, but having invalidation records). FIXES: APER-1322 --- .../0037_fix_legacy_broken_invalid_certs.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py diff --git a/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py b/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py new file mode 100644 index 0000000000..00a9d8aaf0 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.23 on 2024-01-25 21:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + """ + If any certificates exist with an invalidation record that are not marked as unavailable, + change their status. Irreversible. + """ + + dependencies = [ + ("certificates", "0036_modifiedcertificatetemplatecommandconfiguration"), + ] + + def make_invalid_certificates_unavailable(apps, schema_editor): + GeneratedCertificate = apps.get_model("certificates", "GeneratedCertificate") + + GeneratedCertificate.objects.filter( + certificateinvalidation__active=True + ).exclude(status="unavailable").update(status="unavailable") + + operations = [ + migrations.RunPython( + make_invalid_certificates_unavailable, + reverse_code=migrations.RunPython.noop, + ) + ] From ceb248b6242732fc896253ab119a4c0176a2a154 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Sun, 28 Jan 2024 14:03:17 -0500 Subject: [PATCH 02/12] fix: edx-enterprise 4.10.11 --- 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 6416ab19ed..7e3e48f88b 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -23,7 +23,7 @@ click>=8.0,<9.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.10.10 +edx-enterprise==4.10.11 # django-oauth-toolkit version >=2.0.0 has breaking changes. More details # mentioned on this issue https://github.com/openedx/edx-platform/issues/32884 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index e5ad05edab..7b764e6372 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -475,7 +475,7 @@ edx-drf-extensions==10.0.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.10 +edx-enterprise==4.10.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index dc0256f846..5f70b4e9bc 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -755,7 +755,7 @@ edx-drf-extensions==10.0.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.10 +edx-enterprise==4.10.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index a437054dc7..ae3ab27a7d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -553,7 +553,7 @@ edx-drf-extensions==10.0.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.10 +edx-enterprise==4.10.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 4c045c036b..27bf1eec73 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -579,7 +579,7 @@ edx-drf-extensions==10.0.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.10 +edx-enterprise==4.10.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 928e810b3333774c4ee22b756a48fcae9467195e Mon Sep 17 00:00:00 2001 From: Saad Yousaf Date: Fri, 26 Jan 2024 15:11:53 +0500 Subject: [PATCH 03/12] Revert "temp: add temporary logs for course wide notifications" --- .../discussion/rest_api/discussions_notifications.py | 7 ------- openedx/core/djangoapps/notifications/audience_filters.py | 6 ------ openedx/core/djangoapps/notifications/handlers.py | 4 ---- 3 files changed, 17 deletions(-) diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index 31f158fbd7..cba7852023 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -1,8 +1,6 @@ """ Discussion notifications sender util. """ -import logging - from django.conf import settings from lms.djangoapps.discussion.django_comment_client.permissions import get_team from openedx_events.learning.data import UserNotificationData, CourseNotificationData @@ -22,9 +20,6 @@ from openedx.core.djangoapps.django_comment_common.models import ( ) -log = logging.getLogger(__name__) - - class DiscussionNotificationSender: """ Class to send notifications to users who are subscribed to the thread. @@ -262,8 +257,6 @@ class DiscussionNotificationSender: 'username': self.creator.username, 'post_title': self.thread.title } - - log.info(f"Temp: Audience filter for course-wide notification is {audience_filters}") self._send_course_wide_notification(notification_type, audience_filters, context) diff --git a/openedx/core/djangoapps/notifications/audience_filters.py b/openedx/core/djangoapps/notifications/audience_filters.py index 8d02bb3ea7..fe9a047f78 100644 --- a/openedx/core/djangoapps/notifications/audience_filters.py +++ b/openedx/core/djangoapps/notifications/audience_filters.py @@ -1,7 +1,6 @@ """ Audience based filters for notifications """ -import logging from abc import abstractmethod @@ -22,9 +21,6 @@ from openedx.core.djangoapps.django_comment_common.models import ( ) -log = logging.getLogger(__name__) - - class NotificationAudienceFilterBase: """ Base class for notification audience filters @@ -84,12 +80,10 @@ class CourseRoleAudienceFilter(NotificationAudienceFilterBase): if 'staff' in course_roles: staff_users = CourseStaffRole(course_key).users_with_role().values_list('id', flat=True) - log.info(f'Temp: Course wide notification, staff users calculated are {staff_users}') user_ids.extend(staff_users) if 'instructor' in course_roles: instructor_users = CourseInstructorRole(course_key).users_with_role().values_list('id', flat=True) - log.info(f'Temp: Course wide notification, instructor users calculated are {instructor_users}') user_ids.extend(instructor_users) return user_ids diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index 6f8b4775b9..dcd5f1ec1e 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -96,13 +96,10 @@ def calculate_course_wide_notification_audience(course_key, audience_filters): if filter_class: filter_instance = filter_class(course_key) filtered_users = filter_instance.filter(filter_values) - log.info(f'Temp: Course-wide notification filtered users are ' - f'{filtered_users} for filter type {filter_type}') audience_user_ids.extend(filtered_users) else: raise ValueError(f"Invalid audience filter type: {filter_type}") - log.info(f'Temp: Course-wide notification after audience filter is applied, users: {list(set(audience_user_ids))}') return list(set(audience_user_ids)) @@ -131,5 +128,4 @@ def generate_course_notifications(signal, sender, course_notification_data, meta 'content_url': course_notification_data.get('content_url'), } - log.info(f"Temp: Course-wide notification, user_ids to sent notifications to {notification_data.get('user_ids')}") send_notifications.delay(**notification_data) From 7f850d6a2705e76318b237d762d66b88f74a1e8f Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Mon, 29 Jan 2024 14:30:44 +0500 Subject: [PATCH 04/12] fix: resolved js error in course export (#34134) --- cms/static/js/views/export.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/views/export.js b/cms/static/js/views/export.js index 73007363b9..18f1cc182a 100644 --- a/cms/static/js/views/export.js +++ b/cms/static/js/views/export.js @@ -191,7 +191,7 @@ define([ * @return {JSON} the data of the previous export */ storedExport: function(contentHomeUrl) { - var storedData = JSON.parse($.cookie(COOKIE_NAME)); + var storedData = JSON.parse($.cookie(COOKIE_NAME) || null); if (storedData) { successUnixDate = storedData.date; } From cb2a34e51f4daeb41048d962f060806837e10632 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Mon, 29 Jan 2024 14:43:51 +0500 Subject: [PATCH 05/12] feat: logout other sessions on email change (#33846) * feat: logout other sessions on email change * fix: updated the approach for session invalidation * fix: update and add tests * fix: update tests with descriptive comments * feat: add integration tests * fix: store email in session update * fix: add setting for tests * fix: fix tests * feat: Upgrade Python dependency edx-drf-extensions (#34135) Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` Co-authored-by: syedsajjadkazmii --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: syedsajjadkazmii --- .../djangoapps/student/signals/receivers.py | 9 +- .../student/tests/test_receivers.py | 14 +- common/djangoapps/student/views/management.py | 2 +- .../certificates/apis/v0/tests/test_views.py | 6 +- lms/djangoapps/course_api/tests/test_views.py | 2 +- .../course_metadata/tests/test_views.py | 1 + lms/envs/common.py | 17 + .../djangoapps/safe_sessions/middleware.py | 88 +- .../safe_sessions/tests/test_middleware.py | 758 +++++++++++++++++- .../user_api/accounts/tests/test_views.py | 10 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 14 files changed, 892 insertions(+), 23 deletions(-) diff --git a/common/djangoapps/student/signals/receivers.py b/common/djangoapps/student/signals/receivers.py index 6d0cf4f061..079647c145 100644 --- a/common/djangoapps/student/signals/receivers.py +++ b/common/djangoapps/student/signals/receivers.py @@ -23,6 +23,7 @@ from common.djangoapps.student.models import ( ) from common.djangoapps.student.models_api import confirm_name_change from common.djangoapps.student.signals import USER_EMAIL_CHANGED +from openedx.core.djangoapps.safe_sessions.middleware import EmailChangeMiddleware from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed logger = logging.getLogger(__name__) @@ -105,8 +106,12 @@ if is_name_affirmation_installed(): @receiver(USER_EMAIL_CHANGED) -def _listen_for_user_email_changed(sender, user, **kwargs): - """ If user has changed their email, update that in email Braze. """ +def _listen_for_user_email_changed(sender, user, request, **kwargs): + """ If user has changed their email, update that in session and Braze profile. """ + + # Store the user's email for session consistency (used by EmailChangeMiddleware) + EmailChangeMiddleware.register_email_change(request, user.email) + email = user.email user_id = user.id attributes = [{'email': email, 'external_id': user_id}] diff --git a/common/djangoapps/student/tests/test_receivers.py b/common/djangoapps/student/tests/test_receivers.py index 8ce869a731..e987120a9d 100644 --- a/common/djangoapps/student/tests/test_receivers.py +++ b/common/djangoapps/student/tests/test_receivers.py @@ -9,7 +9,7 @@ from common.djangoapps.student.models import CourseEnrollmentCelebration, Pendin from common.djangoapps.student.signals.signals import USER_EMAIL_CHANGED from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from lms.djangoapps.courseware.toggles import COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES -from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.djangolib.testing.utils import skip_unless_lms, get_mock_request from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order @@ -75,10 +75,18 @@ class ReceiversTest(SharedModuleStoreTestCase): @patch('common.djangoapps.student.signals.receivers.get_braze_client') def test_listen_for_user_email_changed(self, mock_get_braze_client): """ - Ensure that USER_EMAIL_CHANGED signal triggers correct calls to get_braze_client. + Ensure that USER_EMAIL_CHANGED signal triggers correct calls to + get_braze_client and update email in session. """ user = UserFactory(email='email@test.com', username='jdoe') + request = get_mock_request(user=user) + request.session = self.client.session - USER_EMAIL_CHANGED.send(sender=None, user=user) + # simulating email change + user.email = 'new_email@test.com' + user.save() + + USER_EMAIL_CHANGED.send(sender=None, user=user, request=request) assert mock_get_braze_client.called + assert request.session.get('email', None) == user.email diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index cb3df1627f..d3e1c54180 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -910,7 +910,7 @@ def confirm_email_change(request, key): response = render_to_response("email_change_successful.html", address_context) - USER_EMAIL_CHANGED.send(sender=None, user=user) + USER_EMAIL_CHANGED.send(sender=None, user=user, request=request) return response diff --git a/lms/djangoapps/certificates/apis/v0/tests/test_views.py b/lms/djangoapps/certificates/apis/v0/tests/test_views.py index 9e62eab6d8..fd31e34569 100644 --- a/lms/djangoapps/certificates/apis/v0/tests/test_views.py +++ b/lms/djangoapps/certificates/apis/v0/tests/test_views.py @@ -309,7 +309,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC def test_query_counts(self): # Test student with no certificates student_no_cert = UserFactory.create(password=self.user_password) - with self.assertNumQueries(17, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(21, table_ignorelist=WAFFLE_TABLES): resp = self.get_response( AuthType.jwt, requesting_user=self.global_staff, @@ -319,7 +319,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC assert len(resp.data) == 0 # Test student with 1 certificate - with self.assertNumQueries(12, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(13, table_ignorelist=WAFFLE_TABLES): resp = self.get_response( AuthType.jwt, requesting_user=self.global_staff, @@ -359,7 +359,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC download_url='www.google.com', grade="0.88", ) - with self.assertNumQueries(12, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(13, table_ignorelist=WAFFLE_TABLES): resp = self.get_response( AuthType.jwt, requesting_user=self.global_staff, diff --git a/lms/djangoapps/course_api/tests/test_views.py b/lms/djangoapps/course_api/tests/test_views.py index c39f808478..4065c54a98 100644 --- a/lms/djangoapps/course_api/tests/test_views.py +++ b/lms/djangoapps/course_api/tests/test_views.py @@ -434,7 +434,7 @@ class CourseListSearchViewTest(CourseApiTestViewMixin, ModuleStoreTestCase, Sear self.setup_user(self.audit_user) # These query counts were found empirically - query_counts = [50, 46, 46, 46, 46, 46, 46, 46, 46, 46, 16] + query_counts = [53, 46, 46, 46, 46, 46, 46, 46, 46, 46, 16] ordered_course_ids = sorted([str(cid) for cid in (course_ids + [c.id for c in self.courses])]) self.clear_caches() diff --git a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py index 74b66fa5f0..4b3524eb1e 100644 --- a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py +++ b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py @@ -86,6 +86,7 @@ class CourseHomeMetadataTests(BaseCourseHomeTests): assert self.client.get(self.url).data['username'] == self.user.username def test_get_unknown_course(self): + self.client.logout() url = reverse('course-home:course-metadata', args=['course-v1:unknown+course+2T2020']) # Django TestCase wraps every test in a transaction, so we must specifically wrap this when we expect an error with transaction.atomic(): diff --git a/lms/envs/common.py b/lms/envs/common.py index 94d298cddb..b567b20a37 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2238,6 +2238,9 @@ MIDDLEWARE = [ #'django.contrib.auth.middleware.AuthenticationMiddleware', 'openedx.core.djangoapps.cache_toolbox.middleware.CacheBackedAuthenticationMiddleware', + # Middleware to flush user's session in other browsers when their email is changed. + 'openedx.core.djangoapps.safe_sessions.middleware.EmailChangeMiddleware', + 'common.djangoapps.student.middleware.UserStandingMiddleware', 'openedx.core.djangoapps.contentserver.middleware.StaticContentServer', @@ -5041,6 +5044,20 @@ HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD = 5 # .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-838 ENABLE_DYNAMIC_REGISTRATION_FIELDS = False +############## Settings for EmailChangeMiddleware ############### + +# .. toggle_name: ENFORCE_SESSION_EMAIL_MATCH +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: When enabled, this setting invalidates sessions in other browsers +# upon email change, while preserving the session validity in the browser where the +# email change occurs. This toggle is just being used for rollout. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-12-07 +# .. toggle_target_removal_date: 2024-04-01 +# .. toggle_tickets: https://2u-internal.atlassian.net/browse/VAN-1797 +ENFORCE_SESSION_EMAIL_MATCH = False + LEARNER_HOME_MFE_REDIRECT_PERCENTAGE = 0 ############### Settings for the ace_common plugin ################# diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py index 5f4449d93c..f3948217ef 100644 --- a/openedx/core/djangoapps/safe_sessions/middleware.py +++ b/openedx/core/djangoapps/safe_sessions/middleware.py @@ -95,7 +95,7 @@ from edx_django_utils.logging import encrypt_for_log from edx_django_utils.monitoring import set_custom_attribute from edx_toggles.toggles import SettingToggle -from openedx.core.djangoapps.user_authn.cookies import delete_logged_in_cookies +from openedx.core.djangoapps.user_authn.cookies import delete_logged_in_cookies, set_logged_in_cookies from openedx.core.lib.mobile_utils import is_request_from_mobile_app # .. toggle_name: LOG_REQUEST_USER_CHANGES @@ -768,6 +768,92 @@ class SafeSessionMiddleware(SessionMiddleware, MiddlewareMixin): return encrypt_for_log(str(request.headers), getattr(settings, 'SAFE_SESSIONS_DEBUG_PUBLIC_KEY', None)) +class EmailChangeMiddleware(MiddlewareMixin): + """ + Middleware responsible for performing the following + jobs on detecting an email change + 1) It will update the session's email and update the JWT cookie + to match the new email. + 2) It will invalidate any future session on other browsers where + the user's email does not match its session email. + + This middleware ensures that the sessions in other browsers + are invalidated when a user changes their email in one browser. + The active session in which the email change is made will remain valid. + + The user's email is stored in their session and JWT cookies during login + and gets updated when the user changes their email. + This middleware checks for any mismatch between the stored email + and the current user's email in each request, and if found, + it invalidates/flushes the session and mark cookies for deletion in request + which are then deleted in the process_response of SafeSessionMiddleware. + """ + + def process_request(self, request): + """ + Invalidate the user session if there's a mismatch + between the email in the user's session and request.user.email. + """ + if request.user.is_authenticated: + user_session_email = request.session.get('email', None) + are_emails_mismatched = user_session_email is not None and request.user.email != user_session_email + EmailChangeMiddleware._set_session_email_match_custom_attributes(are_emails_mismatched) + if settings.ENFORCE_SESSION_EMAIL_MATCH and are_emails_mismatched: + # Flush the session and mark cookies for deletion. + log.info( + f'EmailChangeMiddleware invalidating session for user: {request.user.id} due to email mismatch.' + ) + request.session.flush() + request.user = AnonymousUser() + _mark_cookie_for_deletion(request) + + def process_response(self, request, response): + """ + 1. Update the logged-in cookies if the email change was requested + 2. Store user's email in session if not already + """ + if request.user.is_authenticated: + if request.session.get('email', None) is None: + # .. custom_attribute_name: session_with_no_email_found + # .. custom_attribute_description: Indicates that user's email was not + # yet stored in the user's session. + set_custom_attribute('session_with_no_email_found', True) + request.session['email'] = request.user.email + + if request_cache.get_cached_response('email_change_requested').is_found: + # Update the JWT cookies with new user email + response = set_logged_in_cookies(request, response, request.user) + + return response + + @staticmethod + def register_email_change(request, email): + """ + Stores the fact that an email change happened. + + 1. Sets the email in session for later comparison. + 2. Sets a request level variable to mark that the user email change was requested. + """ + request.session['email'] = email + request_cache.set('email_change_requested', True) + + @staticmethod + def _set_session_email_match_custom_attributes(are_emails_mismatched): + """ + Sets custom attributes of session_email_match + """ + # .. custom_attribute_name: session_email_match + # .. custom_attribute_description: Indicates whether there is a match between the + # email in the user's session and the current user's email in the request. + set_custom_attribute('session_email_mismatch', are_emails_mismatched) + + # .. custom_attribute_name: is_enforce_session_email_match_enabled + # .. custom_attribute_description: Indicates whether session email match was enforced. + # When enforced/enabled, it invalidates sessions in other browsers upon email change, + # while preserving the session validity in the browser where the email change occurs. + set_custom_attribute('is_enforce_session_email_match_enabled', settings.ENFORCE_SESSION_EMAIL_MATCH) + + def obscure_token(value: Union[str, None]) -> Union[str, None]: """ Return a short string that can be used to detect other occurrences diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py index 079cdcdd0c..0ffb1aeb8e 100644 --- a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py +++ b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py @@ -1,22 +1,29 @@ """ Unit tests for SafeSessionMiddleware """ - +import uuid from unittest.mock import call, patch, MagicMock import ddt from crum import set_current_request from django.conf import settings from django.contrib.auth import SESSION_KEY -from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from django.http import HttpResponse, HttpResponseRedirect, SimpleCookie from django.test import TestCase from django.test.utils import override_settings +from django.urls import reverse +from edx_django_utils.cache import RequestCache +from edx_rest_framework_extensions.auth.jwt import cookies as jwt_cookies -from openedx.core.djangolib.testing.utils import get_mock_request, CacheIsolationTestCase +from common.djangoapps.student.models import PendingEmailChange +from openedx.core.djangolib.testing.utils import get_mock_request, CacheIsolationTestCase, skip_unless_lms +from openedx.core.djangoapps.user_authn.tests.utils import setup_login_oauth_client +from openedx.core.djangoapps.user_authn.cookies import ALL_LOGGED_IN_COOKIE_NAMES from common.djangoapps.student.tests.factories import UserFactory from ..middleware import ( + EmailChangeMiddleware, SafeCookieData, SafeSessionMiddleware, mark_user_change_as_expected, @@ -615,3 +622,748 @@ class TestTrackRequestUserChanges(TestCase): request.user = object() assert len(request.debug_user_changes) == 2 assert "Changing request user but user has no id." in request.debug_user_changes[1] + + +@skip_unless_lms +class TestEmailChangeMiddleware(TestSafeSessionsLogMixin, TestCase): + """ + Test class for EmailChangeMiddleware + """ + + def setUp(self): + super().setUp() + self.EMAIL = 'test@example.com' + self.PASSWORD = 'Password1234' + self.user = UserFactory.create(email=self.EMAIL, password=self.PASSWORD) + self.addCleanup(set_current_request, None) + self.request = get_mock_request(self.user) + self.request.session = {} + self.client.response = HttpResponse() + self.client.response.cookies = SimpleCookie() + self.addCleanup(RequestCache.clear_all_namespaces) + + self.login_url = reverse("user_api_login_session", kwargs={'api_version': 'v2'}) + self.register_url = reverse("user_api_registration_v2") + self.dashboard_url = reverse('dashboard') + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=False) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + def test_process_request_user_not_authenticated_with_toggle_disabled(self, mock_mark_cookie_for_deletion): + """ + Calls EmailChangeMiddleware.process_request when no user is authenticated + and ENFORCE_SESSION_EMAIL_MATCH toggle is disabled. + Verifies that session and cookies are not affected. + """ + # Unauthenticated User + self.request.user = AnonymousUser() + + # Call process_request without authenticating a user + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Assert that session and cookies are not affected + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + def test_process_request_user_not_authenticated_with_toggle_enabled(self, mock_mark_cookie_for_deletion): + """ + Calls EmailChangeMiddleware.process_request when no user is authenticated + and ENFORCE_SESSION_EMAIL_MATCH toggle is enabled. + Verifies that session and cookies are not affected. + """ + # Unauthenticated User + self.request.user = AnonymousUser() + + # Call process_request without authenticating a user + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Assert that session and cookies are not affected + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_custom_attribute") + def test_process_request_emails_match_with_toggle_enabled( + self, mock_set_custom_attribute, mock_mark_cookie_for_deletion + ): + """ + Calls EmailChangeMiddleware.process_request when user is authenticated, + ENFORCE_SESSION_EMAIL_MATCH is enabled and user session and request email also match. + Verifies that session and cookies are not affected. + """ + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Registering email change (store user's email in session for later comparison by + # process_request function of middleware) + EmailChangeMiddleware.register_email_change(request=self.request, email=self.user.email) + + # Ensure email is set in the session + self.assertEqual(self.request.session.get('email'), self.user.email) + # Ensure session cookie exist + self.assertEqual(len(self.client.response.cookies), 1) + + # No email change occurred in any browser + + # Call process_request + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Verify that session_email_mismatch and is_enforce_session_email_match_enabled + # custom attributes are set + mock_set_custom_attribute.assert_has_calls([call('session_email_mismatch', False)]) + mock_set_custom_attribute.assert_has_calls([call('is_enforce_session_email_match_enabled', True)]) + + # Assert that the session and cookies are not affected + self.assertEqual(self.request.session.get('email'), self.user.email) + self.assertEqual(len(self.client.response.cookies), 1) + self.assertEqual(self.client.response.cookies[settings.SESSION_COOKIE_NAME].value, 'authenticated') + + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=False) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_custom_attribute") + def test_process_request_emails_match_with_toggle_disabled( + self, mock_set_custom_attribute, mock_mark_cookie_for_deletion + ): + """ + Calls EmailChangeMiddleware.process_request when user is authenticated, + ENFORCE_SESSION_EMAIL_MATCH is disabled and user session and request email match. + Verifies that session and cookies are not affected. + """ + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Registering email change (store user's email in session for later comparison by + # process_request function of middleware) + EmailChangeMiddleware.register_email_change(request=self.request, email=self.user.email) + + # Ensure email is set in the session + self.assertEqual(self.request.session.get('email'), self.user.email) + # Ensure session cookie exist + self.assertEqual(len(self.client.response.cookies), 1) + + # No email change occurred in any browser + + # Call process_request + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Verify that session_email_mismatch and is_enforce_session_email_match_enabled + # custom attributes are set + mock_set_custom_attribute.assert_has_calls([call('session_email_mismatch', False)]) + mock_set_custom_attribute.assert_has_calls([call('is_enforce_session_email_match_enabled', False)]) + + # Assert that the session and cookies are not affected + self.assertEqual(self.request.session.get('email'), self.user.email) + self.assertEqual(len(self.client.response.cookies), 1) + self.assertEqual(self.client.response.cookies[settings.SESSION_COOKIE_NAME].value, 'authenticated') + + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_custom_attribute") + def test_process_request_emails_mismatch_with_toggle_enabled( + self, mock_set_custom_attribute, mock_mark_cookie_for_deletion + ): + """ + Calls EmailChangeMiddleware.process_request when user is authenticated, + ENFORCE_SESSION_EMAIL_MATCH is enabled and user session and request + email mismatch. (Email was changed in some other browser) + Verifies that session is flushed and cookies are marked for deletion. + """ + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Registering email change (store user's email in session for later comparison by + # process_request function of middleware) + EmailChangeMiddleware.register_email_change(request=self.request, email=self.user.email) + + # Ensure email is set in the session + self.assertEqual(self.request.session.get('email'), self.user.email) + # Ensure session cookie exist + self.assertEqual(len(self.client.response.cookies), 1) + + # simulating email changed in some other browser + self.user.email = 'new_email@test.com' + self.user.save() + + # Call process_request + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Verify that session_email_mismatch and is_enforce_session_email_match_enabled + # custom attributes are set + mock_set_custom_attribute.assert_has_calls([call('session_email_mismatch', True)]) + mock_set_custom_attribute.assert_has_calls([call('is_enforce_session_email_match_enabled', True)]) + + # Assert that the session is flushed and cookies marked for deletion + mock_mark_cookie_for_deletion.assert_called() + assert self.request.session.get(SESSION_KEY) is None + assert self.request.user == AnonymousUser() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=False) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_custom_attribute") + def test_process_request_emails_mismatch_with_toggle_disabled( + self, mock_set_custom_attribute, mock_mark_cookie_for_deletion + ): + """ + Calls EmailChangeMiddleware.process_request when user is authenticated, + ENFORCE_SESSION_EMAIL_MATCH is disabled and user session and request + email mismatch. (Email was changed in some other browser) + Verifies that session and cookies are not affected. + """ + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Registering email change (store user's email in session for later comparison by + # process_request function of middleware) + EmailChangeMiddleware.register_email_change(request=self.request, email=self.user.email) + + # Ensure email is set in the session + self.assertEqual(self.request.session.get('email'), self.user.email) + # Ensure session cookie exist + self.assertEqual(len(self.client.response.cookies), 1) + + # simulating email changed in some other browser + self.user.email = 'new_email@test.com' + self.user.save() + + # Call process_request + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Verify that session_email_mismatch and is_enforce_session_email_match_enabled + # custom attributes are set + mock_set_custom_attribute.assert_has_calls([call('session_email_mismatch', True)]) + mock_set_custom_attribute.assert_has_calls([call('is_enforce_session_email_match_enabled', False)]) + + # Assert that the session and cookies are not affected + self.assertNotEqual(self.request.session.get('email'), self.user.email) + self.assertEqual(len(self.client.response.cookies), 1) + self.assertEqual(self.client.response.cookies[settings.SESSION_COOKIE_NAME].value, 'authenticated') + + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + def test_process_request_no_email_change_history_with_toggle_enabled( + self, mock_mark_cookie_for_deletion + ): + """ + Calls EmailChangeMiddleware.process_request when there is no previous + history of an email change and ENFORCE_SESSION_EMAIL_MATCH is enabled + Verifies that existing sessions are not affected. + Test that sessions predating this code are not affected. + """ + # Log in the user (Simulating user logged-in before this code and email was not set in session) + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Ensure there is no email in the session denoting no previous history of email change + self.assertEqual(self.request.session.get('email'), None) + + # Ensure session cookie exist + self.assertEqual(len(self.client.response.cookies), 1) + + # simulating email changed in some other browser + self.user.email = 'new_email@test.com' + self.user.save() + + # Call process_request + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Assert that the session and cookies are not affected + self.assertEqual(len(self.client.response.cookies), 1) + self.assertEqual(self.client.response.cookies[settings.SESSION_COOKIE_NAME].value, 'authenticated') + + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=False) + @patch('openedx.core.djangoapps.safe_sessions.middleware._mark_cookie_for_deletion') + def test_process_request_no_email_change_history_with_toggle_disabled( + self, mock_mark_cookie_for_deletion + ): + """ + Calls EmailChangeMiddleware.process_request when there is no previous + history of an email change and ENFORCE_SESSION_EMAIL_MATCH is disabled + Verifies that existing sessions are not affected. + Test that sessions predating this code are not affected. + """ + # Log in the user (Simulating user logged-in before this code and email was not set in session) + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Ensure there is no email in the session denoting no previous history of email change + self.assertEqual(self.request.session.get('email'), None) + + # Ensure session cookie exist + self.assertEqual(len(self.client.response.cookies), 1) + + # simulating email changed in some other browser + self.user.email = 'new_email@test.com' + self.user.save() + + # Call process_request + EmailChangeMiddleware(get_response=lambda request: None).process_request(self.request) + + # Assert that the session and cookies are not affected + self.assertEqual(len(self.client.response.cookies), 1) + self.assertEqual(self.client.response.cookies[settings.SESSION_COOKIE_NAME].value, 'authenticated') + + # Assert that _mark_cookie_for_deletion not called + mock_mark_cookie_for_deletion.assert_not_called() + + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_logged_in_cookies") + def test_process_response_user_not_authenticated(self, mock_set_logged_in_cookies): + """ + Calls EmailChangeMiddleware.process_response when user is not authenticated. + Verify that the logged-in cookies are not updated + """ + # return value of mock + mock_set_logged_in_cookies.return_value = self.client.response + + # Unauthenticated User + self.request.user = AnonymousUser() + + # Call process_response without authenticating a user + response = EmailChangeMiddleware(get_response=lambda request: None).process_response( + self.request, self.client.response + ) + + assert response.status_code == 200 + # Assert that cookies are not updated + # Assert that mock_set_logged_in_cookies not called + mock_set_logged_in_cookies.assert_not_called() + + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_logged_in_cookies") + def test_process_response_user_authenticated_but_email_change_not_requested(self, mock_set_logged_in_cookies): + """ + Calls EmailChangeMiddleware.process_response when user is authenticated but email + change was not requested. + Verify that the logged-in cookies are not updated + """ + # return value of mock + mock_set_logged_in_cookies.return_value = self.client.response + + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # No call to register_email_change to indicate email was not changed + + # Call process_response + response = EmailChangeMiddleware(get_response=lambda request: None).process_response( + self.request, self.client.response + ) + + assert response.status_code == 200 + # Assert that cookies are not updated + # Assert that mock_set_logged_in_cookies not called + mock_set_logged_in_cookies.assert_not_called() + + @patch("openedx.core.djangoapps.safe_sessions.middleware.set_logged_in_cookies") + def test_process_response_user_authenticated_and_email_change_requested(self, mock_set_logged_in_cookies): + """ + Calls EmailChangeMiddleware.process_response when user is authenticated and email + change was requested. + Verify that the logged-in cookies are updated + """ + # return value of mock + mock_set_logged_in_cookies.return_value = self.client.response + + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Registering email change (setting a variable `email_change_requested` to indicate email was changed) + # so that process_response can update cookies + EmailChangeMiddleware.register_email_change(request=self.request, email=self.user.email) + + # Call process_response + response = EmailChangeMiddleware(get_response=lambda request: None).process_response( + self.request, self.client.response + ) + + assert response.status_code == 200 + # Assert that cookies are updated + # Assert that mock_set_logged_in_cookies is called + mock_set_logged_in_cookies.assert_called() + + def test_process_response_no_email_in_session(self): + """ + Calls EmailChangeMiddleware.process_response when user is authenticated and + user's email was not stored in user's session. + Verify that the user's email is stored in session + """ + # Log in the user + self.client.login(email=self.user.email, password=self.PASSWORD) + self.request.session = self.client.session + self.client.response.set_cookie(settings.SESSION_COOKIE_NAME, 'authenticated') # Add some logged-in cookie + + # Ensure there is no email in the session + self.assertEqual(self.request.session.get('email'), None) + + # Call process_response + response = EmailChangeMiddleware(get_response=lambda request: None).process_response( + self.request, self.client.response + ) + + assert response.status_code == 200 + # Verify that email is set in the session + self.assertEqual(self.request.session.get('email'), self.user.email) + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + def test_user_remain_authenticated_on_email_change_in_other_browser_with_toggle_disabled(self): + """ + Integration Test: test that a user remains authenticated upon email change + in other browser when ENFORCE_SESSION_EMAIL_MATCH toggle is disabled + Verify that the session and cookies are not affected in current browser and + user remains authenticated + """ + setup_login_oauth_client() + + # Login the user with 'test@example.com` email and test password in current browser + response = self.client.post(self.login_url, { + "email_or_username": self.EMAIL, + "password": self.PASSWORD, + }) + # Verify that the user is logged in successfully in current browser + assert response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # simulating email changed in some other browser (Email is changed in DB) + self.user.email = 'new_email@test.com' + self.user.save() + + # Verify that the user remains authenticated in current browser and can access the dashboard + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + def test_cookies_are_updated_with_new_email_on_email_change_with_toggle_enabled(self): + """ + Integration Test: test that cookies are updated with new email upon email change + in current browser regardless of toggle setting + Verify that the cookies are updated in current browser and + user remains authenticated + """ + setup_login_oauth_client() + + # Login the user with 'test@example.com` email and test password in current browser + login_response = self.client.post(self.login_url, { + "email_or_username": self.EMAIL, + "password": self.PASSWORD, + }) + # Verify that the user is logged in successfully in current browser + assert login_response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(login_response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # simulating email change in current browser + activation_key = uuid.uuid4().hex + PendingEmailChange.objects.update_or_create( + user=self.user, + defaults={ + 'new_email': 'new_email@test.com', + 'activation_key': activation_key, + } + ) + email_change_response = self.client.get( + reverse('confirm_email_change', kwargs={'key': activation_key}), + ) + + # Verify that email change is successful + assert email_change_response.status_code == 200 + self._assert_logged_in_cookies_present(email_change_response) + + # Verify that jwt cookies are updated with new email and + # not equal to old logged-in cookies in current browser + self.assertNotEqual( + login_response.cookies[jwt_cookies.jwt_cookie_header_payload_name()].value, + email_change_response.cookies[jwt_cookies.jwt_cookie_header_payload_name()].value + ) + self.assertNotEqual( + login_response.cookies[jwt_cookies.jwt_cookie_signature_name()].value, + email_change_response.cookies[jwt_cookies.jwt_cookie_signature_name()].value + ) + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=False) + def test_cookies_are_updated_with_new_email_on_email_change_with_toggle_disabled(self): + """ + Integration Test: test that cookies are updated with new email upon email change + in current browser regardless of toggle setting + Verify that the cookies are updated in current browser and + user remains authenticated + """ + setup_login_oauth_client() + + # Login the user with 'test@example.com` email and test password in current browser + login_response = self.client.post(self.login_url, { + "email_or_username": self.EMAIL, + "password": self.PASSWORD, + }) + # Verify that the user is logged in successfully in current browser + assert login_response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(login_response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # simulating email change in current browser + activation_key = uuid.uuid4().hex + PendingEmailChange.objects.update_or_create( + user=self.user, + defaults={ + 'new_email': 'new_email@test.com', + 'activation_key': activation_key, + } + ) + email_change_response = self.client.get( + reverse('confirm_email_change', kwargs={'key': activation_key}), + ) + + # Verify that email change is successful + assert email_change_response.status_code == 200 + self._assert_logged_in_cookies_present(email_change_response) + + # Verify that jwt cookies are updated with new email and + # not equal to old logged-in cookies in current browser + self.assertNotEqual( + login_response.cookies[jwt_cookies.jwt_cookie_header_payload_name()].value, + email_change_response.cookies[jwt_cookies.jwt_cookie_header_payload_name()].value + ) + self.assertNotEqual( + login_response.cookies[jwt_cookies.jwt_cookie_signature_name()].value, + email_change_response.cookies[jwt_cookies.jwt_cookie_signature_name()].value + ) + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + def test_logged_in_user_unauthenticated_on_email_change_in_other_browser(self): + """ + Integration Test: Test that a user logged-in in one browser gets unauthenticated + when the email is changed in some other browser and the request and session emails mismatch. + Verify that the session is invalidated and cookies are deleted in current browser + and user gets unauthenticated. + """ + setup_login_oauth_client() + + # Login the user with 'test@example.com` email and test password in current browser + response = self.client.post(self.login_url, { + "email_or_username": self.EMAIL, + "password": self.PASSWORD, + }) + # Verify that the user is logged in successfully in current browser + assert response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # simulating email changed in some other browser (Email is changed in DB) + self.user.email = 'new_email@test.com' + self.user.save() + + # Verify that the user gets unauthenticated in current browser and cannot access the dashboard + response = self.client.get(self.dashboard_url) + assert response.status_code == 302 + self._assert_logged_in_cookies_not_present(response) + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + def test_logged_in_user_remains_authenticated_on_email_change_in_same_browser(self): + """ + Integration Test: test that a user logged-in in some browser remains authenticated + when the email is changed in same browser. + Verify that the session and cookies are updated in current browser and + user remains authenticated + """ + setup_login_oauth_client() + + # Login the user with 'test@example.com` email and test password in current browser + response = self.client.post(self.login_url, { + "email_or_username": self.EMAIL, + "password": self.PASSWORD, + }) + # Verify that the user is logged in successfully in current browser + assert response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # simulating email change in current browser + activation_key = uuid.uuid4().hex + PendingEmailChange.objects.update_or_create( + user=self.user, + defaults={ + 'new_email': 'new_email@test.com', + 'activation_key': activation_key, + } + ) + email_change_response = self.client.get( + reverse('confirm_email_change', kwargs={'key': activation_key}), + ) + + # Verify that email change is successful and all logged-in + # cookies are set in current browser + assert email_change_response.status_code == 200 + self._assert_logged_in_cookies_present(email_change_response) + + # Verify that the user remains authenticated in current browser and can access the dashboard + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + def test_registered_user_unauthenticated_on_email_change_in_other_browser(self): + """ + Integration Test: Test that a user registered in one browser gets unauthenticated + when the email is changed in some other browser and the request and session emails mismatch. + Verify that the session is invalidated and cookies are deleted in current browser + and user gets unauthenticated + """ + setup_login_oauth_client() + + # Register the user with 'john_doe@example.com` email and test password in current browser + response = self.client.post(self.register_url, { + "email": 'john_doe@example.com', + "name": 'John Doe', + "username": 'john_doe', + "password": 'password', + "honor_code": "true", + }) + # Verify that the user is logged in successfully in current browser + assert response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # simulating email changed in some other browser (Email is changed in DB) + registered_user = User.objects.get(email='john_doe@example.com') + registered_user.email = 'new_email@test.com' + registered_user.save() + + # Verify that the user get unauthenticated in current browser and cannot access the dashboard + response = self.client.get(self.dashboard_url) + assert response.status_code == 302 + self._assert_logged_in_cookies_not_present(response) + + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_SET_JWT_COOKIES_FOR_TESTS": False}) + @override_settings(ENFORCE_SESSION_EMAIL_MATCH=True) + def test_registered_user_remain_authenticated_on_email_change_in_same_browser(self): + """ + Integration Test: test that a user registered in one browser remains + authenticated in current browser when the email is changed in same browser. + Verify that the session and cookies updated and user remains + authenticated in current browser + """ + setup_login_oauth_client() + + # Register the user with 'john_doe@example.com` email and test password in current browser + response = self.client.post(self.register_url, { + "email": 'john_doe@example.com', + "name": 'John Doe', + "username": 'john_doe', + "password": 'password', + "honor_code": "true", + }) + # Verify that the user is logged in successfully in current browser + assert response.status_code == 200 + # Verify that the logged-in cookies are set in current browser + self._assert_logged_in_cookies_present(response) + + # Verify that the authenticated user can access the dashboard in current browser + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + # getting newly created user + registered_user = User.objects.get(email='john_doe@example.com') + + # simulating email change in current browser + activation_key = uuid.uuid4().hex + PendingEmailChange.objects.update_or_create( + user=registered_user, + defaults={ + 'new_email': 'new_email@test.com', + 'activation_key': activation_key, + } + ) + email_change_response = self.client.get( + reverse('confirm_email_change', kwargs={'key': activation_key}), + ) + + # Verify that email change is successful and all logged-in + # cookies are updated with new email in current browser + assert email_change_response.status_code == 200 + self._assert_logged_in_cookies_present(email_change_response) + + # Verify that the user remains authenticated in current browser and can access the dashboard + response = self.client.get(self.dashboard_url) + assert response.status_code == 200 + + def _assert_logged_in_cookies_present(self, response): + """ + Helper function to verify that all logged-in cookies are available + and have valid values (not empty strings) + """ + all_cookies = ALL_LOGGED_IN_COOKIE_NAMES + (settings.SESSION_COOKIE_NAME,) + + for cookie in all_cookies: + # Check if the cookie is present in response.cookies.keys() + self.assertIn(cookie, response.cookies.keys()) + + # Assert that the value is not an empty string + self.assertNotEqual(response.cookies[cookie].value, "") + + def _assert_logged_in_cookies_not_present(self, response): + """ + Helper function to verify that all logged-in cookies are cleared + and have empty values + """ + all_cookies = ALL_LOGGED_IN_COOKIE_NAMES + (settings.SESSION_COOKIE_NAME,) + + for cookie in all_cookies: + # Check if the cookie is present in response.cookies.keys() + self.assertIn(cookie, response.cookies.keys()) + + # Assert that the value is not an empty string + self.assertEqual(response.cookies[cookie].value, "") diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 546b8afacc..0496f45ae2 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -232,7 +232,7 @@ class TestOwnUsernameAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAP Test that a client (logged in) can get her own username. """ self.client.login(username=self.user.username, password=TEST_PASSWORD) - self._verify_get_own_username(16) + self._verify_get_own_username(19) def test_get_username_inactive(self): """ @@ -242,7 +242,7 @@ class TestOwnUsernameAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAP self.client.login(username=self.user.username, password=TEST_PASSWORD) self.user.is_active = False self.user.save() - self._verify_get_own_username(16) + self._verify_get_own_username(19) def test_get_username_not_logged_in(self): """ @@ -358,7 +358,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe """ ENABLED_CACHES = ['default'] - TOTAL_QUERY_COUNT = 24 + TOTAL_QUERY_COUNT = 27 FULL_RESPONSE_FIELD_COUNT = 29 def setUp(self): @@ -811,7 +811,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['time_zone'] is None self.client.login(username=self.user.username, password=TEST_PASSWORD) - verify_get_own_information(self._get_num_queries(22)) + verify_get_own_information(self._get_num_queries(25)) # Now make sure that the user can get the same information, even if not active self.user.is_active = False @@ -831,7 +831,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe legacy_profile.save() self.client.login(username=self.user.username, password=TEST_PASSWORD) - with self.assertNumQueries(self._get_num_queries(22), table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(self._get_num_queries(25), table_ignorelist=WAFFLE_TABLES): response = self.send_get(self.client) for empty_field in ("level_of_education", "gender", "country", "state", "bio",): assert response.data[empty_field] is None diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7b764e6372..f5dec2e8cb 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -463,7 +463,7 @@ edx-django-utils==5.10.1 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==10.0.0 +edx-drf-extensions==10.1.0 # via # -r requirements/edx/kernel.in # edx-completion diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5f70b4e9bc..1d89deb5d4 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -742,7 +742,7 @@ edx-django-utils==5.10.1 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==10.0.0 +edx-drf-extensions==10.1.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 ae3ab27a7d..da22aa05fe 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -541,7 +541,7 @@ edx-django-utils==5.10.1 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==10.0.0 +edx-drf-extensions==10.1.0 # via # -r requirements/edx/base.txt # edx-completion diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 27bf1eec73..2211f8798b 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -567,7 +567,7 @@ edx-django-utils==5.10.1 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==10.0.0 +edx-drf-extensions==10.1.0 # via # -r requirements/edx/base.txt # edx-completion From c456781067931b5825969b79f88b4ad0b5e009e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 16:36:26 +0000 Subject: [PATCH 06/12] feat: Upgrade Python dependency ora2 (#34137) Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` Co-authored-by: jansenk --- 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 f5dec2e8cb..6d5a04100d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -790,7 +790,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -ora2==6.0.29 +ora2==6.0.30 # via -r requirements/edx/bundled.in packaging==23.2 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 1d89deb5d4..f04ae1ec5f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1325,7 +1325,7 @@ optimizely-sdk==4.1.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==6.0.29 +ora2==6.0.30 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index da22aa05fe..60684bc3fd 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -932,7 +932,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.0.29 +ora2==6.0.30 # via -r requirements/edx/base.txt packaging==23.2 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 2211f8798b..f597a2b442 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -990,7 +990,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.0.29 +ora2==6.0.30 # via -r requirements/edx/base.txt packaging==23.2 # via From 1c049a3f39dbea8aae9feec485aacbd60d2dbfe8 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Mon, 29 Jan 2024 16:48:52 +0000 Subject: [PATCH 07/12] feat: switching to a defined enum feedback from code review FIXES: APER-1322 --- .../migrations/0037_fix_legacy_broken_invalid_certs.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py b/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py index 00a9d8aaf0..99e57eeed2 100644 --- a/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py +++ b/lms/djangoapps/certificates/migrations/0037_fix_legacy_broken_invalid_certs.py @@ -2,6 +2,8 @@ from django.db import migrations +from lms.djangoapps.certificates.data import CertificateStatuses + class Migration(migrations.Migration): """ @@ -18,7 +20,9 @@ class Migration(migrations.Migration): GeneratedCertificate.objects.filter( certificateinvalidation__active=True - ).exclude(status="unavailable").update(status="unavailable") + ).exclude(status=CertificateStatuses.unavailable).update( + status=CertificateStatuses.unavailable + ) operations = [ migrations.RunPython( From d4536ed79d771aa502da45d125f7ede0fe5d9b90 Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Tue, 30 Jan 2024 01:06:19 +0500 Subject: [PATCH 08/12] fix: resolved cookie js bug on import page (#34139) --- cms/static/js/features/import/views/import.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/features/import/views/import.js b/cms/static/js/features/import/views/import.js index e34965a092..8d6d0a6ace 100644 --- a/cms/static/js/features/import/views/import.js +++ b/cms/static/js/features/import/views/import.js @@ -332,7 +332,7 @@ define( * @return {JSON} the data of the previous import */ storedImport: function() { - return JSON.parse($.cookie(COOKIE_NAME)); + return JSON.parse($.cookie(COOKIE_NAME) || null); } }; From 039583f88f351975f1e54687e7411bfdba6e527c Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Tue, 30 Jan 2024 12:07:49 +0500 Subject: [PATCH 09/12] feat: added reported notification type (#34067) --- .../django_comment_client/base/tests.py | 12 ++- .../rest_api/discussions_notifications.py | 59 ++++++++++-- .../tests/test_disucssions_notifications.py | 91 +++++++++++++++++++ lms/djangoapps/discussion/signals/handlers.py | 33 +++++-- lms/djangoapps/discussion/toggles.py | 12 +++ .../notifications/base_notification.py | 19 ++++ .../core/djangoapps/notifications/models.py | 2 +- .../notifications/tests/test_views.py | 3 + .../core/djangoapps/notifications/utils.py | 5 + 9 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index dbfab3a3f2..c48e9cbe7d 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -426,6 +426,8 @@ class ViewsQueryCountTestCase( @ddt.ddt +@disable_signal(views, 'comment_flagged') +@disable_signal(views, 'thread_flagged') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class ViewsTestCase( ForumsEnableMixin, @@ -1714,7 +1716,13 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto commentable_id = getattr(self, commentable_id) self._setup_mock( user, mock_request, - {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread", "body": 'dummy body'}, + { + "closed": False, + "commentable_id": commentable_id, + "thread_id": "dummy_thread", + "body": 'dummy body', + "course_id": str(self.course.id) + }, ) for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]: response = self.client.post( @@ -1735,7 +1743,7 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto commentable_id = getattr(self, commentable_id) self._setup_mock( user, mock_request, - {"closed": False, "commentable_id": commentable_id, "body": "dummy body"}, + {"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)} ) for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", "follow_thread", "unfollow_thread"]: diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index cba7852023..a148fbdc9f 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -1,6 +1,8 @@ """ Discussion notifications sender util. """ +import re + from django.conf import settings from lms.djangoapps.discussion.django_comment_client.permissions import get_team from openedx_events.learning.data import UserNotificationData, CourseNotificationData @@ -70,7 +72,7 @@ class DiscussionNotificationSender: course_key=self.course.id, content_context={ "replier_name": self.creator.username, - "post_title": self.thread.title, + "post_title": getattr(self.thread, 'title', ''), "course_name": self.course.display_name, "sender_id": self.creator.id, **extra_context, @@ -201,16 +203,20 @@ class DiscussionNotificationSender: discussion_cohorted = is_discussion_cohorted(course_key_str) # Retrieves cohort divided discussion - discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + try: + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + except CourseDiscussionSettings.DoesNotExist: + return {} divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions( self.course, discussion_settings ) # Checks if post has any cohort assigned - group_id = self.thread.attributes['group_id'] - if group_id is not None: - group_id = int(group_id) + group_id = self.thread.attributes.get('group_id') + if group_id is None: + return {} + group_id = int(group_id) # Course wide topics all_topics = divided_inline_discussions + divided_course_wide_discussions @@ -259,11 +265,50 @@ class DiscussionNotificationSender: } self._send_course_wide_notification(notification_type, audience_filters, context) + def send_reported_content_notification(self): + """ + Send notification to users who are subscribed to the thread. + """ + thread_body = self.thread.body if self.thread.body else '' + + thread_body = remove_html_tags(thread_body) + thread_types = { + # numeric key is the depth of the thread in the discussion + 'comment': { + 1: 'comment', + 0: 'response' + }, + 'thread': { + 0: 'thread' + } + } + + content_type = thread_types[self.thread.type][getattr(self.thread, 'depth', 0)] + + context = { + 'username': self.creator.username, + 'content_type': content_type, + 'content': thread_body + } + audience_filters = self._create_cohort_course_audience() + audience_filters['discussion_roles'] = [ + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA + ] + self._send_course_wide_notification("content_reported", audience_filters, context) + def is_discussion_cohorted(course_key_str): """ Returns if the discussion is divided by cohorts """ - cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str) - discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + try: + cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str) + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + except (CourseCohortsSettings.DoesNotExist, CourseDiscussionSettings.DoesNotExist): + return False return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions + + +def remove_html_tags(text): + clean = re.compile('<.*?>') + return re.sub(clean, '', text) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py new file mode 100644 index 0000000000..1156440b6d --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_disucssions_notifications.py @@ -0,0 +1,91 @@ +""" +Unit tests for the DiscussionNotificationSender class +""" + +import unittest +from unittest.mock import MagicMock, patch + +import pytest +from edx_toggles.toggles.testutils import override_waffle_flag + +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS + + +@patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender' + '._create_cohort_course_audience', return_value={}) +@patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender' + '._send_course_wide_notification') +@pytest.mark.django_db +class TestDiscussionNotificationSender(unittest.TestCase): + """ + Tests for the DiscussionNotificationSender class + """ + + @override_waffle_flag(ENABLE_REPORTED_CONTENT_NOTIFICATIONS, True) + def setUp(self): + self.thread = MagicMock() + self.course = MagicMock() + self.creator = MagicMock() + self.notification_sender = DiscussionNotificationSender(self.thread, self.course, self.creator) + + def _setup_thread(self, thread_type, body, depth): + """ + Helper to set up the thread object + """ + self.thread.type = thread_type + self.thread.body = body + self.thread.depth = depth + self.creator.username = 'test_user' + + def _assert_send_notification_called_with(self, mock_send_notification, expected_content_type): + """ + Helper to assert that the send_notification method was called with the correct arguments + """ + notification_type, audience_filters, context = mock_send_notification.call_args[0] + mock_send_notification.assert_called_once() + + self.assertEqual(notification_type, "content_reported") + self.assertEqual(context, { + 'username': 'test_user', + 'content_type': expected_content_type, + 'content': 'Thread body' + }) + self.assertEqual(audience_filters, { + 'discussion_roles': ['Administrator', 'Moderator', 'Community TA'] + }) + + def test_send_reported_content_notification_for_response(self, mock_send_notification, mock_create_audience): + """ + Test that the send_reported_content_notification method calls the send_notification method with the correct + arguments for a comment with depth 0 + """ + self._setup_thread('comment', '

Thread body

', 0) + mock_create_audience.return_value = {} + + self.notification_sender.send_reported_content_notification() + + self._assert_send_notification_called_with(mock_send_notification, 'response') + + def test_send_reported_content_notification_for_comment(self, mock_send_notification, mock_create_audience): + """ + Test that the send_reported_content_notification method calls the send_notification method with the correct + arguments for a comment with depth 1 + """ + self._setup_thread('comment', '

Thread body

', 1) + mock_create_audience.return_value = {} + + self.notification_sender.send_reported_content_notification() + + self._assert_send_notification_called_with(mock_send_notification, 'comment') + + def test_send_reported_content_notification_for_thread(self, mock_send_notification, mock_create_audience): + """ + Test that the send_reported_content_notification method calls the send_notification method with the correct + """ + self._setup_thread('thread', '

Thread body

', 0) + mock_create_audience.return_value = {} + + self.notification_sender.send_reported_content_notification() + + self._assert_send_notification_called_with(mock_send_notification, 'thread') diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 3e142aecd9..f857e1aa8c 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -2,14 +2,17 @@ Signal handlers related to discussions. """ - import logging from django.conf import settings from django.dispatch import receiver from django.utils.html import strip_tags +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator -from xmodule.modulestore.django import SignalHandler + +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS +from xmodule.modulestore.django import SignalHandler, modulestore from lms.djangoapps.discussion import tasks from lms.djangoapps.discussion.rest_api.tasks import send_response_notifications, send_thread_created_notification @@ -19,7 +22,6 @@ from openedx.core.djangoapps.theming.helpers import get_current_site log = logging.getLogger(__name__) - ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY = 'enable_forum_notifications' @@ -43,7 +45,8 @@ def update_discussions_on_course_publish(sender, course_key, **kwargs): # pylin @receiver(signals.comment_created) -def send_discussion_email_notification(sender, user, post, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument +def send_discussion_email_notification(sender, user, post, + **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument current_site = get_current_site() if current_site is None: log.info('Discussion: No current site, not sending notification about post: %s.', post.id) @@ -64,7 +67,10 @@ def send_discussion_email_notification(sender, user, post, **kwargs): # lint-am @receiver(signals.comment_flagged) @receiver(signals.thread_flagged) -def send_reported_content_email_notification(sender, user, post, **kwargs): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument +def send_reported_content_email_notification(sender, user, post, **kwargs): + """ + Sends email notification for reported content. + """ current_site = get_current_site() if current_site is None: log.info('Discussion: No current site, not sending notification about post: %s.', post.id) @@ -84,6 +90,19 @@ def send_reported_content_email_notification(sender, user, post, **kwargs): # l send_message_for_reported_content(user, post, current_site, sender) +@receiver(signals.comment_flagged) +@receiver(signals.thread_flagged) +def send_reported_content_notification(sender, user, post, **kwargs): + """ + Sends notification for reported content. + """ + course_key = CourseKey.from_string(post.course_id) + if not ENABLE_REPORTED_CONTENT_NOTIFICATIONS.is_enabled(course_key): + return + course = modulestore().get_course(course_key) + DiscussionNotificationSender(post, course, user).send_reported_content_notification() + + def create_message_context(comment, site): thread = comment.thread return { @@ -105,6 +124,7 @@ def create_message_context_for_reported_content(user, post, site, sender): """ Create message context for reported content. """ + def get_comment_type(comment): """ Returns type of comment. @@ -131,7 +151,8 @@ def send_message(comment, site): # lint-amnesty, pylint: disable=missing-functi tasks.send_ace_message.apply_async(args=[context]) -def send_message_for_reported_content(user, post, site, sender): # lint-amnesty, pylint: disable=missing-function-docstring +def send_message_for_reported_content(user, post, site, + sender): # lint-amnesty, pylint: disable=missing-function-docstring context = create_message_context_for_reported_content(user, post, site, sender) tasks.send_ace_message_for_reported_content.apply_async(args=[context], countdown=120) diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index a1c292a473..29918c45a9 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -12,3 +12,15 @@ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # .. toggle_creation_date: 2021-11-05 # .. toggle_target_removal_date: 2022-12-05 ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe', __name__) + +# .. toggle_name: discussions.enable_reported_content_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable reported content notifications. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 18-Jan-2024 +# .. toggle_target_removal_date: 18-Feb-2024 +ENABLE_REPORTED_CONTENT_NOTIFICATIONS = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.enable_reported_content_notifications', + __name__ +) diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 877b029ebc..9d620b5b61 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -113,6 +113,25 @@ COURSE_NOTIFICATION_TYPES = { 'email_template': '', 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE] }, + 'content_reported': { + 'notification_app': 'discussion', + 'name': 'content_reported', + 'is_core': False, + 'info': '', + 'web': True, + 'email': True, + 'push': True, + 'non_editable': [], + 'content_template': _('

{username}’s {content_type} has been reported {' + 'content}

'), + + 'content_context': { + 'post_title': 'Post title', + 'author_name': 'author name', + 'replier_name': 'replier name', + }, + 'email_template': '', + }, } COURSE_NOTIFICATION_APPS = { diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 55f6c11a7c..c2c03f2023 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) NOTIFICATION_CHANNELS = ['web', 'push', 'email'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 4 +COURSE_NOTIFICATION_CONFIG_VERSION = 5 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 90b3fd39d3..815266235b 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -18,6 +18,7 @@ from rest_framework.test import APIClient, APITestCase from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -169,6 +170,7 @@ class CourseEnrollmentPostSaveTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +@override_waffle_flag(ENABLE_REPORTED_CONTENT_NOTIFICATIONS, active=True) @ddt.ddt class UserNotificationPreferenceAPITest(ModuleStoreTestCase): """ @@ -246,6 +248,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): }, 'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''}, 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''}, + 'content_reported': {'web': True, 'email': True, 'push': True, 'info': ''}, }, 'non_editable': { 'core': ['web'] diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index 99b133c501..05d9e4ecd2 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -4,6 +4,7 @@ Utils function for notifications app from typing import Dict, List from common.djangoapps.student.models import CourseEnrollment +from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS from openedx.core.djangoapps.django_comment_common.models import Role from openedx.core.lib.cache_utils import request_cached @@ -65,6 +66,10 @@ def filter_course_wide_preferences(course_key, preferences): if ENABLE_COURSEWIDE_NOTIFICATIONS.is_enabled(course_key): return preferences course_wide_notification_types = ['new_discussion_post', 'new_question_post'] + + if not ENABLE_REPORTED_CONTENT_NOTIFICATIONS.is_enabled(course_key): + course_wide_notification_types.append('content_reported') + config = preferences['notification_preference_config'] for app_prefs in config.values(): notification_types = app_prefs['notification_types'] From 9db7e57b9b2c467a97a950f39168624cfd84888d Mon Sep 17 00:00:00 2001 From: saleem-latif Date: Tue, 30 Jan 2024 07:51:29 +0000 Subject: [PATCH 10/12] feat: Upgrade Python dependency edx-enterprise 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 7e3e48f88b..dd88bbbd98 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -23,7 +23,7 @@ click>=8.0,<9.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.10.11 +edx-enterprise==4.11.0 # django-oauth-toolkit version >=2.0.0 has breaking changes. More details # mentioned on this issue https://github.com/openedx/edx-platform/issues/32884 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6d5a04100d..6e31fc148c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -475,7 +475,7 @@ edx-drf-extensions==10.1.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.11 +edx-enterprise==4.11.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 f04ae1ec5f..eb38b3778f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -755,7 +755,7 @@ edx-drf-extensions==10.1.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.11 +edx-enterprise==4.11.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 60684bc3fd..fe0338ca4a 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -553,7 +553,7 @@ edx-drf-extensions==10.1.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.11 +edx-enterprise==4.11.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 f597a2b442..7c038d3637 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -579,7 +579,7 @@ edx-drf-extensions==10.1.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.10.11 +edx-enterprise==4.11.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 0b7e27390c1769adaa09c6b1a05f6c6a8d2104bf Mon Sep 17 00:00:00 2001 From: IrfanUddinAhmad Date: Sun, 28 Jan 2024 11:34:24 +0500 Subject: [PATCH 11/12] feat: Added XblockMixin for skill tagging --- lms/envs/common.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lms/envs/common.py b/lms/envs/common.py index b567b20a37..7ed5b76f62 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -68,6 +68,10 @@ from openedx.core.djangoapps.theming.helpers_dirs import ( from openedx.core.lib.derived import derived, derived_collection_entry from openedx.core.release import doc_version from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin +try: + from skill_tagging.skill_tagging_mixin import SkillTaggingMixin +except ImportError: + SkillTaggingMixin = None ################################### FEATURES ################################### # .. setting_name: PLATFORM_NAME @@ -1633,6 +1637,8 @@ from xmodule.x_module import XModuleMixin # lint-amnesty, pylint: disable=wrong # This should be moved into an XBlock Runtime/Application object # once the responsibility of XBlock creation is moved out of modulestore - cpennington XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin) +if SkillTaggingMixin: + XBLOCK_MIXINS += (SkillTaggingMixin,) XBLOCK_EXTRA_MIXINS = () # .. setting_name: XBLOCK_FIELD_DATA_WRAPPERS From cc8f83792c529935ee3e8846cca901b07cdd27a8 Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Tue, 30 Jan 2024 07:44:28 -0500 Subject: [PATCH 12/12] chore: Updating Python Requirements (#34140) --- requirements/edx/base.txt | 12 ++++++------ requirements/edx/coverage.txt | 2 +- requirements/edx/development.txt | 28 ++++++++++++++-------------- requirements/edx/doc.txt | 12 ++++++------ requirements/edx/semgrep.txt | 2 +- requirements/edx/testing.txt | 26 +++++++++++++------------- 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6e31fc148c..ba85aded42 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -8,7 +8,7 @@ # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.9.1 +aiohttp==3.9.3 # via # geoip2 # openai @@ -73,13 +73,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.34.28 +boto3==1.34.30 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.34.28 +botocore==1.34.30 # via # -r requirements/edx/kernel.in # boto3 @@ -419,7 +419,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -r requirements/edx/bundled.in # edx-enterprise @@ -936,7 +936,7 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2023.3.post1 +pytz==2023.4 # via # -r requirements/edx/kernel.in # babel @@ -976,7 +976,7 @@ redis==5.0.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.32.1 +referencing==0.33.0 # via # jsonschema # jsonschema-specifications diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt index 3d8191aa4c..040832be39 100644 --- a/requirements/edx/coverage.txt +++ b/requirements/edx/coverage.txt @@ -6,7 +6,7 @@ # chardet==5.2.0 # via diff-cover -coverage==7.4.0 +coverage==7.4.1 # via -r requirements/edx/coverage.in diff-cover==8.0.3 # via -r requirements/edx/coverage.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index eb38b3778f..09bbb04832 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -16,7 +16,7 @@ acid-xblock==0.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -144,14 +144,14 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.34.28 +boto3==1.34.30 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.34.28 +botocore==1.34.30 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -280,7 +280,7 @@ coreschema==0.0.4 # -r requirements/edx/testing.txt # coreapi # drf-yasg -coverage[toml]==7.4.0 +coverage[toml]==7.4.1 # via # -r requirements/edx/testing.txt # coverage @@ -333,7 +333,7 @@ deprecated==1.2.14 # jwcrypto diff-cover==8.0.3 # via -r requirements/edx/testing.txt -dill==0.3.7 +dill==0.3.8 # via # -r requirements/edx/testing.txt # pylint @@ -686,7 +686,7 @@ edx-auth-backends==4.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-blockstore -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -896,7 +896,7 @@ execnet==2.0.2 # pytest-xdist factory-boy==3.3.0 # via -r requirements/edx/testing.txt -faker==22.5.1 +faker==22.6.0 # via # -r requirements/edx/testing.txt # factory-boy @@ -1463,11 +1463,11 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.5.3 +pydantic==2.6.0 # via # -r requirements/edx/testing.txt # fastapi -pydantic-core==2.14.6 +pydantic-core==2.16.1 # via # -r requirements/edx/testing.txt # pydantic @@ -1592,7 +1592,7 @@ pysrt==1.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -pytest==7.4.4 +pytest==8.0.0 # via # -r requirements/edx/testing.txt # pylint-pytest @@ -1665,7 +1665,7 @@ python3-saml==1.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pytz==2023.3.post1 +pytz==2023.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1717,7 +1717,7 @@ redis==5.0.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # walrus -referencing==0.32.1 +referencing==0.33.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2038,7 +2038,7 @@ tqdm==4.66.1 # -r requirements/edx/testing.txt # nltk # openai -types-pytz==2023.3.1.1 +types-pytz==2023.4.0.20240130 # via django-stubs types-pyyaml==6.0.12.12 # via @@ -2110,7 +2110,7 @@ user-util==1.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -uvicorn==0.27.0 +uvicorn==0.27.0.post1 # via # -r requirements/edx/testing.txt # pact-python diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index fe0338ca4a..f3a48ce464 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -10,7 +10,7 @@ accessible-pygments==0.0.4 # via pydata-sphinx-theme acid-xblock==0.2.1 # via -r requirements/edx/base.txt -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -r requirements/edx/base.txt # geoip2 @@ -102,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.34.28 +boto3==1.34.30 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.34.28 +botocore==1.34.30 # via # -r requirements/edx/base.txt # boto3 @@ -497,7 +497,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/base.txt # openedx-blockstore -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1119,7 +1119,7 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2023.3.post1 +pytz==2023.4 # via # -r requirements/edx/base.txt # babel @@ -1160,7 +1160,7 @@ redis==5.0.1 # via # -r requirements/edx/base.txt # walrus -referencing==0.32.1 +referencing==0.33.0 # via # -r requirements/edx/base.txt # jsonschema diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 11bfb4b51d..00735298e4 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -60,7 +60,7 @@ pkgutil-resolve-name==1.3.10 # via jsonschema pygments==2.17.2 # via rich -referencing==0.32.1 +referencing==0.33.0 # via # jsonschema # jsonschema-specifications diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 7c038d3637..f83a39fc21 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -8,7 +8,7 @@ # via -r requirements/edx/base.txt acid-xblock==0.2.1 # via -r requirements/edx/base.txt -aiohttp==3.9.1 +aiohttp==3.9.3 # via # -r requirements/edx/base.txt # geoip2 @@ -104,13 +104,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.34.28 +boto3==1.34.30 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.34.28 +botocore==1.34.30 # via # -r requirements/edx/base.txt # boto3 @@ -208,7 +208,7 @@ coreschema==0.0.4 # -r requirements/edx/base.txt # coreapi # drf-yasg -coverage[toml]==7.4.0 +coverage[toml]==7.4.1 # via # -r requirements/edx/coverage.txt # pytest-cov @@ -250,7 +250,7 @@ deprecated==1.2.14 # jwcrypto diff-cover==8.0.3 # via -r requirements/edx/coverage.txt -dill==0.3.7 +dill==0.3.8 # via pylint distlib==0.3.8 # via virtualenv @@ -523,7 +523,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/base.txt # openedx-blockstore -edx-braze-client==0.2.1 +edx-braze-client==0.2.2 # via # -r requirements/edx/base.txt # edx-enterprise @@ -685,7 +685,7 @@ execnet==2.0.2 # via pytest-xdist factory-boy==3.3.0 # via -r requirements/edx/testing.in -faker==22.5.1 +faker==22.6.0 # via factory-boy fastapi==0.109.0 # via pact-python @@ -1095,9 +1095,9 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.5.3 +pydantic==2.6.0 # via fastapi -pydantic-core==2.14.6 +pydantic-core==2.16.1 # via pydantic pygments==2.17.2 # via @@ -1185,7 +1185,7 @@ pysrt==1.1.2 # via # -r requirements/edx/base.txt # edxval -pytest==7.4.4 +pytest==8.0.0 # via # -r requirements/edx/testing.in # pylint-pytest @@ -1247,7 +1247,7 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2023.3.post1 +pytz==2023.4 # via # -r requirements/edx/base.txt # babel @@ -1287,7 +1287,7 @@ redis==5.0.1 # via # -r requirements/edx/base.txt # walrus -referencing==0.32.1 +referencing==0.33.0 # via # -r requirements/edx/base.txt # jsonschema @@ -1546,7 +1546,7 @@ urllib3==1.26.18 # snowflake-connector-python user-util==1.0.0 # via -r requirements/edx/base.txt -uvicorn==0.27.0 +uvicorn==0.27.0.post1 # via pact-python vine==5.1.0 # via