Merge branch 'master' into dkaplan1/APER-3146_investigate-fix-exception-handling-in-program-cert-revocation

This commit is contained in:
Deborah Kaplan
2024-01-30 10:56:16 -05:00
committed by GitHub
31 changed files with 1201 additions and 107 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
# Generated by Django 3.2.23 on 2024-01-25 21:56
from django.db import migrations
from lms.djangoapps.certificates.data import CertificateStatuses
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=CertificateStatuses.unavailable).update(
status=CertificateStatuses.unavailable
)
operations = [
migrations.RunPython(
make_invalid_certificates_unavailable,
reverse_code=migrations.RunPython.noop,
)
]

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"""
Discussion notifications sender util.
"""
import logging
import re
from django.conf import settings
from lms.djangoapps.discussion.django_comment_client.permissions import get_team
@@ -22,9 +22,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.
@@ -75,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,
@@ -206,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
@@ -262,15 +263,52 @@ 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)
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)

View File

@@ -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', '<p>Thread body</p>', 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', '<p>Thread body</p>', 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', '<p>Thread body</p>', 0)
mock_create_audience.return_value = {}
self.notification_sender.send_reported_content_notification()
self._assert_send_notification_called_with(mock_send_notification, 'thread')

View File

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

View File

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

View File

@@ -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
@@ -2238,6 +2244,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 +5050,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 #################

View File

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

View File

@@ -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': _('<p><strong>{username}s </strong> {content_type} has been reported <strong> {'
'content}</strong></p>'),
'content_context': {
'post_title': 'Post title',
'author_name': 'author name',
'replier_name': 'replier name',
},
'email_template': '',
},
}
COURSE_NOTIFICATION_APPS = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -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
@@ -475,7 +475,7 @@ edx-drf-extensions==10.0.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.10
edx-enterprise==4.11.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -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
@@ -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

View File

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

View File

@@ -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
@@ -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
@@ -755,7 +755,7 @@ edx-drf-extensions==10.0.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.10
edx-enterprise==4.11.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.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
@@ -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
@@ -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

View File

@@ -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
@@ -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
@@ -553,7 +553,7 @@ edx-drf-extensions==10.0.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.10
edx-enterprise==4.11.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.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
@@ -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

View File

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

View File

@@ -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
@@ -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
@@ -579,7 +579,7 @@ edx-drf-extensions==10.0.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.10
edx-enterprise==4.11.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -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
@@ -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
@@ -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