diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml
index a658064f09f0..0f369db7d293 100644
--- a/.github/workflows/add-remove-label-on-comment.yml
+++ b/.github/workflows/add-remove-label-on-comment.yml
@@ -17,3 +17,4 @@ on:
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
+
diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml
index 3feb3c0aadae..eeb53c24ed98 100644
--- a/.github/workflows/pylint-checks.yml
+++ b/.github/workflows/pylint-checks.yml
@@ -18,7 +18,7 @@ jobs:
- module-name: lms-2
path: "--django-settings-module=lms.envs.test lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py"
- module-name: openedx-1
- path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
+ path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
- module-name: openedx-2
path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/"
- module-name: common
diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json
index 8b6c1694da28..4ab126cb4715 100644
--- a/.github/workflows/unit-test-shards.json
+++ b/.github/workflows/unit-test-shards.json
@@ -106,7 +106,6 @@
"openedx/core/djangoapps/course_live/",
"openedx/core/djangoapps/dark_lang/",
"openedx/core/djangoapps/debug/",
- "openedx/core/djangoapps/demographics/",
"openedx/core/djangoapps/discussions/",
"openedx/core/djangoapps/django_comment_common/",
"openedx/core/djangoapps/embargo/",
@@ -187,7 +186,6 @@
"openedx/core/djangoapps/credit/",
"openedx/core/djangoapps/dark_lang/",
"openedx/core/djangoapps/debug/",
- "openedx/core/djangoapps/demographics/",
"openedx/core/djangoapps/discussions/",
"openedx/core/djangoapps/django_comment_common/",
"openedx/core/djangoapps/embargo/",
diff --git a/Makefile b/Makefile
index 098236fed8cb..15bab5df67a9 100644
--- a/Makefile
+++ b/Makefile
@@ -50,7 +50,7 @@ pull_xblock_translations: ## pull xblock translations via atlas
clean_translations: ## Remove existing translations to prepare for a fresh pull
# Removes core edx-platform translations but keeps config files and Esperanto (eo) test translations
- find conf/locale -mindepth 1 -maxdepth 1 -type d -a ! -name eo -exec rm -rf {} +
+ find conf/locale/ -type f \! -path '*/eo/*' \( -name '*.mo' -o -name '*.po' \) -delete
# Removes the xblocks/plugins and js-compiled translations
rm -rf conf/plugins-locale cms/static/js/i18n/ lms/static/js/i18n/ cms/static/js/xblock.v1-i18n/ lms/static/js/xblock.v1-i18n/
@@ -137,6 +137,8 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *
mv requirements/common_constraints.tmp requirements/common_constraints.txt
sed 's/Django<4.0//g' requirements/common_constraints.txt > requirements/common_constraints.tmp
mv requirements/common_constraints.tmp requirements/common_constraints.txt
+ sed 's/event-tracking<2.4.1//g' requirements/common_constraints.txt > requirements/common_constraints.tmp
+ mv requirements/common_constraints.tmp requirements/common_constraints.txt
pip-compile -v --allow-unsafe ${COMPILE_OPTS} -o requirements/pip.txt requirements/pip.in
pip install -r requirements/pip.txt
diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py
index 49dbab571539..f84290ba83ae 100644
--- a/cms/djangoapps/contentstore/config/waffle.py
+++ b/cms/djangoapps/contentstore/config/waffle.py
@@ -53,16 +53,3 @@
# .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work.
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_RELATIVE_DATES = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.custom_relative_dates', __name__)
-
-
-# .. toggle_name: studio.enable_course_update_notifications
-# .. toggle_implementation: CourseWaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Waffle flag to enable course update notifications.
-# .. toggle_use_cases: temporary, open_edx
-# .. toggle_creation_date: 14-Feb-2024
-# .. toggle_target_removal_date: 14-Mar-2024
-ENABLE_COURSE_UPDATE_NOTIFICATIONS = CourseWaffleFlag(
- f'{WAFFLE_NAMESPACE}.enable_course_update_notifications',
- __name__
-)
diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py
index 77a6a00c4b58..e8a359d80564 100644
--- a/cms/djangoapps/contentstore/course_info_model.py
+++ b/cms/djangoapps/contentstore/course_info_model.py
@@ -19,7 +19,6 @@
from django.http import HttpResponseBadRequest
from django.utils.translation import gettext as _
-from cms.djangoapps.contentstore.config.waffle import ENABLE_COURSE_UPDATE_NOTIFICATIONS
from cms.djangoapps.contentstore.utils import track_course_update_event, send_course_update_notification
from openedx.core.lib.xblock_utils import get_course_update_items
from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order
@@ -93,10 +92,9 @@ def update_course_updates(location, update, passed_id=None, user=None, request_m
track_course_update_event(location.course_key, user, course_update_dict)
# send course update notification
- if ENABLE_COURSE_UPDATE_NOTIFICATIONS.is_enabled(location.course_key):
- send_course_update_notification(
- location.course_key, course_update_dict["content"], user,
- )
+ send_course_update_notification(
+ location.course_key, course_update_dict["content"], user,
+ )
# remove status key
if "status" in course_update_dict:
diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py
index c55a0a8a2238..7c3a369fed62 100644
--- a/cms/djangoapps/contentstore/toggles.py
+++ b/cms/djangoapps/contentstore/toggles.py
@@ -159,6 +159,18 @@ def use_new_problem_editor():
return ENABLE_NEW_PROBLEM_EDITOR_FLAG.is_enabled()
+# .. toggle_name: new_core_editors.use_advanced_problem_editor
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: This flag enables the use of the new core problem xblock advanced editor as the default
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2024-07-25
+# .. toggle_target_removal_date: 2024-08-31
+# .. toggle_tickets: TNL-11694
+# .. toggle_warning:
+ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG = WaffleFlag('new_core_editors.use_advanced_problem_editor', __name__)
+
+
# .. toggle_name: new_editors.add_game_block_button
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 9ed3f1ce4b36..631ceeb270b6 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -1670,7 +1670,7 @@ def get_home_context(request, no_course=False):
LIBRARY_AUTHORING_MICROFRONTEND_URL,
LIBRARIES_ENABLED,
should_redirect_to_library_authoring_mfe,
- user_can_create_library,
+ user_can_view_create_library_button,
)
active_courses = []
@@ -1699,7 +1699,8 @@ def get_home_context(request, no_course=False):
'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL,
'taxonomy_list_mfe_url': get_taxonomy_list_url(),
'libraries': libraries,
- 'show_new_library_button': user_can_create_library(user) and not should_redirect_to_library_authoring_mfe(),
+ 'show_new_library_button': user_can_view_create_library_button(user)
+ and not should_redirect_to_library_authoring_mfe(),
'user': user,
'request_course_creator_url': reverse('request_course_creator'),
'course_creator_status': _get_course_creator_status(user),
@@ -2254,7 +2255,7 @@ def send_course_update_notification(course_key, content, user):
"course_update_content": text_content if len(text_content.strip()) < 10 else "Click here to view",
**extra_context,
},
- notification_type="course_update",
+ notification_type="course_updates",
content_url=f"{settings.LMS_ROOT_URL}/courses/{str(course_key)}/course/updates",
app_name="updates",
audience_filters={},
diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py
index d52cc5eecfd4..4d6c17838e57 100644
--- a/cms/djangoapps/contentstore/views/block.py
+++ b/cms/djangoapps/contentstore/views/block.py
@@ -13,6 +13,7 @@
from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment
+from cms.djangoapps.contentstore.utils import load_services_for_studio
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.student.auth import (
@@ -47,7 +48,6 @@
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
handle_xblock,
create_xblock_info,
- load_services_for_studio,
get_block_info,
get_xblock,
delete_orphans,
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 9f6cfb7c430e..4647e4fdcca7 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -58,6 +58,7 @@
from common.djangoapps.util.string_utils import _has_non_ascii_characters
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
+from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangolib.js_utils import dump_js_escaped_json
@@ -302,6 +303,10 @@ def course_handler(request, course_key_string=None):
else:
return HttpResponseBadRequest()
elif request.method == 'GET': # assume html
+ # Update course discussion settings, sometimes the course discussion settings are not updated
+ # when the course is created, so we need to update them here.
+ course_key = CourseKey.from_string(course_key_string)
+ update_discussions_settings_from_course(course_key)
if course_key_string is None:
return redirect(reverse('home'))
else:
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
index 17aa24c5712a..870c192653d2 100644
--- a/cms/djangoapps/contentstore/views/library.py
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -10,7 +10,7 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
-from django.http import Http404, HttpResponseForbidden, HttpResponseNotAllowed
+from django.http import Http404, HttpResponseNotAllowed
from django.utils.translation import gettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_http_methods
@@ -69,12 +69,10 @@ def should_redirect_to_library_authoring_mfe():
)
-def user_can_create_library(user, org=None):
+def user_can_view_create_library_button(user):
"""
- Helper method for returning the library creation status for a particular user,
- taking into account the value LIBRARIES_ENABLED.
+ Helper method for displaying the visibilty of the create_library_button.
"""
-
if not LIBRARIES_ENABLED:
return False
elif user.is_staff:
@@ -84,8 +82,56 @@ def user_can_create_library(user, org=None):
has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists()
has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists()
has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists()
+ return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
+ else:
+ # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
+ disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
+ disable_course_creation = settings.FEATURES.get('DISABLE_COURSE_CREATION', False)
+ if disable_library_creation is not None:
+ return not disable_library_creation
+ else:
+ return not disable_course_creation
+
+def user_can_create_library(user, org):
+ """
+ Helper method for returning the library creation status for a particular user,
+ taking into account the value LIBRARIES_ENABLED.
+
+ if the ENABLE_CREATOR_GROUP value is False, then any user can create a library (in any org),
+ if library creation is enabled.
+
+ if the ENABLE_CREATOR_GROUP value is true, then what a user can do varies by thier role.
+
+ Global Staff: can make libraries in any org.
+ Course Creator Group Members: can make libraries in any org.
+ Organization Staff: Can make libraries in the organization for which they are staff.
+ Course Staff: Can make libraries in the organization which has courses of which they are staff.
+ Course Admin: Can make libraries in the organization which has courses of which they are Admin.
+ """
+ if org is None:
+ return False
+ if not LIBRARIES_ENABLED:
+ return False
+ elif user.is_staff:
+ return True
+ if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ is_course_creator = get_course_creator_status(user) == 'granted'
+ has_org_staff_role = org in OrgStaffRole().get_orgs_for_user(user)
+ has_course_staff_role = (
+ UserBasedRole(user=user, role=CourseStaffRole.ROLE)
+ .courses_with_role()
+ .filter(org=org)
+ .exists()
+ )
+ has_course_admin_role = (
+ UserBasedRole(user=user, role=CourseInstructorRole.ROLE)
+ .courses_with_role()
+ .filter(org=org)
+ .exists()
+ )
return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role
+
else:
# EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present.
disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None)
@@ -108,12 +154,8 @@ def library_handler(request, library_key_string=None):
raise Http404 # Should never happen because we test the feature in urls.py also
if request.method == 'POST':
- if not user_can_create_library(request.user):
- return HttpResponseForbidden()
-
if library_key_string is not None:
return HttpResponseNotAllowed(("POST",))
-
return _create_library(request)
else:
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index 9c9926a5b25d..acc5fc95dfe3 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -45,7 +45,7 @@
wrap_xblock_aside
)
-from ..utils import get_visibility_partition_info, StudioPermissionsService
+from ..utils import StudioPermissionsService, get_visibility_partition_info
from .access import get_user_role
from .session_kv_store import SessionKeyValueStore
diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py
index 2e8f60c01150..fc119c7edd58 100644
--- a/cms/djangoapps/contentstore/views/tests/test_block.py
+++ b/cms/djangoapps/contentstore/views/tests/test_block.py
@@ -521,6 +521,7 @@ def test_ancestor_info(self, field_type):
problem1 = self.create_xblock(
parent_usage_key=vert_usage_key, display_name="problem1", category="problem"
)
+ print(problem1)
problem_usage_key = self.response_usage_key(problem1)
def assert_xblock_info(xblock, xblock_info):
@@ -556,7 +557,11 @@ def assert_xblock_info(xblock, xblock_info):
xblock = parent_xblock
else:
self.assertNotIn("ancestors", response)
- self.assertEqual(get_block_info(xblock), response)
+ xblock_info = get_block_info(xblock)
+ # TODO: remove after beta testing for the new problem editor parser
+ if xblock_info["category"] == "problem":
+ xblock_info["metadata"]["default_to_advanced"] = False
+ self.assertEqual(xblock_info, response)
@ddt.ddt
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index c3dcfe5305b7..30e02214a1a8 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -717,8 +717,8 @@ def test_number_of_calls_to_db(self):
"""
Test to check number of queries made to mysql and mongo
"""
- with self.assertNumQueries(29, table_ignorelist=WAFFLE_TABLES):
- with check_mongo_calls(3):
+ with self.assertNumQueries(32, table_ignorelist=WAFFLE_TABLES):
+ with check_mongo_calls(5):
self.client.get_html(reverse_course_url('course_handler', self.course.id))
diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py
index f6b7a48a68e1..fa6505419725 100644
--- a/cms/djangoapps/contentstore/views/tests/test_library.py
+++ b/cms/djangoapps/contentstore/views/tests/test_library.py
@@ -59,55 +59,66 @@ def setUp(self):
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
def test_library_creator_status_libraries_not_enabled(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
- self.assertEqual(user_can_create_library(nostaff_user), False)
+ self.assertEqual(user_can_create_library(nostaff_user, None), False)
# When creator group is disabled, non-staff users can create libraries
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_with_no_course_creator_role(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
- self.assertEqual(user_can_create_library(nostaff_user), True)
+ self.assertEqual(user_can_create_library(nostaff_user, 'An Org'), True)
# When creator group is enabled, Non staff users cannot create libraries
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_for_enabled_creator_group_setting_for_non_staff_users(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
- self.assertEqual(user_can_create_library(nostaff_user), False)
+ self.assertEqual(user_can_create_library(nostaff_user, None), False)
- # Global staff can create libraries
+ # Global staff can create libraries for any org, even ones that don't exist.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_with_is_staff_user(self):
- self.assertEqual(user_can_create_library(self.user), True)
+ print(self.user.is_staff)
+ self.assertEqual(user_can_create_library(self.user, 'aNyOrg'), True)
- # When creator groups are enabled, global staff can create libraries
+ # Global staff can create libraries for any org, but an org has to be supplied.
+ @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
+ def test_library_creator_status_with_is_staff_user_no_org(self):
+ print(self.user.is_staff)
+ self.assertEqual(user_can_create_library(self.user, None), False)
+
+ # When creator groups are enabled, global staff can create libraries in any org
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_for_enabled_creator_group_setting_with_is_staff_user(self):
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
- self.assertEqual(user_can_create_library(self.user), True)
+ self.assertEqual(user_can_create_library(self.user, 'RandomOrg'), True)
- # When creator groups are enabled, course creators can create libraries
+ # When creator groups are enabled, course creators can create libraries in any org.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_with_course_creator_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
grant_course_creator_status(self.user, nostaff_user)
- self.assertEqual(user_can_create_library(nostaff_user), True)
+ self.assertEqual(user_can_create_library(nostaff_user, 'soMeRandOmoRg'), True)
# When creator groups are enabled, course staff members can create libraries
+ # but only in the org they are course staff for.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_with_course_staff_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
auth.add_users(self.user, CourseStaffRole(self.course.id), nostaff_user)
- self.assertEqual(user_can_create_library(nostaff_user), True)
+ self.assertEqual(user_can_create_library(nostaff_user, self.course.org), True)
+ self.assertEqual(user_can_create_library(nostaff_user, 'SomEOtherOrg'), False)
# When creator groups are enabled, course instructor members can create libraries
+ # but only in the org they are course staff for.
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
def test_library_creator_status_with_course_instructor_role_for_enabled_creator_group_setting(self):
_, nostaff_user = self.create_non_staff_authed_user_client()
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
auth.add_users(self.user, CourseInstructorRole(self.course.id), nostaff_user)
- self.assertEqual(user_can_create_library(nostaff_user), True)
+ self.assertEqual(user_can_create_library(nostaff_user, self.course.org), True)
+ self.assertEqual(user_can_create_library(nostaff_user, 'SomEOtherOrg'), False)
@ddt.data(
(False, False, True),
@@ -131,7 +142,7 @@ def test_library_creator_status_settings(self, disable_course, disable_library,
"DISABLE_LIBRARY_CREATION": disable_library
}
):
- self.assertEqual(user_can_create_library(nostaff_user), expected_status)
+ self.assertEqual(user_can_create_library(nostaff_user, 'SomEOrg'), expected_status)
@mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True})
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
@@ -140,7 +151,7 @@ def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaf
Ensure that `DISABLE_COURSE_CREATION` feature works with libraries as well.
"""
nostaff_client, nostaff_user = self.create_non_staff_authed_user_client()
- self.assertFalse(user_can_create_library(nostaff_user))
+ self.assertFalse(user_can_create_library(nostaff_user, 'SomEOrg'))
# To be explicit, this user can GET, but not POST
get_response = nostaff_client.get_json(LIBRARY_REST_URL)
@@ -251,7 +262,7 @@ def test_lib_create_permission_course_staff_role(self):
auth.add_users(self.user, CourseStaffRole(self.course.id), ns_user)
self.assertTrue(auth.user_has_role(ns_user, CourseStaffRole(self.course.id)))
response = self.client.ajax_post(LIBRARY_REST_URL, {
- 'org': 'org', 'library': 'lib', 'display_name': "New Library",
+ 'org': self.course.org, 'library': 'lib', 'display_name': "New Library",
})
self.assertEqual(response.status_code, 200)
diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
index 6959e22b94dc..e7dbec01f8e0 100644
--- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
+++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
@@ -33,6 +33,7 @@
from xblock.fields import Scope
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
+from cms.djangoapps.contentstore.toggles import ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.lib.ai_aside_summary_config import AiAsideSummaryConfig
from common.djangoapps.static_replace import replace_static_urls
@@ -184,6 +185,11 @@ def handle_xblock(request, usage_key_string=None):
# TODO: pass fields to get_block_info and only return those
with modulestore().bulk_operations(usage_key.course_key):
response = get_block_info(get_xblock(usage_key, request.user))
+ # TODO: remove after beta testing for the new problem editor parser
+ if response["category"] == "problem":
+ response["metadata"]["default_to_advanced"] = (
+ ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG.is_enabled()
+ )
if "customReadToken" in fields:
parent_children = _get_block_parent_children(get_xblock(usage_key, request.user))
response.update(parent_children)
@@ -305,13 +311,10 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
old_metadata = own_metadata(xblock)
if old_content is None:
old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
- if hasattr(xblock, "editor_saved"):
- load_services_for_studio(xblock.runtime, user)
- xblock.editor_saved(user, old_metadata, old_content)
+ load_services_for_studio(xblock.runtime, user)
+ xblock.editor_saved(user, old_metadata, old_content)
xblock_updated = modulestore().update_item(xblock, user.id)
- if hasattr(xblock_updated, "post_editor_saved"):
- load_services_for_studio(xblock_updated.runtime, user)
- xblock_updated.post_editor_saved(user, old_metadata, old_content)
+ xblock_updated.post_editor_saved(user, old_metadata, old_content)
return xblock_updated
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 895e4c0ed7e9..be837c518981 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -2688,7 +2688,7 @@
############## NOTIFICATIONS EXPIRY ##############
NOTIFICATIONS_EXPIRY = 60
EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000
-NOTIFICATION_CREATION_BATCH_SIZE = 83
+NOTIFICATION_CREATION_BATCH_SIZE = 76
############################ AI_TRANSLATIONS ##################################
AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1'
diff --git a/cms/lib/xblock/authoring_mixin.py b/cms/lib/xblock/authoring_mixin.py
index a3d3b3298cea..b9057391b18b 100644
--- a/cms/lib/xblock/authoring_mixin.py
+++ b/cms/lib/xblock/authoring_mixin.py
@@ -10,6 +10,7 @@
from xblock.core import XBlock, XBlockMixin
from xblock.fields import String, Scope
+
log = logging.getLogger(__name__)
VISIBILITY_VIEW = 'visibility_view'
@@ -21,6 +22,7 @@ class AuthoringMixin(XBlockMixin):
"""
Mixin class that provides authoring capabilities for XBlocks.
"""
+
def _get_studio_resource_url(self, relative_url):
"""
Returns the Studio URL to a static resource.
@@ -51,3 +53,17 @@ def visibility_view(self, _context=None):
scope=Scope.settings,
enforce_type=True,
)
+
+ def editor_saved(self, user, old_metadata, old_content) -> None: # pylint: disable=unused-argument
+ """
+ Called right *before* the block is written to the DB. Can be used, e.g., to modify fields before saving.
+
+ By default, is a no-op. Can be overriden in subclasses.
+ """
+
+ def post_editor_saved(self, user, old_metadata, old_content) -> None: # pylint: disable=unused-argument
+ """
+ Called right *after* the block is written to the DB. Can be used, e.g., to spin up followup tasks.
+
+ By default, is a no-op. Can be overriden in subclasses.
+ """
diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py
index f66e71a0740f..b06cac7b7e50 100644
--- a/common/djangoapps/student/views/management.py
+++ b/common/djangoapps/student/views/management.py
@@ -62,6 +62,7 @@
)
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
+from openedx.features.discounts.applicability import FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG
from openedx.features.enterprise_support.utils import is_enterprise_learner
from common.djangoapps.student.email_helpers import generate_activation_email_context
from common.djangoapps.student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info
@@ -206,12 +207,13 @@ def compose_activation_email(
message_context = generate_activation_email_context(user, user_registration)
message_context.update({
'confirm_activation_link': _get_activation_confirmation_link(message_context['key'], redirect_url),
+ 'is_enterprise_learner': is_enterprise_learner(user),
+ 'is_first_purchase_discount_overridden': FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG.is_enabled(),
'route_enabled': route_enabled,
'routed_user': user.username,
'routed_user_email': user.email,
'routed_profile_name': profile_name,
'registration_flow': registration_flow,
- 'is_enterprise_learner': is_enterprise_learner(user),
'show_auto_generated_username': show_auto_generated_username(user.username),
})
diff --git a/common/djangoapps/util/file.py b/common/djangoapps/util/file.py
index 66fc2a6f5f35..b2892e6f42c9 100644
--- a/common/djangoapps/util/file.py
+++ b/common/djangoapps/util/file.py
@@ -13,6 +13,7 @@
from django.utils.translation import gettext as _
from django.utils.translation import ngettext
from pytz import UTC
+from storages.backends.s3boto3 import S3Boto3Storage
class FileValidationException(Exception):
@@ -23,7 +24,7 @@ class FileValidationException(Exception):
def store_uploaded_file(
- request, file_key, allowed_file_types, base_storage_filename, max_file_size, validator=None,
+ request, file_key, allowed_file_types, base_storage_filename, max_file_size, validator=None, is_private=False,
):
"""
Stores an uploaded file to django file storage.
@@ -45,6 +46,8 @@ def store_uploaded_file(
a `FileValidationException` if the file is not properly formatted. If any exception is thrown, the stored
file will be deleted before the exception is re-raised. Note that the implementor of the validator function
should take care to close the stored file if they open it for reading.
+ is_private (Boolean): an optional boolean which if True and the storage backend is S3,
+ sets the ACL for the file object to be private.
Returns:
Storage: the file storage object where the file can be retrieved from
@@ -75,6 +78,12 @@ def store_uploaded_file(
file_storage = DefaultStorage()
# If a file already exists with the supplied name, file_storage will make the filename unique.
stored_file_name = file_storage.save(stored_file_name, uploaded_file)
+ if is_private and settings.DEFAULT_FILE_STORAGE == 'storages.backends.s3boto3.S3Boto3Storage':
+ S3Boto3Storage().connection.meta.client.put_object_acl(
+ ACL='private',
+ Bucket=settings.AWS_STORAGE_BUCKET_NAME,
+ Key=stored_file_name,
+ )
if validator:
try:
diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb
index 5eae81b159e2..b4dcff0b4ab8 100644
Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ
diff --git a/lms/djangoapps/bulk_email/apps.py b/lms/djangoapps/bulk_email/apps.py
index 2cfb725ba85e..63a44fcfcde4 100644
--- a/lms/djangoapps/bulk_email/apps.py
+++ b/lms/djangoapps/bulk_email/apps.py
@@ -7,3 +7,7 @@ class BulkEmailConfig(AppConfig):
Application Configuration for bulk_email.
"""
name = 'lms.djangoapps.bulk_email'
+
+ def ready(self):
+ import lms.djangoapps.bulk_email.signals # lint-amnesty, pylint: disable=unused-import
+ from edx_ace.signals import ACE_MESSAGE_SENT # lint-amnesty, pylint: disable=unused-import
diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py
index 818d222b7a34..d45d0ae017bd 100644
--- a/lms/djangoapps/bulk_email/signals.py
+++ b/lms/djangoapps/bulk_email/signals.py
@@ -1,12 +1,13 @@
"""
Signal handlers for the bulk_email app
"""
-
-
+from django.contrib.auth import get_user_model
from django.dispatch import receiver
+from eventtracking import tracker
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS
+from edx_ace.signals import ACE_MESSAGE_SENT
from .models import Optout
@@ -24,3 +25,28 @@ def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused-
for enrollment in CourseEnrollment.objects.filter(user=user):
Optout.objects.get_or_create(user=user, course_id=enrollment.course.id)
+
+
+@receiver(ACE_MESSAGE_SENT)
+def ace_email_sent_handler(sender, **kwargs):
+ """
+ When an email is sent using ACE, this method will create an event to detect ace email success status
+ """
+ # Fetch the message object from kwargs, defaulting to None if not present
+ message = kwargs.get('message', None)
+
+ user_model = get_user_model()
+ try:
+ user_id = user_model.objects.get(email=message.recipient.email_address).id
+ except user_model.DoesNotExist:
+ user_id = None
+ course_email = message.context.get('course_email', None)
+ course_id = course_email.course_id if course_email else None
+ tracker.emit(
+ 'edx.bulk_email.sent',
+ {
+ 'message_type': message.name,
+ 'course_id': course_id,
+ 'user_id': user_id,
+ }
+ )
diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py
index afad888fe0c5..60d45c15642d 100644
--- a/lms/djangoapps/bulk_email/tasks.py
+++ b/lms/djangoapps/bulk_email/tasks.py
@@ -26,6 +26,7 @@
from django.utils.translation import gettext as _
from django.utils.translation import override as override_language
from edx_django_utils.monitoring import set_code_owner_attribute
+from eventtracking import tracker
from markupsafe import escape
from common.djangoapps.util.date_utils import get_default_time_display
@@ -467,7 +468,14 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas
"send."
)
raise exc
-
+ tracker.emit(
+ 'edx.bulk_email.created',
+ {
+ 'course_id': str(course_email.course_id),
+ 'to_list': to_list,
+ 'total_recipients': total_recipients,
+ }
+ )
# Exclude optouts (if not a retry):
# Note that we don't have to do the optout logic at all if this is a retry,
# because we have presumably already performed the optout logic on the first
diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py
index 528baf97b53c..927699091558 100644
--- a/lms/djangoapps/bulk_email/views.py
+++ b/lms/djangoapps/bulk_email/views.py
@@ -7,6 +7,7 @@
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.http import Http404
+from eventtracking import tracker
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
@@ -60,4 +61,12 @@ def opt_out_email_updates(request, token, course_id):
course_id,
)
+ tracker.emit(
+ 'edx.bulk_email.opt_out',
+ {
+ 'course_id': course_id,
+ 'user_id': user.id,
+ }
+ )
+
return render_to_response('bulk_email/unsubscribe_success.html', context)
diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py
index 4f40d9a80192..76928846f080 100644
--- a/lms/djangoapps/course_home_api/outline/tests/test_view.py
+++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py
@@ -11,6 +11,7 @@
import json # lint-amnesty, pylint: disable=wrong-import-order
from completion.models import BlockCompletion
from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order
+from django.test import override_settings
from django.urls import reverse # lint-amnesty, pylint: disable=wrong-import-order
from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, pylint: disable=wrong-import-order
@@ -33,7 +34,10 @@
DISPLAY_COURSE_SOCK_FLAG,
ENABLE_COURSE_GOALS
)
-from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG
+from openedx.features.discounts.applicability import (
+ DISCOUNT_APPLICABILITY_FLAG,
+ FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG
+)
from xmodule.course_block import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
@@ -179,17 +183,32 @@ def test_welcome_message(self, welcome_message_is_dismissed):
welcome_message_html = self.client.get(self.url).data['welcome_message_html']
assert welcome_message_html == (None if welcome_message_is_dismissed else '
Welcome
')
- def test_offer(self):
+ @ddt.data(
+ (False, 'EDXWELCOME', 15),
+ (True, 'NOTEDXWELCOME', 30),
+ )
+ @ddt.unpack
+ def test_offer(self, is_fpd_override_waffle_flag_on, fpd_code, fpd_percentage):
+ """
+ Test that the offer data contains the correct code for the first purchase discount,
+ which can be overriden via a waffle flag from the default EDXWELCOME.
+ """
CourseEnrollment.enroll(self.user, self.course.id)
response = self.client.get(self.url)
assert response.data['offer'] is None
- with override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True):
- response = self.client.get(self.url)
-
- # Just a quick spot check that the dictionary looks like what we expect
- assert response.data['offer']['code'] == 'EDXWELCOME'
+ with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE='NOTEDXWELCOME'):
+ with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE=fpd_percentage):
+ with override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True):
+ with override_waffle_flag(
+ FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG, active=is_fpd_override_waffle_flag_on
+ ):
+ response = self.client.get(self.url)
+
+ # Just a quick spot check that the dictionary looks like what we expect
+ assert response.data['offer']['code'] == fpd_code
+ assert response.data['offer']['percentage'] == fpd_percentage
def test_access_expiration(self):
enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
index f72271f3a60c..7249d086cca6 100644
--- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
@@ -118,9 +118,10 @@ def send_new_comment_notification(self):
self.parent_response and
self.creator.id != int(self.thread.user_id)
):
+ author_name = f"{self.parent_response.username}'s"
# use your if author of response is same as author of post.
# use 'their' if comment author is also response author.
- author_name = (
+ author_pronoun = (
# Translators: Replier commented on "your" response to your post
_("your")
if self._response_and_thread_has_same_creator()
@@ -129,10 +130,12 @@ def send_new_comment_notification(self):
_("their")
if self._response_and_comment_has_same_creator()
else f"{self.parent_response.username}'s"
+
)
)
context = {
"author_name": str(author_name),
+ "author_pronoun": str(author_pronoun),
}
self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
@@ -189,10 +192,21 @@ def send_response_on_followed_post_notification(self):
if not self.parent_id:
self._send_notification(users, "response_on_followed_post")
else:
+ author_name = f"{self.parent_response.username}'s"
+ # use 'their' if comment author is also response author.
+ author_pronoun = (
+ # Translators: Replier commented on "their" response in a post you're following
+ _("their")
+ if self._response_and_comment_has_same_creator()
+ else f"{self.parent_response.username}'s"
+ )
self._send_notification(
users,
"comment_on_followed_post",
- extra_context={"author_name": self.parent_response.username}
+ extra_context={
+ "author_name": str(author_name),
+ "author_pronoun": str(author_pronoun),
+ }
)
def _create_cohort_course_audience(self):
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
index f3f2a68ae668..28cfe3395cb6 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
@@ -338,6 +338,7 @@ def test_send_notification_to_parent_threads(self):
'replier_name': self.user_3.username,
'post_title': self.thread.title,
'author_name': 'dummy\'s',
+ 'author_pronoun': 'dummy\'s',
'course_name': self.course.display_name,
'sender_id': self.user_3.id
}
@@ -399,7 +400,8 @@ def test_comment_creators_own_response(self):
expected_context = {
'replier_name': self.user_3.username,
'post_title': self.thread.title,
- 'author_name': 'your',
+ 'author_name': 'dummy\'s',
+ 'author_pronoun': 'your',
'course_name': self.course.display_name,
'sender_id': self.user_3.id,
}
@@ -441,7 +443,8 @@ def test_send_notification_to_followers(self, parent_id, notification_type):
'sender_id': self.user_2.id,
}
if parent_id:
- expected_context['author_name'] = 'dummy'
+ expected_context['author_name'] = 'dummy\'s'
+ expected_context['author_pronoun'] = 'dummy\'s'
self.assertDictEqual(args.context, expected_context)
self.assertEqual(
args.content_url,
@@ -531,7 +534,8 @@ def test_new_comment_notification(self):
send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id)
handler.assert_called_once()
context = handler.call_args[1]['notification_data'].context
- self.assertEqual(context['author_name'], 'their')
+ self.assertEqual(context['author_name'], 'dummy\'s')
+ self.assertEqual(context['author_pronoun'], 'their')
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
diff --git a/lms/djangoapps/grades/grade_utils.py b/lms/djangoapps/grades/grade_utils.py
index 0344cf6c20d1..05d2058f37ba 100644
--- a/lms/djangoapps/grades/grade_utils.py
+++ b/lms/djangoapps/grades/grade_utils.py
@@ -7,6 +7,7 @@
from datetime import timedelta
from django.utils import timezone
+from django.conf import settings
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
@@ -22,7 +23,7 @@ def are_grades_frozen(course_key):
if ENFORCE_FREEZE_GRADE_AFTER_COURSE_END.is_enabled(course_key):
course = CourseOverview.get_from_id(course_key)
if course.end:
- freeze_grade_date = course.end + timedelta(30)
+ freeze_grade_date = course.end + timedelta(settings.GRADEBOOK_FREEZE_DAYS)
now = timezone.now()
return now > freeze_grade_date
return False
diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py
index 26239e8ecc85..2bf635ce9c64 100644
--- a/lms/djangoapps/instructor/permissions.py
+++ b/lms/djangoapps/instructor/permissions.py
@@ -86,3 +86,4 @@ def has_permission(self, request, view):
course = get_course_by_id(CourseKey.from_string(view.kwargs.get('course_id')))
permission = getattr(view, 'permission_name', None)
return request.user.has_perm(permission, course)
+
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index f5d8b0408950..2548cc4f411d 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -2945,7 +2945,37 @@ def test_get_student_progress_url(self):
response = self.client.post(url, data)
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
- assert 'progress_url' in res_json
+ expected_data = {
+ 'course_id': str(self.course.id),
+ 'progress_url': f'/courses/{self.course.id}/progress/{self.students[0].id}/'
+ }
+
+ for key, value in expected_data.items():
+ self.assertIn(key, res_json)
+ self.assertEqual(res_json[key], value)
+
+ def test_get_student_progress_url_response_headers(self):
+ """
+ Test that the progress_url endpoint returns the correct headers.
+ """
+ url = reverse('get_student_progress_url', kwargs={'course_id': str(self.course.id)})
+ data = {'unique_student_identifier': self.students[0].email}
+ response = self.client.post(url, data)
+ assert response.status_code == 200
+
+ expected_headers = {
+ 'Allow': 'POST, OPTIONS', # drf view brings this key.
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ 'Content-Language': 'en',
+ 'Content-Length': str(len(response.content.decode('utf-8'))),
+ 'Content-Type': 'application/json',
+ 'Vary': 'Cookie, Accept-Language, origin',
+ 'X-Frame-Options': 'DENY'
+ }
+
+ for key, value in expected_headers.items():
+ self.assertIn(key, response.headers)
+ self.assertEqual(response.headers[key], value)
def test_get_student_progress_url_from_uname(self):
""" Test that progress_url is in the successful response. """
@@ -2955,6 +2985,14 @@ def test_get_student_progress_url_from_uname(self):
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
assert 'progress_url' in res_json
+ expected_data = {
+ 'course_id': str(self.course.id),
+ 'progress_url': f'/courses/{self.course.id}/progress/{self.students[0].id}/'
+ }
+
+ for key, value in expected_data.items():
+ self.assertIn(key, res_json)
+ self.assertEqual(res_json[key], value)
def test_get_student_progress_url_noparams(self):
""" Test that the endpoint 404's without the required query params. """
@@ -2968,6 +3006,17 @@ def test_get_student_progress_url_nostudent(self):
response = self.client.post(url)
assert response.status_code == 400
+ def test_get_student_progress_url_without_permissions(self):
+ """ Test that progress_url returns 403 without credentials. """
+
+ # removed both roles from courses for instructor
+ CourseDataResearcherRole(self.course.id).remove_users(self.instructor)
+ CourseInstructorRole(self.course.id).remove_users(self.instructor)
+ url = reverse('get_student_progress_url', kwargs={'course_id': str(self.course.id)})
+ data = {'unique_student_identifier': self.students[0].email}
+ response = self.client.post(url, data)
+ assert response.status_code == 403
+
class TestInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py
index 22905c058617..a98633ab1fbe 100644
--- a/lms/djangoapps/instructor/views/api.py
+++ b/lms/djangoapps/instructor/views/api.py
@@ -106,6 +106,7 @@
from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
from lms.djangoapps.instructor_task.models import ReportStore
+from lms.djangoapps.instructor.views.serializer import RoleNameSerializer, UserSerializer
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
@@ -123,6 +124,7 @@
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from openedx.core.lib.courses import get_course_by_id
+from openedx.core.lib.api.serializers import CourseKeyField
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
from .tools import (
dump_block_extensions,
@@ -1065,15 +1067,11 @@ def modify_access(request, course_id):
return JsonResponse(response_payload)
-@require_POST
-@ensure_csrf_cookie
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_course_permission(permissions.EDIT_COURSE_ACCESS)
-@require_post_params(rolename="'instructor', 'staff', or 'beta'")
-def list_course_role_members(request, course_id):
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+class ListCourseRoleMembersView(APIView):
"""
- List instructors and staff.
- Requires instructor access.
+ View to list instructors and staff for a specific course.
+ Requires the user to have instructor access.
rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach']
@@ -1089,33 +1087,41 @@ def list_course_role_members(request, course_id):
]
}
"""
- course_id = CourseKey.from_string(course_id)
- course = get_course_with_access(
- request.user, 'instructor', course_id, depth=None
- )
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ permission_name = permissions.EDIT_COURSE_ACCESS
- rolename = request.POST.get('rolename')
+ @method_decorator(ensure_csrf_cookie)
+ def post(self, request, course_id):
+ """
+ Handles POST request to list instructors and staff.
- if rolename not in ROLES:
- return HttpResponseBadRequest()
+ Args:
+ request (HttpRequest): The request object containing user data.
+ course_id (str): The ID of the course to list instructors and staff for.
- def extract_user_info(user):
- """ convert user into dicts for json view """
+ Returns:
+ Response: A Response object containing the list of instructors and staff or an error message.
- return {
- 'username': user.username,
- 'email': user.email,
- 'first_name': user.first_name,
- 'last_name': user.last_name,
+ Raises:
+ Http404: If the course does not exist.
+ """
+ course_id = CourseKey.from_string(course_id)
+ course = get_course_with_access(
+ request.user, 'instructor', course_id, depth=None
+ )
+ role_serializer = RoleNameSerializer(data=request.data)
+ role_serializer.is_valid(raise_exception=True)
+ rolename = role_serializer.data['rolename']
+
+ users = list_with_level(course.id, rolename)
+ serializer = UserSerializer(users, many=True)
+
+ response_payload = {
+ 'course_id': str(course_id),
+ rolename: serializer.data,
}
- response_payload = {
- 'course_id': str(course_id),
- rolename: list(map(extract_user_info, list_with_level(
- course.id, rolename
- ))),
- }
- return JsonResponse(response_payload)
+ return Response(response_payload, status=status.HTTP_200_OK)
class ProblemResponseReportPostParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method
@@ -1605,7 +1611,8 @@ def post(self, request, course_key_string):
request, 'uploaded-file', ['.csv'],
course_and_time_based_filename_generator(course_key, 'cohorts'),
max_file_size=2000000, # limit to 2 MB
- validator=_cohorts_csv_validator
+ validator=_cohorts_csv_validator,
+ is_private=True
)
task_api.submit_cohort_students(request, course_key, file_name)
except (FileValidationException, ValueError) as e:
@@ -1719,15 +1726,35 @@ def get_student_enrollment_status(request, course_id):
return JsonResponse(response_payload)
-@require_POST
-@ensure_csrf_cookie
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_course_permission(permissions.ENROLLMENT_REPORT)
-@require_post_params(
- unique_student_identifier="email or username of student for whom to get progress url"
-)
-@common_exceptions_400
-def get_student_progress_url(request, course_id):
+class StudentProgressUrlSerializer(serializers.Serializer):
+ """Serializer for course renders"""
+ unique_student_identifier = serializers.CharField(write_only=True)
+ course_id = CourseKeyField(required=False)
+ progress_url = serializers.SerializerMethodField()
+
+ def get_progress_url(self, obj): # pylint: disable=unused-argument
+ """
+ Return the progress URL for the student.
+ Args:
+ obj (dict): The dictionary containing data for the serializer.
+ Returns:
+ str: The URL for the progress of the student in the course.
+ """
+ user = get_student_from_identifier(obj.get('unique_student_identifier'))
+ course_id = obj.get('course_id') # Adjust based on your data structure
+
+ if course_home_mfe_progress_tab_is_active(course_id):
+ progress_url = get_learning_mfe_home_url(course_id, url_fragment='progress')
+ if user is not None:
+ progress_url += '/{}/'.format(user.id)
+ else:
+ progress_url = reverse('student_progress', kwargs={'course_id': str(course_id), 'student_id': user.id})
+
+ return progress_url
+
+
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+class StudentProgressUrl(APIView):
"""
Get the progress url of a student.
Limited to staff access.
@@ -1737,21 +1764,25 @@ def get_student_progress_url(request, course_id):
'progress_url': '/../...'
}
"""
- course_id = CourseKey.from_string(course_id)
- user = get_student_from_identifier(request.POST.get('unique_student_identifier'))
-
- if course_home_mfe_progress_tab_is_active(course_id):
- progress_url = get_learning_mfe_home_url(course_id, url_fragment='progress')
- if user is not None:
- progress_url += '/{}/'.format(user.id)
- else:
- progress_url = reverse('student_progress', kwargs={'course_id': str(course_id), 'student_id': user.id})
+ authentication_classes = (
+ JwtAuthentication,
+ BearerAuthenticationAllowInactiveUser,
+ SessionAuthenticationAllowInactiveUser,
+ )
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ serializer_class = StudentProgressUrlSerializer
+ permission_name = permissions.ENROLLMENT_REPORT
- response_payload = {
- 'course_id': str(course_id),
- 'progress_url': progress_url,
- }
- return JsonResponse(response_payload)
+ @method_decorator(ensure_csrf_cookie)
+ def post(self, request, course_id):
+ """Post method for validating incoming data and generating progress URL"""
+ data = {
+ 'course_id': course_id,
+ 'unique_student_identifier': request.data.get('unique_student_identifier')
+ }
+ serializer = self.serializer_class(data=data)
+ serializer.is_valid(raise_exception=True)
+ return Response(serializer.data)
@transaction.non_atomic_requests
@@ -2342,46 +2373,51 @@ def _list_instructor_tasks(request, course_id):
return JsonResponse(response_payload)
-@require_POST
-@ensure_csrf_cookie
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-@require_course_permission(permissions.SHOW_TASKS)
-def list_entrance_exam_instructor_tasks(request, course_id):
+@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
+class ListEntranceExamInstructorTasks(APIView):
"""
List entrance exam related instructor tasks.
-
- Takes either of the following query parameters
- - unique_student_identifier is an email or username
- - all_students is a boolean
"""
- course_id = CourseKey.from_string(course_id)
- course = get_course_by_id(course_id)
- student = request.POST.get('unique_student_identifier', None)
- if student is not None:
- student = get_student_from_identifier(student)
+ permission_classes = (IsAuthenticated, permissions.InstructorPermission)
+ permission_name = permissions.SHOW_TASKS
- try:
- entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id)
- except InvalidKeyError:
- return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
- if student:
- # Specifying for a single student's entrance exam history
- tasks = task_api.get_entrance_exam_instructor_task_history(
- course_id,
- entrance_exam_key,
- student
- )
- else:
- # Specifying for all student's entrance exam history
- tasks = task_api.get_entrance_exam_instructor_task_history(
- course_id,
- entrance_exam_key
- )
+ @method_decorator(ensure_csrf_cookie)
+ def post(self, request, course_id):
+ """
+ List entrance exam related instructor tasks.
- response_payload = {
- 'tasks': list(map(extract_task_features, tasks)),
- }
- return JsonResponse(response_payload)
+ Takes either of the following query parameters
+ - unique_student_identifier is an email or username
+ - all_students is a boolean
+ """
+ course_id = CourseKey.from_string(course_id)
+ course = get_course_by_id(course_id)
+ student = request.POST.get('unique_student_identifier', None)
+ if student is not None:
+ student = get_student_from_identifier(student)
+
+ try:
+ entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id)
+ except InvalidKeyError:
+ return HttpResponseBadRequest(_("Course has no valid entrance exam section."))
+ if student:
+ # Specifying for a single student's entrance exam history
+ tasks = task_api.get_entrance_exam_instructor_task_history(
+ course_id,
+ entrance_exam_key,
+ student
+ )
+ else:
+ # Specifying for all student's entrance exam history
+ tasks = task_api.get_entrance_exam_instructor_task_history(
+ course_id,
+ entrance_exam_key
+ )
+
+ response_payload = {
+ 'tasks': list(map(extract_task_features, tasks)),
+ }
+ return JsonResponse(response_payload)
class ReportDownloadSerializer(serializers.Serializer): # pylint: disable=abstract-method
diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py
index 4f0e096d0d6e..476c9fcc9f33 100644
--- a/lms/djangoapps/instructor/views/api_urls.py
+++ b/lms/djangoapps/instructor/views/api_urls.py
@@ -1,3 +1,4 @@
+
"""
Instructor API endpoint urls.
"""
@@ -22,7 +23,7 @@
urlpatterns = [
path('students_update_enrollment', api.students_update_enrollment, name='students_update_enrollment'),
path('register_and_enroll_students', api.register_and_enroll_students, name='register_and_enroll_students'),
- path('list_course_role_members', api.list_course_role_members, name='list_course_role_members'),
+ path('list_course_role_members', api.ListCourseRoleMembersView.as_view(), name='list_course_role_members'),
path('modify_access', api.modify_access, name='modify_access'),
path('bulk_beta_modify_access', api.bulk_beta_modify_access, name='bulk_beta_modify_access'),
path('get_problem_responses', api.get_problem_responses, name='get_problem_responses'),
@@ -32,14 +33,14 @@
path('get_students_who_may_enroll', api.get_students_who_may_enroll, name='get_students_who_may_enroll'),
path('get_anon_ids', api.get_anon_ids, name='get_anon_ids'),
path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"),
- path('get_student_progress_url', api.get_student_progress_url, name='get_student_progress_url'),
+ path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'),
path('reset_student_attempts', api.reset_student_attempts, name='reset_student_attempts'),
path('rescore_problem', api.rescore_problem, name='rescore_problem'),
path('override_problem_score', api.override_problem_score, name='override_problem_score'),
path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam,
name='reset_student_attempts_for_entrance_exam'),
path('rescore_entrance_exam', api.rescore_entrance_exam, name='rescore_entrance_exam'),
- path('list_entrance_exam_instructor_tasks', api.list_entrance_exam_instructor_tasks,
+ path('list_entrance_exam_instructor_tasks', api.ListEntranceExamInstructorTasks.as_view(),
name='list_entrance_exam_instructor_tasks'),
path('mark_student_can_skip_entrance_exam', api.mark_student_can_skip_entrance_exam,
name='mark_student_can_skip_entrance_exam'),
diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py
new file mode 100644
index 000000000000..b4f6f7626013
--- /dev/null
+++ b/lms/djangoapps/instructor/views/serializer.py
@@ -0,0 +1,30 @@
+""" Instructor apis serializers. """
+
+from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext as _
+from rest_framework import serializers
+
+from lms.djangoapps.instructor.access import ROLES
+
+
+class RoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-method
+ """
+ Serializer that describes the response of the problem response report generation API.
+ """
+
+ rolename = serializers.CharField(help_text=_("Role name"))
+
+ def validate_rolename(self, value):
+ """
+ Check that the rolename is valid.
+ """
+ if value not in ROLES:
+ raise ValidationError(_("Invalid role name."))
+ return value
+
+
+class UserSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = User
+ fields = ['username', 'email', 'first_name', 'last_name']
diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py
index 0c4d4a96aa01..affefafe5ba0 100644
--- a/lms/djangoapps/mobile_api/course_info/views.py
+++ b/lms/djangoapps/mobile_api/course_info/views.py
@@ -418,19 +418,25 @@ class CourseEnrollmentDetailsView(APIView):
This api works with all versions {api_version}, you can use: v0.5, v1, v2 or v3
- GET /api/mobile/{api_version}/course_info/{course_id}}/enrollment_details
+ GET /api/mobile/{api_version}/course_info/{course_id}/enrollment_details
"""
- @mobile_course_access()
- def get(self, request, course, *args, **kwargs):
+ def get(self, request, *args, **kwargs):
"""
Handle the GET request
Returns user enrollment and course details.
"""
+ course_key_string = kwargs.get('course_id')
+ try:
+ course_key = CourseKey.from_string(course_key_string)
+ except InvalidKeyError:
+ error = {'error': f"'{str(course_key_string)}' is not a valid course key."}
+ return Response(data=error, status=status.HTTP_400_BAD_REQUEST)
+
data = {
'api_version': self.kwargs.get('api_version'),
- 'course_id': course.id,
+ 'course_id': course_key,
'user': request.user,
'request': request,
}
diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_views.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py
index 730943c365e9..56c020ec8fa3 100644
--- a/lms/djangoapps/mobile_api/tests/test_course_info_views.py
+++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py
@@ -1,6 +1,7 @@
"""
Tests for course_info
"""
+from datetime import datetime, timedelta
from unittest.mock import patch
import ddt
@@ -11,6 +12,7 @@
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from milestones.tests.utils import MilestonesTestCaseMixin
+from pytz import utc
from rest_framework import status
from common.djangoapps.student.tests.factories import UserFactory # pylint: disable=unused-import
@@ -26,6 +28,7 @@
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import \
SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order
User = get_user_model()
@@ -521,3 +524,79 @@ def verify_certificate(self, response, mock_certificate_downloadable_status):
mock_certificate_downloadable_status.assert_called_once()
certificate_url = 'https://test_certificate_url'
assert response.data['certificate'] == {'url': certificate_url}
+
+ @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status')
+ def test_course_not_started(self, mock_certificate_downloadable_status):
+ """ Test course data which has not started yet """
+
+ certificate_url = 'https://test_certificate_url'
+ mock_certificate_downloadable_status.return_value = {
+ 'is_downloadable': True,
+ 'download_url': certificate_url,
+ }
+ now = datetime.now(utc)
+ course_not_started = CourseFactory.create(
+ mobile_available=True,
+ static_asset_path="needed_for_split",
+ start=now + timedelta(days=5),
+ )
+
+ url = reverse('course-enrollment-details', kwargs={
+ 'api_version': 'v1',
+ 'course_id': course_not_started.id
+ })
+
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+ assert response.data['id'] == str(course_not_started.id)
+
+ self.verify_course_access_details(response)
+
+ @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status')
+ def test_course_closed(self, mock_certificate_downloadable_status):
+ """ Test course data whose end date is in past """
+
+ certificate_url = 'https://test_certificate_url'
+ mock_certificate_downloadable_status.return_value = {
+ 'is_downloadable': True,
+ 'download_url': certificate_url,
+ }
+ now = datetime.now(utc)
+ course_closed = CourseFactory.create(
+ mobile_available=True,
+ static_asset_path="needed_for_split",
+ start=now - timedelta(days=250),
+ end=now - timedelta(days=50),
+ )
+
+ url = reverse('course-enrollment-details', kwargs={
+ 'api_version': 'v1',
+ 'course_id': course_closed.id
+ })
+
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+ assert response.data['id'] == str(course_closed.id)
+
+ self.verify_course_access_details(response)
+
+ @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status')
+ def test_invalid_course_id(self, mock_certificate_downloadable_status):
+ """ Test view with invalid course id """
+
+ certificate_url = 'https://test_certificate_url'
+ mock_certificate_downloadable_status.return_value = {
+ 'is_downloadable': True,
+ 'download_url': certificate_url,
+ }
+
+ invalid_id = "invalid" + str(self.course.id)
+ url = reverse('course-enrollment-details', kwargs={
+ 'api_version': 'v1',
+ 'course_id': invalid_id
+ })
+
+ response = self.client.get(path=url)
+ assert response.status_code == 400
+ expected_error = "'{}' is not a valid course key.".format(invalid_id)
+ assert response.data['error'] == expected_error
diff --git a/lms/djangoapps/static_template_view/views.py b/lms/djangoapps/static_template_view/views.py
index 01b99d51c861..a788f77a95fd 100644
--- a/lms/djangoapps/static_template_view/views.py
+++ b/lms/djangoapps/static_template_view/views.py
@@ -5,7 +5,7 @@
# List of valid templates is explicitly managed for (short-term)
# security reasons.
-
+import logging
import mimetypes
from django.conf import settings
@@ -23,6 +23,8 @@
from common.djangoapps.util.views import fix_crum_request
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+log = logging.getLogger(__name__)
+
valid_templates = []
if settings.STATIC_GRAB:
@@ -122,4 +124,21 @@ def render_429(request, exception=None): # lint-amnesty, pylint: disable=unused
@fix_crum_request
def render_500(request):
- return HttpResponseServerError(render_to_string('static_templates/server-error.html', {}, request=request))
+ """
+ Render the generic error page when we have an uncaught error.
+ """
+ try:
+ return HttpResponseServerError(render_to_string('static_templates/server-error.html', {}, request=request))
+ except BaseException as e:
+ # If we can't render the error page, ensure we don't raise another
+ # exception -- because if we do, we'll probably just end up back
+ # at the same rendering error.
+ #
+ # This is an attempt at working around the recursive error handling issues
+ # observed in , which
+ # were triggered by Mako and translation errors.
+
+ log.error("Encountered error while rendering error page.", exc_info=True)
+ # This message is intentionally hardcoded and does not involve
+ # any translation, templating, etc. Do not translate.
+ return HttpResponseServerError("Encountered error while rendering error page.")
diff --git a/lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py b/lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py
new file mode 100644
index 000000000000..5f09d0cc493b
--- /dev/null
+++ b/lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.13 on 2024-06-27 20:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('support', '0005_unique_course_id'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='historicalusersocialauth',
+ name='extra_data',
+ field=models.JSONField(default=dict),
+ ),
+ migrations.AlterField(
+ model_name='historicalusersocialauth',
+ name='id',
+ field=models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID'),
+ ),
+ ]
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 065d059eeda1..d0d2eeae765c 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1101,6 +1101,11 @@
# If this is true, random scores will be generated for the purpose of debugging the profile graphs
GENERATE_PROFILE_SCORES = False
+# .. setting_name: GRADEBOOK_FREEZE_DAYS
+# .. setting_default: 30
+# .. setting_description: Sets the number of days after which the gradebook will freeze following the course's end.
+GRADEBOOK_FREEZE_DAYS = 30
+
# Used with XQueue
XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds
XQUEUE_INTERFACE = {
@@ -3350,9 +3355,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# Management of external user ids
'openedx.core.djangoapps.external_user_ids',
- # Provides api for Demographics support
- 'openedx.core.djangoapps.demographics',
-
# Management of per-user schedules
'openedx.core.djangoapps.schedules',
@@ -4295,6 +4297,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
}
}
+# Enable First Purchase Discount offer override
+FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE = ''
+FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE = 15
+
# E-Commerce API Configuration
ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:8002'
ECOMMERCE_API_URL = 'http://localhost:8002/api/v2'
@@ -5382,7 +5388,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
############## NOTIFICATIONS ##############
NOTIFICATIONS_EXPIRY = 60
EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000
-NOTIFICATION_CREATION_BATCH_SIZE = 83
+NOTIFICATION_CREATION_BATCH_SIZE = 76
NOTIFICATIONS_DEFAULT_FROM_EMAIL = "no-reply@example.com"
NOTIFICATION_TYPE_ICONS = {}
DEFAULT_NOTIFICATION_ICON_URL = ""
diff --git a/lms/static/js/learner_dashboard/spec/program_details_view_spec.js b/lms/static/js/learner_dashboard/spec/program_details_view_spec.js
index 2387ade00b9e..feaf72526192 100644
--- a/lms/static/js/learner_dashboard/spec/program_details_view_spec.js
+++ b/lms/static/js/learner_dashboard/spec/program_details_view_spec.js
@@ -739,8 +739,8 @@ describe('Program Details View', () => {
);
});
- it('should render appropriate subscription text when subscription is active with trial', () => {
- testSubscriptionState(
+ it('should not render appropriate subscription text when subscription is active with trial', () => {
+ testSubscriptionSunsetting(
'active',
'Manage my subscription',
'Trial ends',
@@ -748,8 +748,8 @@ describe('Program Details View', () => {
);
});
- it('should render appropriate subscription text when subscription is active', () => {
- testSubscriptionState(
+ it('should not render appropriate subscription text when subscription is active', () => {
+ testSubscriptionSunsetting(
'active',
'Manage my subscription',
'Your next billing date is',
diff --git a/lms/templates/learner_dashboard/program_details_tab_view.underscore b/lms/templates/learner_dashboard/program_details_tab_view.underscore
index f15265119d8a..37691cca55c5 100644
--- a/lms/templates/learner_dashboard/program_details_tab_view.underscore
+++ b/lms/templates/learner_dashboard/program_details_tab_view.underscore
@@ -46,64 +46,7 @@
<% } %>
- <% if (isSubscriptionEligible && (
- completedCount !== totalCount
- || subscriptionState === 'active'
- )
- ) { %>
-
- <% } else if (
+ <% if (
!isSubscriptionEligible
&& is_learner_eligible_for_one_click_purchase
&& (typeof is_mobile_only === 'undefined' || is_mobile_only === false)
diff --git a/lms/templates/learner_dashboard/program_details_view.underscore b/lms/templates/learner_dashboard/program_details_view.underscore
index c2bdca897edc..25f1cd5b883b 100644
--- a/lms/templates/learner_dashboard/program_details_view.underscore
+++ b/lms/templates/learner_dashboard/program_details_view.underscore
@@ -21,64 +21,7 @@
<% } %>
- <% if (isSubscriptionEligible && (
- completedCount !== totalCount
- || subscriptionState === 'active'
- )
- ) { %>
-
- <% } else if (
+ <% if (
!isSubscriptionEligible
&& is_learner_eligible_for_one_click_purchase
&& (typeof is_mobile_only === 'undefined' || is_mobile_only === false)
diff --git a/lms/urls.py b/lms/urls.py
index 99288e25b519..f97bc7f0d04e 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -129,9 +129,6 @@
),
),
- # Demographics API RESTful endpoints
- path('api/demographics/', include('openedx.core.djangoapps.demographics.rest_api.urls')),
-
# Courseware search endpoints
path('search/', include('search.urls')),
diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py
index 17824dbc2a7e..9473dabbe427 100644
--- a/openedx/core/djangoapps/content/search/api.py
+++ b/openedx/core/djangoapps/content/search/api.py
@@ -322,6 +322,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.tags + "." + Fields.tags_level3,
Fields.type,
Fields.access_id,
+ Fields.last_published,
])
# Mark which attributes are used for keyword search, in order of importance:
client.index(temp_index_name).update_searchable_attributes([
@@ -340,28 +341,49 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.tags + "." + Fields.tags_level2,
Fields.tags + "." + Fields.tags_level3,
])
+ # Mark which attributes can be used for sorting search results:
+ client.index(temp_index_name).update_sortable_attributes([
+ Fields.display_name,
+ Fields.created,
+ Fields.modified,
+ Fields.last_published,
+ ])
+
+ # Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance.
+ # cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy
+ client.index(temp_index_name).update_ranking_rules([
+ "sort",
+ "words",
+ "typo",
+ "proximity",
+ "attribute",
+ "exactness",
+ ])
############## Libraries ##############
status_cb("Indexing libraries...")
for lib_key in lib_keys:
status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing library {lib_key}")
- try:
- docs = []
- for component in lib_api.get_library_components(lib_key):
+ docs = []
+ for component in lib_api.get_library_components(lib_key):
+ try:
metadata = lib_api.LibraryXBlockMetadata.from_component(lib_key, component)
doc = {}
doc.update(searchable_doc_for_library_block(metadata))
doc.update(searchable_doc_tags(metadata.usage_key))
docs.append(doc)
-
+ except Exception as err: # pylint: disable=broad-except
+ status_cb(f"Error indexing library component {component}: {err}")
+ finally:
num_blocks_done += 1
- if docs:
+ if docs:
+ try:
# Add all the docs in this library at once (usually faster than adding one at a time):
_wait_for_meili_task(client.index(temp_index_name).add_documents(docs))
- except Exception as err: # pylint: disable=broad-except
- status_cb(f"Error indexing library {lib_key}: {err}")
- finally:
- num_contexts_done += 1
+ except (TypeError, KeyError, MeilisearchError) as err:
+ status_cb(f"Error indexing library {lib_key}: {err}")
+
+ num_contexts_done += 1
############## Courses ##############
status_cb("Indexing courses...")
diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py
index dea494f312f0..032023f97c60 100644
--- a/openedx/core/djangoapps/content/search/documents.py
+++ b/openedx/core/djangoapps/content/search/documents.py
@@ -27,6 +27,9 @@ class Fields:
type = "type" # DocType.course_block or DocType.library_block (see below)
block_id = "block_id" # The block_id part of the usage key. Sometimes human-readable, sometimes a random hex ID
display_name = "display_name"
+ modified = "modified"
+ created = "created"
+ last_published = "last_published"
block_type = "block_type"
context_key = "context_key"
org = "org"
@@ -221,6 +224,9 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad
Generate a dictionary document suitable for ingestion into a search engine
like Meilisearch or Elasticsearch, so that the given library block can be
found using faceted search.
+
+ Datetime fields (created, modified, last_published) are serialized to POSIX timestamps so that they can be used to
+ sort the search results.
"""
library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title
block = xblock_api.load_block(xblock_metadata.usage_key, user=None)
@@ -228,7 +234,10 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad
doc = {
Fields.id: meili_id_from_opaque_key(xblock_metadata.usage_key),
Fields.type: DocType.library_block,
- Fields.breadcrumbs: []
+ Fields.breadcrumbs: [],
+ Fields.created: xblock_metadata.created.timestamp(),
+ Fields.modified: xblock_metadata.modified.timestamp(),
+ Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None,
}
doc.update(_fields_from_block(block))
diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py
index 1a80b2215781..ba0e8c1a1680 100644
--- a/openedx/core/djangoapps/content/search/handlers.py
+++ b/openedx/core/djangoapps/content/search/handlers.py
@@ -12,6 +12,7 @@
CONTENT_LIBRARY_UPDATED,
LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_DELETED,
+ LIBRARY_BLOCK_UPDATED,
XBLOCK_CREATED,
XBLOCK_DELETED,
XBLOCK_UPDATED,
@@ -96,6 +97,7 @@ def xblock_deleted_handler(**kwargs) -> None:
@receiver(LIBRARY_BLOCK_CREATED)
+@receiver(LIBRARY_BLOCK_UPDATED)
@only_if_meilisearch_enabled
def library_block_updated_handler(**kwargs) -> None:
"""
diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py
index 1c78b28506fe..9dcdfb76b4a6 100644
--- a/openedx/core/djangoapps/content/search/tests/test_api.py
+++ b/openedx/core/djangoapps/content/search/tests/test_api.py
@@ -5,11 +5,13 @@
import copy
+from datetime import datetime, timezone
from unittest.mock import MagicMock, call, patch
from opaque_keys.edx.keys import UsageKey
import ddt
from django.test import override_settings
+from freezegun import freeze_time
from organizations.tests.factories import OrganizationFactory
from common.djangoapps.student.tests.factories import UserFactory
@@ -118,9 +120,18 @@ def setUp(self):
title="Library",
)
lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key)
- # Populate it with a problem:
- self.problem = library_api.create_library_block(self.library.key, "problem", "p1")
- self.doc_problem = {
+
+ # Populate it with 2 problems, freezing the date so we can verify created date serializes correctly.
+ created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
+ with freeze_time(created_date):
+ self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1")
+ self.problem2 = library_api.create_library_block(self.library.key, "problem", "p2")
+ # Update problem1, freezing the date so we can verify modified date serializes correctly.
+ modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc)
+ with freeze_time(modified_date):
+ library_api.set_library_block_olx(self.problem1.usage_key, "")
+
+ self.doc_problem1 = {
"id": "lborg1libproblemp1-a698218e",
"usage_key": "lb:org1:lib:problem:p1",
"block_id": "p1",
@@ -132,6 +143,25 @@ def setUp(self):
"content": {"problem_types": [], "capa_content": " "},
"type": "library_block",
"access_id": lib_access.id,
+ "last_published": None,
+ "created": created_date.timestamp(),
+ "modified": modified_date.timestamp(),
+ }
+ self.doc_problem2 = {
+ "id": "lborg1libproblemp2-b2f65e29",
+ "usage_key": "lb:org1:lib:problem:p2",
+ "block_id": "p2",
+ "display_name": "Blank Problem",
+ "block_type": "problem",
+ "context_key": "lib:org1:lib",
+ "org": "org1",
+ "breadcrumbs": [{"display_name": "Library"}],
+ "content": {"problem_types": [], "capa_content": " "},
+ "type": "library_block",
+ "access_id": lib_access.id,
+ "last_published": None,
+ "created": created_date.timestamp(),
+ "modified": created_date.timestamp(),
}
# Create a couple of taxonomies with tags
@@ -159,18 +189,72 @@ def test_reindex_meilisearch(self, mock_meilisearch):
doc_sequential["tags"] = {}
doc_vertical = copy.deepcopy(self.doc_vertical)
doc_vertical["tags"] = {}
- doc_problem = copy.deepcopy(self.doc_problem)
- doc_problem["tags"] = {}
+ doc_problem1 = copy.deepcopy(self.doc_problem1)
+ doc_problem1["tags"] = {}
+ doc_problem2 = copy.deepcopy(self.doc_problem2)
+ doc_problem2["tags"] = {}
api.rebuild_index()
mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls(
[
call([doc_sequential, doc_vertical]),
- call([doc_problem]),
+ call([doc_problem1, doc_problem2]),
+ ],
+ any_order=True,
+ )
+
+ @override_settings(MEILISEARCH_ENABLED=True)
+ def test_reindex_meilisearch_library_block_error(self, mock_meilisearch):
+
+ # Add tags field to doc, since reindex calls includes tags
+ doc_sequential = copy.deepcopy(self.doc_sequential)
+ doc_sequential["tags"] = {}
+ doc_vertical = copy.deepcopy(self.doc_vertical)
+ doc_vertical["tags"] = {}
+ doc_problem2 = copy.deepcopy(self.doc_problem2)
+ doc_problem2["tags"] = {}
+
+ orig_from_component = library_api.LibraryXBlockMetadata.from_component
+
+ def mocked_from_component(lib_key, component):
+ # Simulate an error when processing problem 1
+ if component.key == 'xblock.v1:problem:p1':
+ raise Exception('Error')
+
+ return orig_from_component(lib_key, component)
+
+ with patch.object(
+ library_api.LibraryXBlockMetadata,
+ "from_component",
+ new=mocked_from_component,
+ ):
+ api.rebuild_index()
+
+ mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls(
+ [
+ call([doc_sequential, doc_vertical]),
+ # Problem 1 should not be indexed
+ call([doc_problem2]),
],
any_order=True,
)
+ # Check that the sorting-related settings were updated to support sorting on the expected fields
+ mock_meilisearch.return_value.index.return_value.update_sortable_attributes.assert_called_with([
+ "display_name",
+ "created",
+ "modified",
+ "last_published",
+ ])
+ mock_meilisearch.return_value.index.return_value.update_ranking_rules.assert_called_with([
+ "sort",
+ "words",
+ "typo",
+ "proximity",
+ "attribute",
+ "exactness",
+ ])
+
@ddt.data(
True,
False
@@ -245,9 +329,9 @@ def test_index_library_block_metadata(self, mock_meilisearch):
"""
Test indexing a Library Block.
"""
- api.upsert_library_block_index_doc(self.problem.usage_key)
+ api.upsert_library_block_index_doc(self.problem1.usage_key)
- mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem])
+ mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem1])
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_library_block_tags(self, mock_meilisearch):
@@ -256,19 +340,19 @@ def test_index_library_block_tags(self, mock_meilisearch):
"""
# Tag XBlock (these internally call `upsert_block_tags_index_docs`)
- tagging_api.tag_object(str(self.problem.usage_key), self.taxonomyA, ["one", "two"])
- tagging_api.tag_object(str(self.problem.usage_key), self.taxonomyB, ["three", "four"])
+ tagging_api.tag_object(str(self.problem1.usage_key), self.taxonomyA, ["one", "two"])
+ tagging_api.tag_object(str(self.problem1.usage_key), self.taxonomyB, ["three", "four"])
# Build expected docs with tags at each stage
doc_problem_with_tags1 = {
- "id": self.doc_problem["id"],
+ "id": self.doc_problem1["id"],
"tags": {
'taxonomy': ['A'],
'level0': ['A > one', 'A > two']
}
}
doc_problem_with_tags2 = {
- "id": self.doc_problem["id"],
+ "id": self.doc_problem1["id"],
"tags": {
'taxonomy': ['A', 'B'],
'level0': ['A > one', 'A > two', 'B > four', 'B > three']
@@ -288,10 +372,10 @@ def test_delete_index_library_block(self, mock_meilisearch):
"""
Test deleting a Library Block doc from the index.
"""
- api.delete_index_doc(self.problem.usage_key)
+ api.delete_index_doc(self.problem1.usage_key)
mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with(
- self.doc_problem['id']
+ self.doc_problem1['id']
)
@override_settings(MEILISEARCH_ENABLED=True)
@@ -301,4 +385,6 @@ def test_index_content_library_metadata(self, mock_meilisearch):
"""
api.upsert_content_library_index_docs(self.library.key)
- mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem])
+ mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with(
+ [self.doc_problem1, self.doc_problem2]
+ )
diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py
index 1ce9c57a1ab9..8a6627e3902d 100644
--- a/openedx/core/djangoapps/content/search/tests/test_handlers.py
+++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py
@@ -1,9 +1,11 @@
"""
Tests for the search index update handlers
"""
+from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from django.test import LiveServerTestCase, override_settings
+from freezegun import freeze_time
from organizations.tests.factories import OrganizationFactory
from common.djangoapps.student.tests.factories import UserFactory
@@ -132,7 +134,10 @@ def test_create_delete_library_block(self, meilisearch_client):
)
lib_access, _ = SearchAccess.objects.get_or_create(context_key=library.key)
- problem = library_api.create_library_block(library.key, "problem", "Problem1")
+ # Populate it with a problem, freezing the date so we can verify created date serializes correctly.
+ created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
+ with freeze_time(created_date):
+ problem = library_api.create_library_block(library.key, "problem", "Problem1")
doc_problem = {
"id": "lborgalib_aproblemproblem1-ca3186e9",
"type": "library_block",
@@ -145,6 +150,9 @@ def test_create_delete_library_block(self, meilisearch_client):
"breadcrumbs": [{"display_name": "Library Org A"}],
"content": {"problem_types": [], "capa_content": " "},
"access_id": lib_access.id,
+ "last_published": None,
+ "created": created_date.timestamp(),
+ "modified": created_date.timestamp(),
}
meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
@@ -152,10 +160,24 @@ def test_create_delete_library_block(self, meilisearch_client):
# Rename the content library
library_api.update_library(library.key, title="Updated Library Org A")
- # The breadcrumbs should be updated
+ # The breadcrumbs should be updated (but nothing else)
doc_problem["breadcrumbs"][0]["display_name"] = "Updated Library Org A"
meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
+ # Edit the problem block, freezing the date so we can verify modified date serializes correctly
+ modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc)
+ with freeze_time(modified_date):
+ library_api.set_library_block_olx(problem.usage_key, "")
+ doc_problem["modified"] = modified_date.timestamp()
+ meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
+
+ # Publish the content library, freezing the date so we can verify last_published date serializes correctly
+ published_date = datetime(2024, 6, 7, 8, 9, 10, tzinfo=timezone.utc)
+ with freeze_time(published_date):
+ library_api.publish_changes(library.key)
+ doc_problem["last_published"] = published_date.timestamp()
+ meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem])
+
# Delete the Library Block
library_api.delete_library_block(problem.usage_key)
diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py
index 3800fdb7f4f4..d2c6eeeeabd9 100644
--- a/openedx/core/djangoapps/content_libraries/api.py
+++ b/openedx/core/djangoapps/content_libraries/api.py
@@ -156,6 +156,9 @@ class ContentLibraryMetadata:
version = attr.ib(0)
type = attr.ib(default=COMPLEX)
last_published = attr.ib(default=None, type=datetime)
+ last_draft_created = attr.ib(default=None, type=datetime)
+ last_draft_created_by = attr.ib(default=None, type=datetime)
+ published_by = attr.ib("")
has_unpublished_changes = attr.ib(False)
# has_unpublished_deletes will be true when the draft version of the library's bundle
# contains deletes of any XBlocks that were in the most recently published version
@@ -168,6 +171,8 @@ class ContentLibraryMetadata:
# Studio, use it in their courses, and copy content out of this library.
allow_public_read = attr.ib(False)
license = attr.ib("")
+ created = attr.ib(default=None, type=datetime)
+ updated = attr.ib(default=None, type=datetime)
class AccessLevel:
@@ -194,7 +199,10 @@ class LibraryXBlockMetadata:
Class that represents the metadata about an XBlock in a content library.
"""
usage_key = attr.ib(type=LibraryUsageLocatorV2)
+ created = attr.ib(type=datetime)
+ modified = attr.ib(type=datetime)
display_name = attr.ib("")
+ last_published = attr.ib(default=None, type=datetime)
has_unpublished_changes = attr.ib(False)
tags_count = attr.ib(0)
@@ -203,6 +211,8 @@ def from_component(cls, library_key, component):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
+ last_publish_log = component.versioning.last_publish_log
+
return cls(
usage_key=LibraryUsageLocatorV2(
library_key,
@@ -210,6 +220,9 @@ def from_component(cls, library_key, component):
component.local_key,
),
display_name=component.versioning.draft.title,
+ created=component.created,
+ modified=component.versioning.draft.created,
+ last_published=None if last_publish_log is None else last_publish_log.published_at,
has_unpublished_changes=component.versioning.has_unpublished_changes
)
@@ -342,8 +355,11 @@ def get_library(library_key):
learning_package = ref.learning_package
num_blocks = authoring_api.get_all_drafts(learning_package.id).count()
last_publish_log = authoring_api.get_last_publish(learning_package.id)
- has_unpublished_changes = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \
- .exists()
+ last_draft_log = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \
+ .order_by('-created').first()
+ last_draft_created = last_draft_log.created if last_draft_log else None
+ last_draft_created_by = last_draft_log.created_by.username if last_draft_log and last_draft_log.created_by else None
+ has_unpublished_changes = last_draft_log is not None
# TODO: I'm doing this one to match already-existing behavior, but this is
# something that we should remove. It exists to accomodate some complexities
@@ -369,6 +385,9 @@ def get_library(library_key):
# libraries. The top level version stays for now because LibraryContentBlock
# uses it, but that should hopefully change before the Redwood release.
version = 0 if last_publish_log is None else last_publish_log.pk
+ published_by = None
+ if last_publish_log and last_publish_log.published_by:
+ published_by = last_publish_log.published_by.username
return ContentLibraryMetadata(
key=library_key,
@@ -378,12 +397,17 @@ def get_library(library_key):
num_blocks=num_blocks,
version=version,
last_published=None if last_publish_log is None else last_publish_log.published_at,
+ published_by=published_by,
+ last_draft_created=last_draft_created,
+ last_draft_created_by=last_draft_created_by,
allow_lti=ref.allow_lti,
allow_public_learning=ref.allow_public_learning,
allow_public_read=ref.allow_public_read,
has_unpublished_changes=has_unpublished_changes,
has_unpublished_deletes=has_unpublished_deletes,
license=ref.license,
+ created=learning_package.created,
+ updated=learning_package.updated,
)
@@ -660,13 +684,11 @@ def get_library_block(usage_key) -> LibraryXBlockMetadata:
if not draft_version:
raise ContentLibraryBlockNotFound(usage_key)
- published_version = component.versioning.published
-
- return LibraryXBlockMetadata(
- usage_key=usage_key,
- display_name=draft_version.title,
- has_unpublished_changes=(draft_version != published_version),
+ xblock_metadata = LibraryXBlockMetadata.from_component(
+ library_key=usage_key.context_key,
+ component=component,
)
+ return xblock_metadata
def set_library_block_olx(usage_key, new_olx_str):
@@ -729,7 +751,7 @@ def set_library_block_olx(usage_key, new_olx_str):
)
-def create_library_block(library_key, block_type, definition_id):
+def create_library_block(library_key, block_type, definition_id, user_id=None):
"""
Create a new XBlock in this library of the specified type (e.g. "html").
"""
@@ -771,7 +793,7 @@ def create_library_block(library_key, block_type, definition_id):
if _component_exists(usage_key):
raise LibraryBlockAlreadyExists(f"An XBlock with ID '{usage_key}' already exists")
- _create_component_for_block(ref, usage_key)
+ _create_component_for_block(ref, usage_key, user_id=user_id)
# Now return the metadata about the new block:
LIBRARY_BLOCK_CREATED.send_event(
@@ -810,7 +832,7 @@ def get_or_create_olx_media_type(block_type: str) -> MediaType:
)
-def _create_component_for_block(content_lib, usage_key):
+def _create_component_for_block(content_lib, usage_key, user_id=None):
"""
Create a Component for an XBlock type, and initialize it.
@@ -841,7 +863,7 @@ def _create_component_for_block(content_lib, usage_key):
local_key=usage_key.block_id,
title=display_name,
created=now,
- created_by=None,
+ created_by=user_id,
)
content = authoring_api.get_or_create_text_content(
learning_package.id,
@@ -945,13 +967,13 @@ def get_allowed_block_types(library_key): # pylint: disable=unused-argument
return info
-def publish_changes(library_key):
+def publish_changes(library_key, user_id=None):
"""
Publish all pending changes to the specified library.
"""
learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package
- authoring_api.publish_all_drafts(learning_package.id)
+ authoring_api.publish_all_drafts(learning_package.id, published_by=user_id)
CONTENT_LIBRARY_UPDATED.send_event(
content_library=ContentLibraryData(
diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py
index 2607c18df7e4..6ff426e73560 100644
--- a/openedx/core/djangoapps/content_libraries/library_context.py
+++ b/openedx/core/djangoapps/content_libraries/library_context.py
@@ -6,6 +6,9 @@
from django.core.exceptions import PermissionDenied
+from openedx_events.content_authoring.data import LibraryBlockData
+from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED
+
from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
from openedx.core.djangoapps.xblock.api import LearningContext
@@ -93,3 +96,16 @@ def block_exists(self, usage_key):
type_name=usage_key.block_type,
local_key=usage_key.block_id,
)
+
+ def send_block_updated_event(self, usage_key):
+ """
+ Send a "block updated" event for the library block with the given usage_key.
+
+ usage_key: the UsageKeyV2 subclass used for this learning context
+ """
+ LIBRARY_BLOCK_UPDATED.send_event(
+ library_block=LibraryBlockData(
+ library_key=usage_key.lib_key,
+ usage_key=usage_key,
+ )
+ )
diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py
index 89c6b611a822..e714718a77b5 100644
--- a/openedx/core/djangoapps/content_libraries/serializers.py
+++ b/openedx/core/djangoapps/content_libraries/serializers.py
@@ -36,12 +36,14 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
org = serializers.SlugField(source="key.org")
slug = serializers.CharField(source="key.slug", validators=(validate_unicode_slug, ))
bundle_uuid = serializers.UUIDField(format='hex_verbose', read_only=True)
- #collection_uuid = serializers.UUIDField(format='hex_verbose', write_only=True)
title = serializers.CharField()
description = serializers.CharField(allow_blank=True)
num_blocks = serializers.IntegerField(read_only=True)
version = serializers.IntegerField(read_only=True)
last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
+ published_by = serializers.CharField(read_only=True)
+ last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
+ last_draft_created_by = serializers.CharField(read_only=True)
allow_lti = serializers.BooleanField(default=False, read_only=True)
allow_public_learning = serializers.BooleanField(default=False)
allow_public_read = serializers.BooleanField(default=False)
@@ -49,6 +51,8 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
has_unpublished_deletes = serializers.BooleanField(read_only=True)
license = serializers.ChoiceField(choices=LICENSE_OPTIONS, default=ALL_RIGHTS_RESERVED)
can_edit_library = serializers.SerializerMethodField()
+ created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
+ updated = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
def get_can_edit_library(self, obj):
"""
diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py
index 810c99f223db..502150b47da6 100644
--- a/openedx/core/djangoapps/content_libraries/views.py
+++ b/openedx/core/djangoapps/content_libraries/views.py
@@ -487,7 +487,7 @@ def post(self, request, lib_key_str):
"""
key = LibraryLocatorV2.from_string(lib_key_str)
api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
- api.publish_changes(key)
+ api.publish_changes(key, request.user.id)
return Response({})
@convert_exceptions
@@ -556,7 +556,7 @@ def post(self, request, lib_key_str):
# Create a new regular top-level block:
try:
- result = api.create_library_block(library_key, **serializer.validated_data)
+ result = api.create_library_block(library_key, user_id=request.user.id, **serializer.validated_data)
except api.IncompatibleTypesError as err:
raise ValidationError( # lint-amnesty, pylint: disable=raise-missing-from
detail={'block_type': str(err)},
diff --git a/openedx/core/djangoapps/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py
index e8b97df41274..1b9790cfbeee 100644
--- a/openedx/core/djangoapps/content_staging/views.py
+++ b/openedx/core/djangoapps/content_staging/views.py
@@ -10,12 +10,14 @@
import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
-from opaque_keys.edx.locator import CourseLocator
+from opaque_keys.edx.locator import CourseLocator, LibraryLocatorV2
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.student.auth import has_studio_read_access
+from openedx.core.djangoapps.content_libraries import api as lib_api
+from openedx.core.djangoapps.xblock import api as xblock_api
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -89,18 +91,31 @@ def post(self, request):
if usage_key.block_type in ('course', 'chapter', 'sequential'):
raise ValidationError('Requested XBlock tree is too large')
course_key = usage_key.context_key
- if not isinstance(course_key, CourseLocator):
- # In the future, we'll support libraries too but for now we don't.
- raise ValidationError('Invalid usage key: not a modulestore course')
- # Make sure the user has permission on that course
- if not has_studio_read_access(request.user, course_key):
- raise PermissionDenied("You must be a member of the course team in Studio to export OLX using this API.")
# Load the block and copy it to the user's clipboard
try:
- block = modulestore().get_item(usage_key)
+ if isinstance(course_key, CourseLocator):
+ # Make sure the user has permission on that course
+ if not has_studio_read_access(request.user, course_key):
+ raise PermissionDenied(
+ "You must be a member of the course team in Studio to export OLX using this API."
+ )
+ block = modulestore().get_item(usage_key)
+
+ elif isinstance(course_key, LibraryLocatorV2):
+ lib_api.require_permission_for_library_key(
+ course_key,
+ request.user,
+ lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY
+ )
+ block = xblock_api.load_block(usage_key, user=None)
+
+ else:
+ raise ValidationError("Invalid usage_key for the content.")
+
except ItemNotFoundError as exc:
raise NotFound("The requested usage key does not exist.") from exc
+
clipboard = api.save_xblock_to_user_clipboard(block=block, user_id=request.user.id)
# Return the current clipboard exactly as if GET was called:
diff --git a/openedx/core/djangoapps/contentserver/test/test_views.py b/openedx/core/djangoapps/contentserver/test/test_views.py
new file mode 100644
index 000000000000..9e0de99ed5a6
--- /dev/null
+++ b/openedx/core/djangoapps/contentserver/test/test_views.py
@@ -0,0 +1,26 @@
+"""
+Tests for the view version of course asset serving.
+"""
+
+import ddt
+from django.test import TestCase
+from django.urls import resolve
+
+
+@ddt.ddt
+class UrlsTest(TestCase):
+ """
+ Tests for ensuring that the urlpatterns registered to the view are
+ appropriate for the URLs that the middleware historically handled.
+ """
+
+ @ddt.data(
+ '/c4x/edX/Open_DemoX/asset/images_course_image.jpg',
+ '/asset-v1:edX+DemoX.1+2T2019+type@asset+block/DemoX-poster.jpg',
+ '/assets/courseware/v1/0123456789abcdef0123456789abcdef/asset-v1:edX+FAKE101+2024+type@asset+block/HW1.png',
+ )
+ def test_sample_urls(self, sample_url):
+ """
+ Regression test -- c4x URL was previously incorrect in urls.py.
+ """
+ assert resolve(sample_url).view_name == 'openedx.core.djangoapps.contentserver.views.course_assets_view'
diff --git a/openedx/core/djangoapps/contentserver/urls.py b/openedx/core/djangoapps/contentserver/urls.py
index 96bbe6bf3820..f3fce5a6f296 100644
--- a/openedx/core/djangoapps/contentserver/urls.py
+++ b/openedx/core/djangoapps/contentserver/urls.py
@@ -2,7 +2,7 @@
URL patterns for course asset serving.
"""
-from django.urls import path, re_path
+from django.urls import re_path
from . import views
@@ -10,7 +10,7 @@
# components of the URLs. That's because the view itself is separately
# parsing the paths, for historical reasons. See docstring on views.py.
urlpatterns = [
- path("c4x/", views.course_assets_view),
+ re_path("^c4x/", views.course_assets_view),
re_path("^asset-v1:", views.course_assets_view),
re_path("^assets/courseware/", views.course_assets_view),
]
diff --git a/openedx/core/djangoapps/demographics/README.rst b/openedx/core/djangoapps/demographics/README.rst
deleted file mode 100644
index 9081b000585a..000000000000
--- a/openedx/core/djangoapps/demographics/README.rst
+++ /dev/null
@@ -1,19 +0,0 @@
-Status: Active
-
-Responsibilities
-================
-The Demographics app is an application to support the Demographics feature set
-and IDA. It serves as the access point for demographics related status.
-
-Direction: Decompose
-===============
-This app may be removed in the future as the Demographics feature set expands
-to a larger set of users. It is not recommended that new features are added
-here at this time.
-
-Glossary
-========
-IDA: Independently Deployable Application
-
-More Documentation
-==================
diff --git a/openedx/core/djangoapps/demographics/__init__.py b/openedx/core/djangoapps/demographics/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/openedx/core/djangoapps/demographics/admin.py b/openedx/core/djangoapps/demographics/admin.py
deleted file mode 100644
index 115581a7fb5f..000000000000
--- a/openedx/core/djangoapps/demographics/admin.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-Django admin page for demographics
-"""
-
-from django.contrib import admin
-
-from openedx.core.djangoapps.demographics.models import UserDemographics
-
-
-class UserDemographicsAdmin(admin.ModelAdmin):
- """
- Admin for UserDemographics Model
- """
- list_display = ('id', 'user', 'show_call_to_action')
- readonly_fields = ('user',)
- search_fields = ('id', 'user__username')
-
- class Meta:
- model = UserDemographics
-
-
-admin.site.register(UserDemographics, UserDemographicsAdmin)
diff --git a/openedx/core/djangoapps/demographics/api/__init__.py b/openedx/core/djangoapps/demographics/api/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/openedx/core/djangoapps/demographics/api/status.py b/openedx/core/djangoapps/demographics/api/status.py
deleted file mode 100644
index cb861cf92f81..000000000000
--- a/openedx/core/djangoapps/demographics/api/status.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""
-Python API for Demographics Status
-"""
-
-from openedx.features.enterprise_support.utils import is_enterprise_learner
-from openedx.core.djangoapps.programs.api import is_user_enrolled_in_program_type
-from openedx.core.djangoapps.demographics.models import UserDemographics
-
-
-def show_user_demographics(user, enrollments=None, entitlements=None):
- """
- Check if the user should be shown demographics collection fields. Currently limited
- to MicroBachlors Programs' learners who aren't part of an enterprise.
- """
- is_user_in_microbachelors_program = is_user_enrolled_in_program_type(
- user,
- "microbachelors",
- enrollments=enrollments,
- entitlements=entitlements
- )
- return is_user_in_microbachelors_program and not is_enterprise_learner(user)
-
-
-def show_call_to_action_for_user(user):
- """
- Utility method to determine if a user should be shown the Demographics call to
- action.
- """
- try:
- return UserDemographics.objects.get(user=user).show_call_to_action
- except UserDemographics.DoesNotExist:
- return True
diff --git a/openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst b/openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst
deleted file mode 100644
index 343b63576160..000000000000
--- a/openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst
+++ /dev/null
@@ -1,22 +0,0 @@
-Django Application to Support Demographics Features
----------------------------------------------------
-
-
-Status
-======
-
-Accepted
-
-Context
-=======
-
-To support demographics features and the IDA we need to be able to access the
-current state of a User from the LMS (i.e. Program Enrollments, Enterprise status).
-
-Decisions
-=========
-
-* To meet this need we are creating the Demographics Django Application in the
-Open edX core. This application will contain utilities and APIs that will support
-the Demographics feature set until they are replaced with other more general APIs
-or no longer needed.
diff --git a/openedx/core/djangoapps/demographics/migrations/0001_initial.py b/openedx/core/djangoapps/demographics/migrations/0001_initial.py
deleted file mode 100644
index 313406112549..000000000000
--- a/openedx/core/djangoapps/demographics/migrations/0001_initial.py
+++ /dev/null
@@ -1,55 +0,0 @@
-# Generated by Django 2.2.14 on 2020-07-23 19:25
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-import django.utils.timezone
-import model_utils.fields
-import simple_history.models
-
-
-class Migration(migrations.Migration):
-
- initial = True
-
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ]
-
- operations = [
- migrations.CreateModel(
- name='UserDemographics',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
- ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
- ('show_call_to_action', models.BooleanField(default=True)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
- ],
- options={
- 'verbose_name': 'user demographic',
- 'verbose_name_plural': 'user demographic',
- },
- ),
- migrations.CreateModel(
- name='HistoricalUserDemographics',
- fields=[
- ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
- ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
- ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
- ('show_call_to_action', models.BooleanField(default=True)),
- ('history_id', models.AutoField(primary_key=True, serialize=False)),
- ('history_date', models.DateTimeField()),
- ('history_change_reason', models.CharField(max_length=100, null=True)),
- ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
- ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
- ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
- ],
- options={
- 'verbose_name': 'historical user demographic',
- 'get_latest_by': 'history_date',
- 'ordering': ('-history_date', '-history_id'),
- },
- bases=(simple_history.models.HistoricalChanges, models.Model),
- ),
- ]
diff --git a/openedx/core/djangoapps/demographics/migrations/0002_clean_duplicate_entries.py b/openedx/core/djangoapps/demographics/migrations/0002_clean_duplicate_entries.py
deleted file mode 100644
index bfadadf061b3..000000000000
--- a/openedx/core/djangoapps/demographics/migrations/0002_clean_duplicate_entries.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import logging
-
-from django.conf import settings
-from django.db import migrations, models
-
-log = logging.getLogger(__name__)
-
-
-def _clean_duplicate_entries(apps, schema_editor):
- """
- This method finds all the duplicate user entries in the UserDemographics model
- and then removes all duplicate entries except for the most recently modified one.
- """
- demographics_model = apps.get_model('demographics', 'UserDemographics')
- # Retrieve a list of all users that have more than one entry.
- duplicate_users = (
- demographics_model.objects.values(
- 'user'
- ).annotate(models.Count('id')).values('user').order_by().filter(id__count__gt=1)
- )
- # Get a QuerySet of all the UserDemographics instances for the duplicates
- # sorted by user and modified in descending order.
- user_demographic_dupes = demographics_model.objects.filter(user__in=duplicate_users).order_by('user', '-modified')
-
- # Go through the QuerySet and only keep the most recent instance.
- existing_user_ids = set()
- for demographic in user_demographic_dupes:
- if demographic.user_id in existing_user_ids:
- log.info('UserDemographics {user} -- {modified}'.format(
- user=demographic.user_id, modified=demographic.modified
- ))
- demographic.delete()
- else:
- log.info('UserDemographics Duplicate User Delete {user} -- {modified}'.format(
- user=demographic.user_id, modified=demographic.modified
- ))
- existing_user_ids.add(demographic.user_id)
-
-
-class Migration(migrations.Migration):
- dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('demographics', '0001_initial'),
- ]
-
- operations = [
- migrations.RunPython(_clean_duplicate_entries, migrations.RunPython.noop),
- ]
diff --git a/openedx/core/djangoapps/demographics/migrations/0003_auto_20200827_1949.py b/openedx/core/djangoapps/demographics/migrations/0003_auto_20200827_1949.py
deleted file mode 100644
index d31c24841ea6..000000000000
--- a/openedx/core/djangoapps/demographics/migrations/0003_auto_20200827_1949.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Generated by Django 2.2.15 on 2020-08-27 19:49
-
-from django.conf import settings
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('demographics', '0002_clean_duplicate_entries'),
- ]
-
- operations = [
- migrations.AlterField(
- model_name='userdemographics',
- name='user',
- field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
- ),
- ]
diff --git a/openedx/core/djangoapps/demographics/migrations/0004_alter_historicaluserdemographics_options.py b/openedx/core/djangoapps/demographics/migrations/0004_alter_historicaluserdemographics_options.py
deleted file mode 100644
index 4e6b50a81cd4..000000000000
--- a/openedx/core/djangoapps/demographics/migrations/0004_alter_historicaluserdemographics_options.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# Generated by Django 3.2.20 on 2023-08-08 09:44
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('demographics', '0003_auto_20200827_1949'),
- ]
-
- operations = [
- migrations.AlterModelOptions(
- name='historicaluserdemographics',
- options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical user demographic', 'verbose_name_plural': 'historical user demographic'},
- ),
- ]
diff --git a/openedx/core/djangoapps/demographics/migrations/__init__.py b/openedx/core/djangoapps/demographics/migrations/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/openedx/core/djangoapps/demographics/models.py b/openedx/core/djangoapps/demographics/models.py
deleted file mode 100644
index 09c9251b0e40..000000000000
--- a/openedx/core/djangoapps/demographics/models.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""
-Demographics models
-"""
-
-from django.contrib.auth import get_user_model
-from django.db import models
-from model_utils.models import TimeStampedModel
-from simple_history.models import HistoricalRecords
-
-User = get_user_model()
-
-
-class UserDemographics(TimeStampedModel):
- """
- A Users Demographics platform related data in support of the Demographics
- IDA and features
-
- .. no_pii:
- """
- user = models.OneToOneField(User, on_delete=models.CASCADE)
- show_call_to_action = models.BooleanField(default=True)
- history = HistoricalRecords(app='demographics')
-
- class Meta:
- app_label = "demographics"
- verbose_name = "user demographic"
- verbose_name_plural = "user demographic"
-
- def __str__(self):
- return f'UserDemographics for {self.user}'
diff --git a/openedx/core/djangoapps/demographics/rest_api/__init__.py b/openedx/core/djangoapps/demographics/rest_api/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/openedx/core/djangoapps/demographics/rest_api/urls.py b/openedx/core/djangoapps/demographics/rest_api/urls.py
deleted file mode 100644
index 2f7dcc1f11e7..000000000000
--- a/openedx/core/djangoapps/demographics/rest_api/urls.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""
-Demographics API URLs.
-"""
-from django.urls import path, include
-
-from .v1 import urls as v1_urls
-
-app_name = 'openedx.core.djangoapps.demographics'
-
-urlpatterns = [
- path('v1/', include(v1_urls))
-]
diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/__init__.py b/openedx/core/djangoapps/demographics/rest_api/v1/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/urls.py b/openedx/core/djangoapps/demographics/rest_api/v1/urls.py
deleted file mode 100644
index 2bc0383771b0..000000000000
--- a/openedx/core/djangoapps/demographics/rest_api/v1/urls.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""
-URL Routes for this app.
-"""
-from django.urls import path
-from .views import DemographicsStatusView
-
-
-urlpatterns = [
- path('demographics/status/', DemographicsStatusView.as_view(),
- name='demographics_status'
- ),
-]
diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/views.py b/openedx/core/djangoapps/demographics/rest_api/v1/views.py
deleted file mode 100644
index 35aacc61bd24..000000000000
--- a/openedx/core/djangoapps/demographics/rest_api/v1/views.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# lint-amnesty, pylint: disable=missing-module-docstring
-from rest_framework import permissions, status
-from rest_framework.response import Response
-from rest_framework.views import APIView
-
-from openedx.core.djangoapps.demographics.api.status import (
- show_user_demographics, show_call_to_action_for_user,
-)
-from openedx.core.djangoapps.demographics.models import UserDemographics
-
-
-class DemographicsStatusView(APIView):
- """
- Demographics display status for the User.
-
- The API will return whether or not to display the Demographics UI based on
- the User's status in the Platform
- """
- permission_classes = (permissions.IsAuthenticated, )
-
- def _response_context(self, user, user_demographics=None):
- """
- Determine whether the user should be shown demographics collection fields and the demographics call to action.
- """
- if user_demographics:
- show_call_to_action = user_demographics.show_call_to_action
- else:
- show_call_to_action = show_call_to_action_for_user(user)
- return {
- 'display': show_user_demographics(user),
- 'show_call_to_action': show_call_to_action
- }
-
- def get(self, request):
- """
- GET /api/user/v1/accounts/demographics/status
-
- This is a Web API to determine the status of demographics related features
- """
- user = request.user
- return Response(self._response_context(user))
-
- def patch(self, request):
- """
- PATCH /api/user/v1/accounts/demographics/status
-
- This is a Web API to update fields that are dependent on user interaction.
- """
- show_call_to_action = request.data.get('show_call_to_action')
- user = request.user
- if not isinstance(show_call_to_action, bool):
- return Response(status.HTTP_400_BAD_REQUEST)
- (user_demographics, _) = UserDemographics.objects.get_or_create(user=user)
- user_demographics.show_call_to_action = show_call_to_action
- user_demographics.save()
- return Response(self._response_context(user, user_demographics))
diff --git a/openedx/core/djangoapps/demographics/tests/__init__.py b/openedx/core/djangoapps/demographics/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/openedx/core/djangoapps/demographics/tests/factories.py b/openedx/core/djangoapps/demographics/tests/factories.py
deleted file mode 100644
index 9678eaec6d6a..000000000000
--- a/openedx/core/djangoapps/demographics/tests/factories.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""
-Factoryboy factories for Demographics.
-"""
-
-import factory
-
-from openedx.core.djangoapps.demographics.models import UserDemographics
-
-
-class UserDemographicsFactory(factory.django.DjangoModelFactory):
- """
- UserDemographics Factory
- """
-
- class Meta:
- model = UserDemographics
diff --git a/openedx/core/djangoapps/demographics/tests/test_status.py b/openedx/core/djangoapps/demographics/tests/test_status.py
deleted file mode 100644
index 525b82e13c8c..000000000000
--- a/openedx/core/djangoapps/demographics/tests/test_status.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""
-Test status utilities
-"""
-from unittest import TestCase
-from unittest import mock
-
-from django.conf import settings
-from opaque_keys.edx.keys import CourseKey
-from pytest import mark
-from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory
-
-from common.djangoapps.course_modes.models import CourseMode
-from common.djangoapps.course_modes.tests.factories import CourseModeFactory
-from common.djangoapps.student.tests.factories import UserFactory
-from openedx.core.djangoapps.catalog.tests.factories import (
- ProgramFactory,
-)
-from openedx.core.djangolib.testing.utils import skip_unless_lms
-from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory
-
-if settings.ROOT_URLCONF == 'lms.urls':
- from openedx.core.djangoapps.demographics.api.status import show_user_demographics, show_call_to_action_for_user
- from openedx.core.djangoapps.demographics.tests.factories import UserDemographicsFactory
-
-MICROBACHELORS = 'microbachelors'
-
-
-@skip_unless_lms
-@mock.patch('openedx.core.djangoapps.programs.utils.get_programs_by_type')
-class TestShowDemographics(SharedModuleStoreTestCase):
- """
- Tests for whether the demographics collection fields should be shown
- """
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.store = modulestore()
- cls.user = UserFactory()
- cls.program = ProgramFactory(type=MICROBACHELORS)
- cls.catalog_course_run = cls.program['courses'][0]['course_runs'][0]
- cls.course_key = CourseKey.from_string(cls.catalog_course_run['key'])
- cls.course_run = CourseFactory.create(
- org=cls.course_key.org,
- number=cls.course_key.course,
- run=cls.course_key.run,
- modulestore=cls.store,
- )
- CourseModeFactory.create(course_id=cls.course_run.id, mode_slug=CourseMode.VERIFIED)
-
- def test_user_enterprise(self, mock_get_programs_by_type):
- mock_get_programs_by_type.return_value = [self.program]
- EnterpriseCustomerUserFactory.create(user_id=self.user.id)
- assert not show_user_demographics(user=self.user)
-
-
-@skip_unless_lms
-@mark.django_db
-class TestShowCallToAction(TestCase):
- """
- Tests for whether the demographics call to action should be shown
- """
- def setUp(self):
- super().setUp()
- self.user = UserFactory()
-
- def test_new_user(self):
- assert show_call_to_action_for_user(self.user)
-
- def test_existing_user_no_dismiss(self):
- user_demographics = UserDemographicsFactory.create(user=self.user)
- assert user_demographics.show_call_to_action
- assert show_call_to_action_for_user(self.user)
-
- def test_existing_user_dismissed(self):
- user_demographics = UserDemographicsFactory.create(user=self.user)
- user_demographics.show_call_to_action = False
- user_demographics.save()
- assert not user_demographics.show_call_to_action
- assert not show_call_to_action_for_user(self.user)
diff --git a/openedx/core/djangoapps/discussions/admin.py b/openedx/core/djangoapps/discussions/admin.py
index eb61942abf2a..810f57e95226 100644
--- a/openedx/core/djangoapps/discussions/admin.py
+++ b/openedx/core/djangoapps/discussions/admin.py
@@ -3,6 +3,7 @@
"""
from django.contrib import admin
from django.contrib.admin import SimpleListFilter
+from django.contrib.admin.utils import quote
from simple_history.admin import SimpleHistoryAdmin
from openedx.core.djangoapps.config_model_utils.admin import StackedConfigModelAdmin
@@ -26,6 +27,9 @@ class DiscussionsConfigurationAdmin(SimpleHistoryAdmin):
'provider_type',
)
+ def change_view(self, request, object_id=None, form_url="", extra_context=None):
+ return super().change_view(request, quote(object_id), form_url, extra_context)
+
class AllowListFilter(SimpleListFilter):
"""
diff --git a/openedx/core/djangoapps/discussions/tests/test_admin.py b/openedx/core/djangoapps/discussions/tests/test_admin.py
new file mode 100644
index 000000000000..d16d73dd8bef
--- /dev/null
+++ b/openedx/core/djangoapps/discussions/tests/test_admin.py
@@ -0,0 +1,32 @@
+"""
+Tests for DiscussionsConfiguration admin view
+"""
+from django.test import TestCase
+from django.urls import reverse
+
+from common.djangoapps.student.tests.factories import UserFactory
+from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider
+
+
+class DiscussionsConfigurationAdminTest(TestCase):
+ """
+ Tests for discussion config admin
+ """
+ def setUp(self):
+ super().setUp()
+ self.superuser = UserFactory(is_staff=True, is_superuser=True)
+ self.client.login(username=self.superuser.username, password="Password1234")
+
+ def test_change_view(self):
+ """
+ Test that the DiscussionAdmin's change_view processes the context_key correctly and returns a successful
+ response.
+ """
+ discussion_config = DiscussionsConfiguration.objects.create(
+ context_key='course-v1:test+test+06_25_2024',
+ provider_type=Provider.OPEN_EDX,
+ )
+ url = reverse('admin:discussions_discussionsconfiguration_change', args=[discussion_config.context_key])
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'course-v1:test+test+06_25_2024')
diff --git a/openedx/core/djangoapps/notifications/audience_filters.py b/openedx/core/djangoapps/notifications/audience_filters.py
index 9bd18a90c637..a4be0070e7be 100644
--- a/openedx/core/djangoapps/notifications/audience_filters.py
+++ b/openedx/core/djangoapps/notifications/audience_filters.py
@@ -1,13 +1,19 @@
"""
-Audience based filters for notifications
+Audience based filters for notifications and Notification filters
"""
from abc import abstractmethod
from opaque_keys.edx.keys import CourseKey
+import logging
+from typing import List
+
+from django.utils import timezone
+
from common.djangoapps.course_modes.models import CourseMode
-from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
+from openedx.core.djangoapps.course_date_signals.utils import get_expected_duration
from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
from lms.djangoapps.discussion.django_comment_client.utils import get_users_with_roles
from lms.djangoapps.teams.models import CourseTeam
@@ -18,7 +24,13 @@
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_STUDENT,
+ Role
)
+from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES
+from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
+from xmodule.modulestore.django import modulestore
+
+logger = logging.getLogger(__name__)
class NotificationAudienceFilterBase:
@@ -142,3 +154,100 @@ def filter(self, group_ids):
course_id=self.course_key, id__in=group_ids
).values_list('users__id', flat=True)
return users_in_cohort
+
+
+class NotificationFilter:
+ """
+ Filter notifications based on their type
+ """
+
+ @staticmethod
+ def get_users_with_course_role(user_ids: List[int], course_id: str) -> List[int]:
+ """
+ Get users with a course role for the given course.
+ """
+ return CourseAccessRole.objects.filter(
+ user_id__in=user_ids,
+ course_id=course_id,
+ ).values_list('user_id', flat=True)
+
+ @staticmethod
+ def get_users_with_forum_roles(user_ids: List[int], course_id: str) -> List[int]:
+ """
+ Get users with forum roles for the given course.
+ """
+ return Role.objects.filter(
+
+ course_id=course_id,
+ users__id__in=user_ids,
+ name__in=[
+ FORUM_ROLE_MODERATOR,
+ FORUM_ROLE_COMMUNITY_TA,
+ FORUM_ROLE_ADMINISTRATOR,
+ FORUM_ROLE_GROUP_MODERATOR,
+ ]
+
+ ).values_list('users__id', flat=True)
+
+ def filter_audit_expired_users_with_no_role(self, user_ids, course) -> list:
+ """
+ Check if the user has access to the course this would be true if the user has a course role or a forum role
+ """
+ verified_mode = CourseMode.verified_mode_for_course(course=course, include_expired=True)
+ access_duration = get_expected_duration(course.id)
+ course_time_limit = CourseDurationLimitConfig.current(course_key=course.id)
+ if not verified_mode:
+ logger.debug(
+ "NotificationFilter: Course %s does not have a verified mode, so no users will be filtered out",
+ course.id,
+ )
+ return user_ids
+
+ users_with_course_role = self.get_users_with_course_role(user_ids, course.id)
+ users_with_forum_roles = self.get_users_with_forum_roles(user_ids, course.id)
+ enrollments = CourseEnrollment.objects.filter(
+ user_id__in=user_ids,
+ course_id=course.id,
+ mode=CourseMode.AUDIT,
+ user__is_staff=False,
+ )
+
+ if course_time_limit.enabled_for_course(course.id):
+ enrollments = enrollments.filter(created__gte=course_time_limit.enabled_as_of)
+ logger.debug("NotificationFilter: Number of audit enrollments for course %s: %s", course.id,
+ enrollments.count())
+
+ for enrollment in enrollments:
+ if enrollment.user_id in users_with_course_role or enrollment.user_id in users_with_forum_roles:
+ logger.debug(
+ "NotificationFilter: User %s has a course or forum role for course %s, so they will not be "
+ "filtered out",
+ enrollment.user_id,
+ course.id,
+ )
+ continue
+ content_availability_date = max(enrollment.created, course.start)
+ expiration_date = content_availability_date + access_duration
+ logger.debug("NotificationFilter: content_availability_date: %s and access_duration: %s",
+ content_availability_date, access_duration
+ )
+ if expiration_date and timezone.now() > expiration_date:
+ logger.debug("User %s has expired audit access to course %s", enrollment.user_id, course.id)
+ user_ids.remove(enrollment.user_id)
+ return user_ids
+
+ def apply_filters(self, user_ids, course_key, notification_type) -> list:
+ """
+ Apply all the filters
+ """
+ notification_config = COURSE_NOTIFICATION_TYPES.get(notification_type, {})
+ applicable_filters = notification_config.get('filters', [])
+ course = modulestore().get_course(course_key)
+ for filter_name in applicable_filters:
+ logger.debug(
+ "NotificationFilter: Applying filter %s for notification type %s",
+ filter_name,
+ notification_type,
+ )
+ user_ids = getattr(self, filter_name)(user_ids, course)
+ return user_ids
diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py
index 0287d8f73c19..2c696ec60d13 100644
--- a/openedx/core/djangoapps/notifications/base_notification.py
+++ b/openedx/core/djangoapps/notifications/base_notification.py
@@ -7,6 +7,7 @@
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
from .utils import find_app_in_normalized_apps, find_pref_in_normalized_prefs
from ..django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA
+from .notification_content import get_notification_type_content_function
FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE = 'filter_audit_expired_users_with_no_role'
@@ -109,8 +110,9 @@
'is_core': True,
'info': '',
'non_editable': [],
- 'content_template': _('<{p}><{strong}>{replier_name}{strong}> commented on {author_name}\'s response in '
- 'a post you’re following <{strong}>{post_title}{strong}>{p}>'),
+ 'content_template': _('<{p}><{strong}>{replier_name}{strong}> commented on <{strong}>{author_name}'
+ '{strong}> response in a post you’re following <{strong}>{post_title}'
+ '{strong}>{p}>'),
'content_context': {
'post_title': 'Post title',
'author_name': 'author name',
@@ -169,13 +171,13 @@
'email_template': '',
'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE]
},
- 'course_update': {
+ 'course_updates': {
'notification_app': 'updates',
- 'name': 'course_update',
+ 'name': 'course_updates',
'is_core': False,
'info': '',
'web': True,
- 'email': True,
+ 'email': False,
'push': True,
'email_cadence': EmailCadence.DAILY,
'non_editable': [],
@@ -197,7 +199,7 @@
'push': False,
'email_cadence': EmailCadence.DAILY,
'non_editable': [],
- 'content_template': _('<{p}>You have a new open response submission awaiting for review for : '
+ 'content_template': _('<{p}>You have a new open response submission awaiting for review for '
'<{strong}>{ora_name}{strong}>{p}>'),
'content_context': {
'ora_name': 'Name of ORA in course',
@@ -465,8 +467,13 @@ def get_notification_content(notification_type, context):
'strong': 'strong',
'p': 'p',
}
+ content_function = get_notification_type_content_function(notification_type)
+ if notification_type == 'course_update':
+ notification_type = 'course_updates'
notification_type = NotificationTypeManager().notification_types.get(notification_type, None)
if notification_type:
+ if content_function:
+ return content_function(notification_type, context)
notification_type_content_template = notification_type.get('content_template', None)
if notification_type_content_template:
return notification_type_content_template.format(**context, **html_tags_context)
diff --git a/openedx/core/djangoapps/notifications/email/events.py b/openedx/core/djangoapps/notifications/email/events.py
new file mode 100644
index 000000000000..165539a018cb
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/email/events.py
@@ -0,0 +1,40 @@
+"""
+Events for email notifications
+"""
+import datetime
+
+from eventtracking import tracker
+
+from common.djangoapps.track import segment
+from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_APPS
+
+
+EMAIL_DIGEST_SENT = "edx.notifications.email_digest"
+
+
+def send_user_email_digest_sent_event(user, cadence_type, notifications):
+ """
+ Sends tracker and segment email for user email digest
+ """
+ notification_breakdown = {key: 0 for key in COURSE_NOTIFICATION_APPS.keys()}
+ for notification in notifications:
+ notification_breakdown[notification.app_name] += 1
+ event_data = {
+ "username": user.username,
+ "email": user.email,
+ "cadence_type": cadence_type,
+ "total_notifications_count": len(notifications),
+ "count_breakdown": notification_breakdown,
+ "notification_ids": [notification.id for notification in notifications],
+ "send_at": str(datetime.datetime.now())
+ }
+ with tracker.get_tracker().context(EMAIL_DIGEST_SENT, event_data):
+ tracker.emit(
+ EMAIL_DIGEST_SENT,
+ event_data,
+ )
+ segment.track(
+ 'None',
+ EMAIL_DIGEST_SENT,
+ event_data,
+ )
diff --git a/openedx/core/djangoapps/notifications/email/tasks.py b/openedx/core/djangoapps/notifications/email/tasks.py
index dc42be585cc7..0d450fe9a917 100644
--- a/openedx/core/djangoapps/notifications/email/tasks.py
+++ b/openedx/core/djangoapps/notifications/email/tasks.py
@@ -14,6 +14,7 @@
Notification,
get_course_notification_preference_config_version
)
+from .events import send_user_email_digest_sent_event
from .message_type import EmailNotificationMessageType
from .utils import (
add_headers_to_email_message,
@@ -69,10 +70,12 @@ def get_user_preferences_for_courses(course_ids, user):
return new_preferences
-def send_digest_email_to_user(user, cadence_type, course_language='en', courses_data=None):
+def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_language='en', courses_data=None):
"""
Send [cadence_type] email to user.
Cadence Type can be EmailCadence.DAILY or EmailCadence.WEEKLY
+ start_date: Datetime object
+ end_date: Datetime object
"""
if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]:
raise ValueError('Invalid cadence_type')
@@ -80,7 +83,6 @@ def send_digest_email_to_user(user, cadence_type, course_language='en', courses_
if not is_email_notification_flag_enabled(user):
logger.info(f' Flag disabled for {user.username} ==Temp Log==')
return
- start_date, end_date = get_start_end_date(cadence_type)
notifications = Notification.objects.filter(user=user, email=True,
created__gte=start_date, created__lte=end_date)
if not notifications:
@@ -101,6 +103,7 @@ def send_digest_email_to_user(user, cadence_type, course_language='en', courses_
).personalize(recipient, course_language, message_context)
message = add_headers_to_email_message(message, message_context)
ace.send(message)
+ send_user_email_digest_sent_event(user, cadence_type, notifications)
logger.info(f' Email sent to {user.username} ==Temp Log==')
@@ -113,6 +116,7 @@ def send_digest_email_to_all_users(cadence_type):
logger.info(f' Sending cadence email of type {cadence_type}')
users = get_audience_for_cadence_email(cadence_type)
courses_data = {}
+ start_date, end_date = get_start_end_date(cadence_type)
logger.info(f' Email Cadence Audience {len(users)}')
for user in users:
- send_digest_email_to_user(user, cadence_type, courses_data=courses_data)
+ send_digest_email_to_user(user, cadence_type, start_date, end_date, courses_data=courses_data)
diff --git a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py
index 5c88ef3edb1a..785dcf2a1b54 100644
--- a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py
+++ b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py
@@ -16,6 +16,7 @@
send_digest_email_to_all_users,
send_digest_email_to_user
)
+from openedx.core.djangoapps.notifications.email.utils import get_start_end_date
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -42,8 +43,9 @@ def test_email_is_not_sent_if_no_notifications(self, mock_func):
"""
Tests email is sent iff waffle flag is enabled
"""
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert not mock_func.called
@ddt.data(True, False)
@@ -54,8 +56,9 @@ def test_email_is_sent_iff_flag_enabled(self, flag_value, mock_func):
"""
created_date = datetime.datetime.now() - datetime.timedelta(days=1)
create_notification(self.user, self.course.id, created=created_date)
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, flag_value):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert mock_func.called is flag_value
@patch('edx_ace.ace.send')
@@ -63,9 +66,10 @@ def test_notification_not_send_if_created_on_next_day(self, mock_func):
"""
Tests email is not sent if notification is created on next day
"""
- create_notification(self.user, self.course.id)
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
+ create_notification(self.user, self.course.id, created=end_date + datetime.timedelta(minutes=2))
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert not mock_func.called
@patch('edx_ace.ace.send')
@@ -73,12 +77,35 @@ def test_notification_not_send_if_created_day_before_yesterday(self, mock_func):
"""
Tests email is not sent if notification is created day before yesterday
"""
- created_date = datetime.datetime.now() - datetime.timedelta(days=2)
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
+ created_date = datetime.datetime.now() - datetime.timedelta(days=1, minutes=18)
create_notification(self.user, self.course.id, created=created_date)
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert not mock_func.called
+ @ddt.data(
+ (EmailCadence.DAILY, datetime.datetime.now() - datetime.timedelta(days=1, minutes=30), False),
+ (EmailCadence.DAILY, datetime.datetime.now() - datetime.timedelta(minutes=10), True),
+ (EmailCadence.DAILY, datetime.datetime.now() - datetime.timedelta(days=1), True),
+ (EmailCadence.DAILY, datetime.datetime.now() + datetime.timedelta(minutes=20), False),
+ (EmailCadence.WEEKLY, datetime.datetime.now() - datetime.timedelta(days=7, minutes=30), False),
+ (EmailCadence.WEEKLY, datetime.datetime.now() - datetime.timedelta(days=7), True),
+ (EmailCadence.WEEKLY, datetime.datetime.now() - datetime.timedelta(minutes=20), True),
+ (EmailCadence.WEEKLY, datetime.datetime.now() + datetime.timedelta(minutes=20), False),
+ )
+ @ddt.unpack
+ @patch('edx_ace.ace.send')
+ def test_notification_content(self, cadence_type, created_time, notification_created, mock_func):
+ """
+ Tests email only contains notification created within date
+ """
+ start_date, end_date = get_start_end_date(cadence_type)
+ create_notification(self.user, self.course.id, created=created_time)
+ with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
+ assert mock_func.called is notification_created
+
@ddt.ddt
class TestEmailDigestAudience(ModuleStoreTestCase):
@@ -146,10 +173,11 @@ def test_digest_should_contain_email_enabled_notifications(self, email_value, mo
"""
Tests email is sent only when notifications with email=True exists
"""
- created_date = datetime.datetime.now() - datetime.timedelta(days=1)
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
+ created_date = datetime.datetime.now() - datetime.timedelta(hours=23, minutes=59)
create_notification(self.user, self.course.id, created=created_date, email=email_value)
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert mock_func.called is email_value
@@ -166,7 +194,7 @@ def setUp(self):
self.user = UserFactory()
self.course = CourseFactory.create(display_name='test course', run="Testing_course")
self.preference = CourseNotificationPreference.objects.create(user=self.user, course_id=self.course.id)
- created_date = datetime.datetime.now() - datetime.timedelta(days=1)
+ created_date = datetime.datetime.now() - datetime.timedelta(hours=23)
create_notification(self.user, self.course.id, notification_type='new_discussion_post', created=created_date)
@patch('edx_ace.ace.send')
@@ -174,13 +202,14 @@ def test_email_send_for_digest_preference(self, mock_func):
"""
Tests email is send for digest notification preference
"""
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
config = self.preference.notification_preference_config
types = config['discussion']['notification_types']
types['new_discussion_post']['email_cadence'] = EmailCadence.DAILY
types['new_discussion_post']['email'] = True
self.preference.save()
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert mock_func.called
@ddt.data(True, False)
@@ -189,13 +218,14 @@ def test_email_send_for_email_preference_value(self, pref_value, mock_func):
"""
Tests email is sent iff preference value is True
"""
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
config = self.preference.notification_preference_config
types = config['discussion']['notification_types']
types['new_discussion_post']['email_cadence'] = EmailCadence.DAILY
types['new_discussion_post']['email'] = pref_value
self.preference.save()
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert mock_func.called is pref_value
@patch('edx_ace.ace.send')
@@ -203,10 +233,11 @@ def test_email_not_send_if_different_digest_preference(self, mock_func):
"""
Tests email is not send if digest notification preference doesnot match
"""
+ start_date, end_date = get_start_end_date(EmailCadence.DAILY)
config = self.preference.notification_preference_config
types = config['discussion']['notification_types']
types['new_discussion_post']['email_cadence'] = EmailCadence.WEEKLY
self.preference.save()
with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True):
- send_digest_email_to_user(self.user, EmailCadence.DAILY)
+ send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date)
assert not mock_func.called
diff --git a/openedx/core/djangoapps/notifications/email/tests/test_utils.py b/openedx/core/djangoapps/notifications/email/tests/test_utils.py
index 8d72ffd748b5..6e79c497a4fe 100644
--- a/openedx/core/djangoapps/notifications/email/tests/test_utils.py
+++ b/openedx/core/djangoapps/notifications/email/tests/test_utils.py
@@ -70,7 +70,7 @@ def test_create_app_notifications_dict(self):
"""
Notification.objects.all().delete()
create_notification(self.user, self.course.id, app_name='discussion', notification_type='new_comment')
- create_notification(self.user, self.course.id, app_name='updates', notification_type='course_update')
+ create_notification(self.user, self.course.id, app_name='updates', notification_type='course_updates')
app_dict = create_app_notifications_dict(Notification.objects.all())
assert len(app_dict.keys()) == 2
for key in ['discussion', 'updates']:
@@ -130,7 +130,7 @@ def test_email_digest_context(self, digest_frequency):
discussion_notification = create_notification(self.user, self.course.id, app_name='discussion',
notification_type='new_comment')
update_notification = create_notification(self.user, self.course.id, app_name='updates',
- notification_type='course_update')
+ notification_type='course_updates')
app_dict = create_app_notifications_dict(Notification.objects.all())
end_date = datetime.datetime(2024, 3, 24, 12, 0)
params = {
diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py
index 3ce2306435f2..1e0f4c81c743 100644
--- a/openedx/core/djangoapps/notifications/email/utils.py
+++ b/openedx/core/djangoapps/notifications/email/utils.py
@@ -171,13 +171,10 @@ def get_start_end_date(cadence_type):
"""
if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]:
raise ValueError('Invalid cadence_type')
- date_today = datetime.datetime.now()
- yesterday = date_today - datetime.timedelta(days=1)
- end_date = datetime.datetime.combine(yesterday, datetime.time.max)
- start_date = end_date
+ end_date = datetime.datetime.now()
+ start_date = end_date - datetime.timedelta(days=1, minutes=15)
if cadence_type == EmailCadence.WEEKLY:
- start_date = end_date - datetime.timedelta(days=6)
- start_date = datetime.datetime.combine(start_date, datetime.time.min)
+ start_date = start_date - datetime.timedelta(days=6)
return utc.localize(start_date), utc.localize(end_date)
diff --git a/openedx/core/djangoapps/notifications/filters.py b/openedx/core/djangoapps/notifications/filters.py
deleted file mode 100644
index 804a9f98d76f..000000000000
--- a/openedx/core/djangoapps/notifications/filters.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""
-Notification filters
-"""
-import logging
-from typing import List
-
-from django.utils import timezone
-
-from common.djangoapps.course_modes.models import CourseMode
-from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
-from openedx.core.djangoapps.course_date_signals.utils import get_expected_duration
-from openedx.core.djangoapps.django_comment_common.models import (
- FORUM_ROLE_ADMINISTRATOR,
- FORUM_ROLE_COMMUNITY_TA,
- FORUM_ROLE_GROUP_MODERATOR,
- FORUM_ROLE_MODERATOR,
- Role
-)
-from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES
-from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
-from xmodule.modulestore.django import modulestore
-
-logger = logging.getLogger(__name__)
-
-
-class NotificationFilter:
- """
- Filter notifications based on their type
- """
-
- @staticmethod
- def get_users_with_course_role(user_ids: List[int], course_id: str) -> List[int]:
- """
- Get users with a course role for the given course.
- """
- return CourseAccessRole.objects.filter(
- user_id__in=user_ids,
- course_id=course_id,
- ).values_list('user_id', flat=True)
-
- @staticmethod
- def get_users_with_forum_roles(user_ids: List[int], course_id: str) -> List[int]:
- """
- Get users with forum roles for the given course.
- """
- return Role.objects.filter(
-
- course_id=course_id,
- users__id__in=user_ids,
- name__in=[
- FORUM_ROLE_MODERATOR,
- FORUM_ROLE_COMMUNITY_TA,
- FORUM_ROLE_ADMINISTRATOR,
- FORUM_ROLE_GROUP_MODERATOR,
- ]
-
- ).values_list('users__id', flat=True)
-
- def filter_audit_expired_users_with_no_role(self, user_ids, course) -> list:
- """
- Check if the user has access to the course this would be true if the user has a course role or a forum role
- """
- verified_mode = CourseMode.verified_mode_for_course(course=course, include_expired=True)
- access_duration = get_expected_duration(course.id)
- course_time_limit = CourseDurationLimitConfig.current(course_key=course.id)
- if not verified_mode:
- logger.debug(
- "NotificationFilter: Course %s does not have a verified mode, so no users will be filtered out",
- course.id,
- )
- return user_ids
-
- users_with_course_role = self.get_users_with_course_role(user_ids, course.id)
- users_with_forum_roles = self.get_users_with_forum_roles(user_ids, course.id)
- enrollments = CourseEnrollment.objects.filter(
- user_id__in=user_ids,
- course_id=course.id,
- mode=CourseMode.AUDIT,
- user__is_staff=False,
- )
-
- if course_time_limit.enabled_for_course(course.id):
- enrollments = enrollments.filter(created__gte=course_time_limit.enabled_as_of)
- logger.debug("NotificationFilter: Number of audit enrollments for course %s: %s", course.id,
- enrollments.count())
-
- for enrollment in enrollments:
- if enrollment.user_id in users_with_course_role or enrollment.user_id in users_with_forum_roles:
- logger.debug(
- "NotificationFilter: User %s has a course or forum role for course %s, so they will not be "
- "filtered out",
- enrollment.user_id,
- course.id,
- )
- continue
- content_availability_date = max(enrollment.created, course.start)
- expiration_date = content_availability_date + access_duration
- logger.debug("NotificationFilter: content_availability_date: %s and access_duration: %s",
- content_availability_date, access_duration
- )
- if expiration_date and timezone.now() > expiration_date:
- logger.debug("User %s has expired audit access to course %s", enrollment.user_id, course.id)
- user_ids.remove(enrollment.user_id)
- return user_ids
-
- def apply_filters(self, user_ids, course_key, notification_type) -> list:
- """
- Apply all the filters
- """
- notification_config = COURSE_NOTIFICATION_TYPES.get(notification_type, {})
- applicable_filters = notification_config.get('filters', [])
- course = modulestore().get_course(course_key)
- for filter_name in applicable_filters:
- logger.debug(
- "NotificationFilter: Applying filter %s for notification type %s",
- filter_name,
- notification_type,
- )
- user_ids = getattr(self, filter_name)(user_ids, course)
- return user_ids
diff --git a/openedx/core/djangoapps/notifications/migrations/0006_notification_group_by_id.py b/openedx/core/djangoapps/notifications/migrations/0006_notification_group_by_id.py
new file mode 100644
index 000000000000..5a45b9c25a8a
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/migrations/0006_notification_group_by_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.13 on 2024-07-23 07:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0005_notification_email_notification_web'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='notification',
+ name='group_by_id',
+ field=models.CharField(db_index=True, default='', max_length=42),
+ ),
+ ]
diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py
index 2f7da1803bf6..e1bdf94acc33 100644
--- a/openedx/core/djangoapps/notifications/models.py
+++ b/openedx/core/djangoapps/notifications/models.py
@@ -23,7 +23,7 @@
ADDITIONAL_NOTIFICATION_CHANNEL_SETTINGS = ['email_cadence']
# Update this version when there is a change to any course specific notification type or app.
-COURSE_NOTIFICATION_CONFIG_VERSION = 10
+COURSE_NOTIFICATION_CONFIG_VERSION = 11
def get_course_notification_preference_config():
@@ -109,6 +109,7 @@ class Notification(TimeStampedModel):
email = models.BooleanField(default=False, null=False, blank=False)
last_read = models.DateTimeField(null=True, blank=True)
last_seen = models.DateTimeField(null=True, blank=True)
+ group_by_id = models.CharField(max_length=42, db_index=True, null=False, default="")
def __str__(self):
return f'{self.user.username} - {self.course_id} - {self.app_name} - {self.notification_type}'
diff --git a/openedx/core/djangoapps/notifications/notification_content.py b/openedx/core/djangoapps/notifications/notification_content.py
new file mode 100644
index 000000000000..1dcdc4fb5a6c
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/notification_content.py
@@ -0,0 +1,35 @@
+"""
+Helper functions for overriding notification content for given notification type.
+"""
+
+
+def get_notification_type_content_function(notification_type):
+ """
+ Returns the content function for the given notification if it exists.
+ """
+ try:
+ return globals()[f"get_{notification_type}_notification_content"]
+ except KeyError:
+ return None
+
+
+def get_notification_content_with_author_pronoun(notification_type, context):
+ """
+ Helper function to get notification content with author's pronoun.
+ """
+ html_tags_context = {
+ 'strong': 'strong',
+ 'p': 'p',
+ }
+ notification_type_content_template = notification_type.get('content_template', None)
+ if 'author_pronoun' in context:
+ context['author_name'] = context['author_pronoun']
+ if notification_type_content_template:
+ return notification_type_content_template.format(**context, **html_tags_context)
+ return ''
+
+
+# Returns notification content for the new_comment notification.
+get_new_comment_notification_content = get_notification_content_with_author_pronoun
+# Returns notification content for the comment_on_followed_post notification.
+get_comment_on_followed_post_notification_content = get_notification_content_with_author_pronoun
diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py
index d56fb820f64a..79c8c4af9d13 100644
--- a/openedx/core/djangoapps/notifications/serializers.py
+++ b/openedx/core/djangoapps/notifications/serializers.py
@@ -184,60 +184,6 @@ def update(self, instance, validated_data):
return instance
-class UserNotificationChannelPreferenceUpdateSerializer(serializers.Serializer):
- """
- Serializer for user notification preferences update for an entire channel.
- """
-
- notification_app = serializers.CharField()
- value = serializers.BooleanField()
- notification_channel = serializers.CharField(required=False)
-
- def validate(self, attrs):
- """
- Validation for notification preference update form
- """
- notification_app = attrs.get('notification_app')
- notification_channel = attrs.get('notification_channel')
-
- notification_app_config = self.instance.notification_preference_config
-
- if not notification_channel:
- raise ValidationError(
- 'notification_channel is required for notification_type.'
- )
-
- if not notification_app_config.get(notification_app, None):
- raise ValidationError(
- f'{notification_app} is not a valid notification app.'
- )
-
- if notification_channel and notification_channel not in get_notification_channels():
- raise ValidationError(
- f'{notification_channel} is not a valid notification channel.'
- )
-
- return attrs
-
- def update(self, instance, validated_data):
- """
- Update notification preference config.
- """
- notification_app = validated_data.get('notification_app')
- notification_channel = validated_data.get('notification_channel')
- value = validated_data.get('value')
- user_notification_preference_config = instance.notification_preference_config
-
- app_prefs = user_notification_preference_config[notification_app]
- for notification_type_name, notification_type_preferences in app_prefs['notification_types'].items():
- non_editable_channels = app_prefs['non_editable'].get(notification_type_name, [])
- if notification_channel not in non_editable_channels:
- app_prefs['notification_types'][notification_type_name][notification_channel] = value
-
- instance.save()
- return instance
-
-
class NotificationSerializer(serializers.ModelSerializer):
"""
Serializer for the Notification model.
diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py
index 75ae61a8147e..2934355e0365 100644
--- a/openedx/core/djangoapps/notifications/tasks.py
+++ b/openedx/core/djangoapps/notifications/tasks.py
@@ -20,7 +20,7 @@
)
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.events import notification_generated_event
-from openedx.core.djangoapps.notifications.filters import NotificationFilter
+from openedx.core.djangoapps.notifications.audience_filters import NotificationFilter
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
Notification,
diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html
index 1da86f48f0b1..13ac89d4ec29 100644
--- a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html
+++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html
@@ -33,7 +33,7 @@
- {{ notification.content | safe }}
+ {{ notification.content | truncatechars_html:600 | safe }}
diff --git a/openedx/core/djangoapps/notifications/templates/notifications/email_digest_preference_update.html b/openedx/core/djangoapps/notifications/templates/notifications/email_digest_preference_update.html
new file mode 100644
index 000000000000..79e88012f313
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/templates/notifications/email_digest_preference_update.html
@@ -0,0 +1,13 @@
+
+
+
+
+ {{ _("Email Digest Preferences Updated") }}
+
+
+
+
+
diff --git a/openedx/core/djangoapps/notifications/tests/test_filters.py b/openedx/core/djangoapps/notifications/tests/test_filters.py
index 4391d48852d0..c8e0bdfccfb7 100644
--- a/openedx/core/djangoapps/notifications/tests/test_filters.py
+++ b/openedx/core/djangoapps/notifications/tests/test_filters.py
@@ -28,8 +28,8 @@
CourseRoleAudienceFilter,
CohortAudienceFilter,
TeamAudienceFilter,
+ NotificationFilter,
)
-from openedx.core.djangoapps.notifications.filters import NotificationFilter
from openedx.core.djangoapps.notifications.handlers import calculate_course_wide_notification_audience
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
@@ -96,7 +96,8 @@ def test_audit_expired_filter_with_no_role(
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
@mock.patch(
- "openedx.core.djangoapps.notifications.filters.NotificationFilter.filter_audit_expired_users_with_no_role")
+ "openedx.core.djangoapps.notifications.audience_filters.NotificationFilter"
+ ".filter_audit_expired_users_with_no_role")
def test_apply_filter(
self,
mock_filter_audit_expired,
diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py
index 5058c0f492c3..706ae2989842 100644
--- a/openedx/core/djangoapps/notifications/tests/test_tasks.py
+++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py
@@ -390,7 +390,7 @@ def test_app_name_param(self):
"""
assert not Notification.objects.all()
create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment')
- create_notification(self.user, self.course_1.id, app_name='updates', notification_type='course_update')
+ create_notification(self.user, self.course_1.id, app_name='updates', notification_type='course_updates')
delete_notifications({'app_name': 'discussion'})
assert not Notification.objects.filter(app_name='discussion')
assert Notification.objects.filter(app_name='updates')
diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py
index 413c1ce1521b..d2968749efab 100644
--- a/openedx/core/djangoapps/notifications/tests/test_views.py
+++ b/openedx/core/djangoapps/notifications/tests/test_views.py
@@ -275,9 +275,9 @@ def _expected_api_response(self, course=None):
'enabled': True,
'core_notification_types': [],
'notification_types': {
- 'course_update': {
+ 'course_updates': {
'web': True,
- 'email': True,
+ 'email': False,
'push': True,
'email_cadence': 'Daily',
'info': ''
@@ -459,201 +459,6 @@ def test_info_is_not_saved_in_json(self):
assert 'info' not in type_prefs.keys()
-@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
-@ddt.ddt
-class UserNotificationChannelPreferenceAPITest(ModuleStoreTestCase):
- """
- Test for user notification channel preference API.
- """
-
- def setUp(self):
- super().setUp()
- self.user = UserFactory()
- self.course = CourseFactory.create(
- org='testorg',
- number='testcourse',
- run='testrun'
- )
-
- course_overview = CourseOverviewFactory.create(id=self.course.id, org='AwesomeOrg')
- self.course_enrollment = CourseEnrollment.objects.create(
- user=self.user,
- course=course_overview,
- is_active=True,
- mode='audit'
- )
- self.client = APIClient()
- self.path = reverse('notification-channel-preferences', kwargs={'course_key_string': self.course.id})
-
- enrollment_data = CourseEnrollmentData(
- user=UserData(
- pii=UserPersonalData(
- username=self.user.username,
- email=self.user.email,
- name=self.user.profile.name,
- ),
- id=self.user.id,
- is_active=self.user.is_active,
- ),
- course=CourseData(
- course_key=self.course.id,
- display_name=self.course.display_name,
- ),
- mode=self.course_enrollment.mode,
- is_active=self.course_enrollment.is_active,
- creation_date=self.course_enrollment.created,
- )
- COURSE_ENROLLMENT_CREATED.send_event(
- enrollment=enrollment_data
- )
-
- def _expected_api_response(self, course=None):
- """
- Helper method to return expected API response.
- """
- if course is None:
- course = self.course
- response = {
- 'id': 1,
- 'course_name': 'course-v1:testorg+testcourse+testrun Course',
- 'course_id': 'course-v1:testorg+testcourse+testrun',
- 'notification_preference_config': {
- 'discussion': {
- 'enabled': True,
- 'core_notification_types': [
- 'new_comment_on_response',
- 'new_comment',
- 'new_response',
- 'response_on_followed_post',
- 'comment_on_followed_post',
- 'response_endorsed_on_thread',
- 'response_endorsed'
- ],
- 'notification_types': {
- 'core': {
- 'web': True,
- 'email': True,
- 'push': True,
- 'email_cadence': 'Daily',
- 'info': 'Notifications for responses and comments on your posts, and the ones you’re '
- 'following, including endorsements to your responses and on your posts.'
- },
- 'new_discussion_post': {
- 'web': False,
- 'email': False,
- 'push': False,
- 'email_cadence': 'Daily',
- 'info': ''
- },
- 'new_question_post': {
- 'web': False,
- 'email': False,
- 'push': False,
- 'email_cadence': 'Daily',
- 'info': ''
- },
- 'content_reported': {
- 'web': True,
- 'email': True,
- 'push': True,
- 'email_cadence': 'Daily',
- 'info': ''
- },
- },
- 'non_editable': {
- 'core': ['web']
- }
- },
- 'updates': {
- 'enabled': True,
- 'core_notification_types': [
-
- ],
- 'notification_types': {
- 'course_update': {
- 'web': True,
- 'email': True,
- 'push': True,
- 'email_cadence': 'Daily',
- 'info': ''
- },
- 'core': {
- 'web': True,
- 'email': True,
- 'push': True,
- 'email_cadence': 'Daily',
- 'info': 'Notifications for new announcements and updates from the course team.'
- }
- },
- 'non_editable': {}
- },
- 'grading': {
- 'enabled': True,
- 'core_notification_types': [],
- 'notification_types': {
- 'ora_staff_notification': {
- 'web': False,
- 'email': False,
- 'push': False,
- 'email_cadence': 'Daily',
- 'info': ''
- },
- 'core': {
- 'web': True,
- 'email': True,
- 'push': True,
- 'email_cadence': 'Daily',
- 'info': 'Notifications for submission grading.'
- }
- },
- 'non_editable': {}
- }
- }
- }
- return response
-
- @ddt.data(
- ('discussion', 'web', True, status.HTTP_200_OK),
- ('discussion', 'web', False, status.HTTP_200_OK),
-
- ('invalid_notification_app', 'web', False, status.HTTP_400_BAD_REQUEST),
- ('discussion', 'invalid_notification_channel', False, status.HTTP_400_BAD_REQUEST),
- )
- @ddt.unpack
- @mock.patch("eventtracking.tracker.emit")
- def test_patch_user_notification_preference(
- self, notification_app, notification_channel, value, expected_status, mock_emit,
- ):
- """
- Test update of user notification channel preference.
- """
- self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- payload = {
- 'notification_app': notification_app,
- 'value': value,
- }
- if notification_channel:
- payload['notification_channel'] = notification_channel
-
- response = self.client.patch(self.path, json.dumps(payload), content_type='application/json')
- self.assertEqual(response.status_code, expected_status)
-
- if expected_status == status.HTTP_200_OK:
- expected_data = self._expected_api_response()
- expected_app_prefs = expected_data['notification_preference_config'][notification_app]
- for notification_type, __ in expected_app_prefs['notification_types'].items():
- non_editable_channels = expected_app_prefs['non_editable'].get(notification_type, [])
- if notification_channel not in non_editable_channels:
- expected_app_prefs['notification_types'][notification_type][notification_channel] = value
- expected_data = remove_notifications_with_visibility_settings(expected_data)
- self.assertEqual(response.data, expected_data)
- event_name, event_data = mock_emit.call_args[0]
- self.assertEqual(event_name, 'edx.notifications.preferences.updated')
- self.assertEqual(event_data['notification_app'], notification_app)
- self.assertEqual(event_data['notification_channel'], notification_channel)
- self.assertEqual(event_data['value'], value)
-
-
@ddt.ddt
class NotificationListAPIViewTest(APITestCase):
"""
diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py
index 9904010fb33f..7f611bc2c4ca 100644
--- a/openedx/core/djangoapps/notifications/urls.py
+++ b/openedx/core/djangoapps/notifications/urls.py
@@ -11,7 +11,6 @@
NotificationCountView,
NotificationListAPIView,
NotificationReadAPIView,
- UserNotificationChannelPreferenceView,
UserNotificationPreferenceView,
preference_update_from_encrypted_username_view,
)
@@ -26,11 +25,6 @@
UserNotificationPreferenceView.as_view(),
name='notification-preferences'
),
- re_path(
- fr'^channel/configurations/{settings.COURSE_KEY_PATTERN}$',
- UserNotificationChannelPreferenceView.as_view(),
- name='notification-channel-preferences'
- ),
path('', NotificationListAPIView.as_view(), name='notifications-list'),
path('count/', NotificationCountView.as_view(), name='notifications-count'),
path(
diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py
index 0c3f4d0ba945..ee5e282d905e 100644
--- a/openedx/core/djangoapps/notifications/views.py
+++ b/openedx/core/djangoapps/notifications/views.py
@@ -5,8 +5,7 @@
from django.conf import settings
from django.db.models import Count
-from django.http import HttpResponse
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, render
from django.utils.translation import gettext as _
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
@@ -38,7 +37,7 @@
NotificationCourseEnrollmentSerializer,
NotificationSerializer,
UserCourseNotificationPreferenceSerializer,
- UserNotificationPreferenceUpdateSerializer, UserNotificationChannelPreferenceUpdateSerializer,
+ UserNotificationPreferenceUpdateSerializer,
)
from .utils import get_show_notifications_tray
@@ -239,55 +238,6 @@ def patch(self, request, course_key_string):
return Response(serializer.data, status=status.HTTP_200_OK)
-@allow_any_authenticated_user()
-class UserNotificationChannelPreferenceView(APIView):
- """
- Supports retrieving and patching the UserNotificationPreference
- model.
- **Example Requests**
- PATCH /api/notifications/configurations/{course_id}
- """
-
- def patch(self, request, course_key_string):
- """
- Update an existing user notification preference for an entire channel with the data in the request body.
-
- Parameters:
- request (Request): The request object
- course_key_string (int): The ID of the course of the notification preference to be updated.
- Returns:
- 200: The updated preference, serialized using the UserNotificationPreferenceSerializer
- 404: If the preference does not exist
- 403: If the user does not have permission to update the preference
- 400: Validation error
- """
- course_id = CourseKey.from_string(course_key_string)
- user_course_notification_preference = CourseNotificationPreference.objects.get(
- user=request.user,
- course_id=course_id,
- is_active=True,
- )
- if user_course_notification_preference.config_version != get_course_notification_preference_config_version():
- return Response(
- {'error': _('The notification preference config version is not up to date.')},
- status=status.HTTP_409_CONFLICT,
- )
-
- preference_update = UserNotificationChannelPreferenceUpdateSerializer(
- user_course_notification_preference, data=request.data, partial=True
- )
- preference_update.is_valid(raise_exception=True)
- updated_notification_preferences = preference_update.save()
- notification_preference_update_event(request.user, course_id, preference_update.validated_data)
- serializer_context = {
- 'course_id': course_id,
- 'user': request.user
- }
- serializer = UserCourseNotificationPreferenceSerializer(updated_notification_preferences,
- context=serializer_context)
- return Response(serializer.data, status=status.HTTP_200_OK)
-
-
@allow_any_authenticated_user()
class NotificationListAPIView(generics.ListAPIView):
"""
@@ -491,4 +441,7 @@ def preference_update_from_encrypted_username_view(request, username, patch):
username and patch must be string
"""
update_user_preferences_from_patch(username, patch)
- return HttpResponse("Success", status=status.HTTP_200_OK)
+ context = {
+ "notification_preferences_url": f"{settings.ACCOUNT_MICROFRONTEND_URL}/notifications"
+ }
+ return render(request, "notifications/email_digest_preference_update.html", context=context)
diff --git a/openedx/core/djangoapps/user_authn/tests/test_tasks.py b/openedx/core/djangoapps/user_authn/tests/test_tasks.py
index 80516f20a39c..5103343a0879 100644
--- a/openedx/core/djangoapps/user_authn/tests/test_tasks.py
+++ b/openedx/core/djangoapps/user_authn/tests/test_tasks.py
@@ -19,6 +19,7 @@ class SendActivationEmailTestCase(TestCase):
"""
Test for send activation email to user
"""
+
def setUp(self):
""" Setup components used by each test."""
super().setUp()
@@ -44,6 +45,7 @@ def test_ComposeEmail(self):
assert self.msg.context['routed_profile_name'] == ''
assert self.msg.context['registration_flow'] is False
assert self.msg.context['is_enterprise_learner'] is False
+ assert self.msg.context['is_first_purchase_discount_overridden'] is False
@mock.patch('time.sleep', mock.Mock(return_value=None))
@mock.patch('openedx.core.djangoapps.user_authn.tasks.log')
diff --git a/openedx/core/djangoapps/xblock/learning_context/learning_context.py b/openedx/core/djangoapps/xblock/learning_context/learning_context.py
index 1ac621ef244f..2dc5155dc4e2 100644
--- a/openedx/core/djangoapps/xblock/learning_context/learning_context.py
+++ b/openedx/core/djangoapps/xblock/learning_context/learning_context.py
@@ -58,3 +58,10 @@ def definition_for_usage(self, usage_key, **kwargs):
Retuns None if the usage key doesn't exist in this context.
"""
raise NotImplementedError
+
+ def send_block_updated_event(self, usage_key):
+ """
+ Send a "block updated" event for the block with the given usage_key in this context.
+
+ usage_key: the UsageKeyV2 subclass used for this learning context
+ """
diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py
index 3722d9d8ab15..8c2d16839a67 100644
--- a/openedx/core/djangoapps/xblock/rest_api/views.py
+++ b/openedx/core/djangoapps/xblock/rest_api/views.py
@@ -21,6 +21,7 @@
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
+from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
from openedx.core.lib.api.view_utils import view_auth_classes
from ..api import (
get_block_metadata,
@@ -254,6 +255,10 @@ def post(self, request, usage_key_str):
# Save after the callback so any changes made in the callback will get persisted.
block.save()
+ # Signal that we've modified this block
+ context_impl = get_learning_context_impl(usage_key)
+ context_impl.send_block_updated_event(usage_key)
+
return Response({
"id": str(block.location),
"data": data,
diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py
index b158a5f45a42..97d6f74403bd 100644
--- a/openedx/features/discounts/applicability.py
+++ b/openedx/features/discounts/applicability.py
@@ -13,6 +13,7 @@
import pytz
from crum import get_current_request, impersonate
+from django.conf import settings
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from edx_toggles.toggles import WaffleFlag
@@ -31,7 +32,7 @@
# .. toggle_name: discounts.enable_first_purchase_discount_override
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
-# .. toggle_description: Waffle flag to enable the First Purchase Discount to be overriden from
+# .. toggle_description: Waffle flag to enable the First Purchase Discount to be overridden from
# EDXWELCOME/BIENVENIDOAEDX 15% discount to a new code.
# .. toggle_use_cases: opt_in
# .. toggle_creation_date: 2024-07-18
@@ -227,6 +228,13 @@ def discount_percentage(course):
"""
Get the configured discount amount.
"""
+ if FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG.is_enabled():
+ return getattr(
+ settings,
+ 'FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE',
+ 15
+ )
+
configured_percentage = DiscountPercentageConfig.current(course_key=course.id).percentage
if configured_percentage:
return configured_percentage
diff --git a/openedx/features/discounts/tests/test_utils.py b/openedx/features/discounts/tests/test_utils.py
index 1ff305831d74..6bd9d1cd1593 100644
--- a/openedx/features/discounts/tests/test_utils.py
+++ b/openedx/features/discounts/tests/test_utils.py
@@ -5,7 +5,7 @@
import ddt
from django.contrib.auth.models import AnonymousUser
-from django.test import TestCase
+from django.test import TestCase, override_settings
from django.utils.translation import override as override_lang
from edx_toggles.toggles.testutils import override_waffle_flag
@@ -14,7 +14,11 @@
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
-from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG, get_discount_expiration_date
+from openedx.features.discounts.applicability import (
+ DISCOUNT_APPLICABILITY_FLAG,
+ FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG,
+ get_discount_expiration_date
+)
from .. import utils
@@ -84,6 +88,14 @@ def test_spanish_code(self):
with override_lang('es-419'):
assert utils.generate_offer_data(self.user, self.overview)['code'] == 'BIENVENIDOAEDX'
+ def test_override(self):
+ with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE='NOTEDXWELCOME'):
+ with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE=30):
+ with override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True):
+ with override_waffle_flag(FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG, active=True):
+ assert utils.generate_offer_data(self.user, self.overview)['code'] == 'NOTEDXWELCOME'
+ assert utils.generate_offer_data(self.user, self.overview)['percentage'] == 30
+
def test_anonymous(self):
assert utils.generate_offer_data(AnonymousUser(), self.overview) is None
diff --git a/openedx/features/discounts/utils.py b/openedx/features/discounts/utils.py
index f52f821d9a9d..e97524d4381a 100644
--- a/openedx/features/discounts/utils.py
+++ b/openedx/features/discounts/utils.py
@@ -5,6 +5,7 @@
from datetime import datetime
import pytz
+from django.conf import settings
from django.utils.translation import get_language
from django.utils.translation import gettext as _
@@ -13,6 +14,7 @@
from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link
from openedx.core.djangolib.markup import HTML
from openedx.features.discounts.applicability import (
+ FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG,
REV1008_EXPERIMENT_ID,
can_receive_discount,
discount_percentage,
@@ -98,8 +100,17 @@ def generate_offer_data(user, course):
original, discounted, percentage = _get_discount_prices(user, course, assume_discount=True)
+ # Override the First Purchase Discount to another code only if flag is enabled
+ first_purchase_discount_code = 'BIENVENIDOAEDX' if get_language() == 'es-419' else 'EDXWELCOME'
+ if FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG.is_enabled():
+ first_purchase_discount_code = getattr(
+ settings,
+ 'FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE',
+ first_purchase_discount_code
+ )
+
return {
- 'code': 'BIENVENIDOAEDX' if get_language() == 'es-419' else 'EDXWELCOME',
+ 'code': first_purchase_discount_code,
'expiration_date': expiration_date,
'original_price': original,
'discounted_price': discounted,
diff --git a/package-lock.json b/package-lock.json
index 04728ccaab91..02dde6f701bc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -59,7 +59,7 @@
"react-slick": "0.29.0",
"redux": "3.7.2",
"redux-thunk": "2.2.0",
- "requirejs": "2.3.6",
+ "requirejs": "2.3.7",
"rtlcss": "2.6.2",
"sass": "^1.54.8",
"sass-loader": "^14.1.1",
@@ -20862,9 +20862,10 @@
}
},
"node_modules/requirejs": {
- "version": "2.3.6",
- "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz",
- "integrity": "sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==",
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.7.tgz",
+ "integrity": "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw==",
+ "license": "MIT",
"bin": {
"r_js": "bin/r.js",
"r.js": "bin/r.js"
diff --git a/package.json b/package.json
index d182d83d3151..49ab2820d11a 100644
--- a/package.json
+++ b/package.json
@@ -65,7 +65,7 @@
"react-slick": "0.29.0",
"redux": "3.7.2",
"redux-thunk": "2.2.0",
- "requirejs": "2.3.6",
+ "requirejs": "2.3.7",
"rtlcss": "2.6.2",
"sass": "^1.54.8",
"sass-loader": "^14.1.1",
diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt
index 4abc9ae22cb3..9405a605c520 100644
--- a/requirements/common_constraints.txt
+++ b/requirements/common_constraints.txt
@@ -21,6 +21,7 @@ Django<5.0
# elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process.
# elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html
+# See https://github.com/openedx/edx-platform/issues/35126 for more info
elasticsearch<7.14.0
# django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected
@@ -33,3 +34,10 @@ elasticsearch<7.14.0
# So we need to pin it globally, for now.
# Ticket for unpinning: https://github.com/openedx/edx-lint/issues/407
importlib-metadata<7
+
+# Cause: https://github.com/openedx/event-tracking/pull/290
+# event-tracking 2.4.1 upgrades to pymongo 4.4.0 which is not supported on edx-platform.
+# We will pin event-tracking to do not break existing installations
+# This can be unpinned once https://github.com/openedx/edx-platform/issues/34586
+# has been resolved and edx-platform is running with pymongo>=4.4.0
+
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index b404440e04dd..02cd4b01ab08 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -20,7 +20,7 @@ celery>=5.2.2,<6.0.0
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==4.21.5
+edx-enterprise==4.23.3
# Stay on LTS version, remove once this is added to common constraint
Django<5.0
@@ -32,9 +32,13 @@ django-oauth-toolkit==1.7.1
# incremental upgrade
django-simple-history==3.4.0
-# constrained in opaque_keys. migration guide here: https://pymongo.readthedocs.io/en/4.0/migrate-to-pymongo4.html
-# Major upgrade will be done in separate ticket.
-pymongo<4.0.0
+# Adding pin to avoid any major upgrade
+pymongo<4.4.1
+
+# To override the constraint of edx-lint
+# This can be removed once https://github.com/openedx/edx-platform/issues/34586 is resolved
+# and the upstream constraint in edx-lint has been removed.
+event-tracking==3.0.0
# greater version has breaking changes and requires some migration steps.
django-webpack-loader==0.7.0
@@ -59,14 +63,6 @@ pycodestyle<2.9.0
pylint<2.16.0 # greater version failing quality test. Fix them in seperate ticket.
-# adding these constraints to minimize boto3 and botocore changeset
-social-auth-core==4.3.0
-
-# social-auth-app-django versions after 5.2.0 has a problematic migration that will cause issues deployments with large
-# `social_auth_usersocialauth` tables. 5.1.0 has missing migration and 5.2.0 has that problematic migration.
-social-auth-app-django==5.0.0
-
-
# urllib3>=2.0.0 conflicts with elastic search && snowflake-connector-python packages
# which require urllib3<2 for now.
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/32222
@@ -91,7 +87,7 @@ libsass==0.10.0
click==8.1.6
# pinning this version to avoid updates while the library is being developed
-openedx-learning==0.10.0
+openedx-learning==0.10.1
# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.
openai<=0.28.1
@@ -129,3 +125,18 @@ backports.zoneinfo;python_version<"3.9" # Newer versions have zoneinfo availabl
# Otherwise we see a failure while running the following command:
# export DJANGO_SETTINGS_MODULE=cms.envs.test; python manage.py cms check_reserved_keywords --override_file db_keyword_overrides.yml --report_path reports/reserved_keywords --report_file cms_reserved_keyword_report.csv
numpy<2.0.0
+
+# django-storages==1.14.4 breaks course imports
+# Two lines were added in 1.14.4 that make file_exists_in_storage function always return False,
+# as the default value of AWS_S3_FILE_OVERWRITE is True
+django-storages<1.14.4
+
+# social-auth-app-django 5.4.2 introduces a new migration that will not play nicely with large installations. This will touch
+# user tables, which are quite large, especially on instances like edx.org.
+# We are pinning this until after all the smaller migrations get handled and then we can migrate this all at once.
+# Ticket to unpin: https://github.com/edx/edx-arch-experiments/issues/760
+social-auth-app-django<=5.4.1
+
+# Xblock==5.0.0 changed how entrypoints were loaded, breaking a workaround for overriding blocks.
+# See ticket: https://github.com/openedx/XBlock/issues/777
+xblock[django]==4.0.1
diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt
index 241deba480cf..8942bc7dd9cb 100644
--- a/requirements/edx-sandbox/base.txt
+++ b/requirements/edx-sandbox/base.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-cffi==1.16.0
+cffi==1.17.0
# via cryptography
chem==1.3.0
# via -r requirements/edx-sandbox/base.in
@@ -16,7 +16,7 @@ codejail-includes==1.0.0
# via -r requirements/edx-sandbox/base.in
contourpy==1.2.1
# via matplotlib
-cryptography==42.0.8
+cryptography==43.0.0
# via -r requirements/edx-sandbox/base.in
cycler==0.12.1
# via matplotlib
@@ -35,7 +35,7 @@ markupsafe==2.1.5
# via
# chem
# openedx-calc
-matplotlib==3.9.1
+matplotlib==3.9.0
# via -r requirements/edx-sandbox/base.in
mpmath==1.3.0
# via sympy
@@ -71,7 +71,7 @@ python-dateutil==2.9.0.post0
# via matplotlib
random2==1.0.2
# via -r requirements/edx-sandbox/base.in
-regex==2024.5.15
+regex==2024.7.24
# via nltk
scipy==1.14.0
# via
@@ -82,9 +82,9 @@ six==1.16.0
# via
# codejail-includes
# python-dateutil
-sympy==1.13.0
+sympy==1.13.1
# via
# -r requirements/edx-sandbox/base.in
# openedx-calc
-tqdm==4.66.4
+tqdm==4.66.5
# via nltk
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 1259d5bda7fb..66923e250104 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -8,7 +8,9 @@
# via -r requirements/edx/github.in
acid-xblock==0.3.1
# via -r requirements/edx/kernel.in
-aiohttp==3.9.5
+aiohappyeyeballs==2.3.4
+ # via aiohttp
+aiohttp==3.10.1
# via
# geoip2
# openai
@@ -33,7 +35,7 @@ asgiref==3.8.1
# django-countries
asn1crypto==1.5.1
# via snowflake-connector-python
-attrs==23.2.0
+attrs==24.2.0
# via
# -r requirements/edx/kernel.in
# aiohttp
@@ -50,7 +52,7 @@ babel==2.15.0
# enmerkar-underscore
backoff==1.10.0
# via analytics-python
-bcrypt==4.1.3
+bcrypt==4.2.0
# via paramiko
beautifulsoup4==4.12.3
# via pynliner
@@ -66,19 +68,23 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/kernel.in
-boto3==1.34.144
+boto3==1.34.154
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
-botocore==1.34.144
+botocore==1.34.154
# via
# -r requirements/edx/kernel.in
# boto3
# s3transfer
bridgekeeper==0.9
# via -r requirements/edx/kernel.in
+cachecontrol==0.14.0
+ # via firebase-admin
+cachetools==5.4.0
+ # via google-auth
camel-converter[pydantic]==3.1.2
# via meilisearch
celery==5.4.0
@@ -98,7 +104,7 @@ certifi==2024.7.4
# py2neo
# requests
# snowflake-connector-python
-cffi==1.16.0
+cffi==1.17.0
# via
# cryptography
# pynacl
@@ -160,7 +166,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.14
+django==4.2.15
# via
# -c requirements/edx/../common_constraints.txt
# -c requirements/edx/../constraints.txt
@@ -179,6 +185,7 @@ django==4.2.14
# django-multi-email-field
# django-mysql
# django-oauth-toolkit
+ # django-push-notifications
# django-sekizai
# django-ses
# django-statici18n
@@ -227,6 +234,7 @@ django==4.2.14
# openedx-filters
# openedx-learning
# ora2
+ # social-auth-app-django
# super-csv
# xblock-google-drive
# xss-utils
@@ -261,7 +269,7 @@ django-crum==0.7.9
# super-csv
django-fernet-fields-v2==0.9
# via edx-enterprise
-django-filter==24.2
+django-filter==24.3
# via
# -r requirements/edx/kernel.in
# edx-enterprise
@@ -310,6 +318,8 @@ django-object-actions==4.2.0
# via edx-enterprise
django-pipeline==3.1.0
# via -r requirements/edx/kernel.in
+django-push-notifications==3.1.0
+ # via edx-ace
django-ratelimit==4.1.0
# via -r requirements/edx/kernel.in
django-sekizai==4.1.0
@@ -333,8 +343,9 @@ django-statici18n==2.5.0
# lti-consumer-xblock
# xblock-drag-and-drop-v2
# xblock-poll
-django-storages==1.14.4
+django-storages==1.14.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# edxval
django-user-tasks==3.2.0
@@ -374,6 +385,10 @@ djangorestframework==3.14.0
# super-csv
djangorestframework-xml==2.0.0
# via edx-enterprise
+dnspython==2.6.1
+ # via
+ # -r requirements/edx/paver.txt
+ # pymongo
done-xblock==2.3.0
# via -r requirements/edx/bundled.in
drf-jwt==1.19.2
@@ -384,7 +399,7 @@ drf-yasg==1.21.7
# via
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.9.1
+edx-ace==1.11.1
# via -r requirements/edx/kernel.in
edx-api-doc-tools==1.8.0
# via
@@ -412,7 +427,7 @@ edx-celeryutils==1.3.0
# super-csv
edx-codejail==3.4.1
# via -r requirements/edx/kernel.in
-edx-completion==4.6.6
+edx-completion==4.6.7
# via -r requirements/edx/kernel.in
edx-django-release-util==1.4.0
# via
@@ -421,7 +436,7 @@ edx-django-release-util==1.4.0
# edxval
edx-django-sites-extensions==4.2.0
# via -r requirements/edx/kernel.in
-edx-django-utils==5.14.2
+edx-django-utils==5.15.0
# via
# -r requirements/edx/kernel.in
# django-config-models
@@ -450,11 +465,11 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.21.5
+edx-enterprise==4.23.3
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
-edx-event-bus-kafka==5.7.0
+edx-event-bus-kafka==5.8.1
# via -r requirements/edx/kernel.in
edx-event-bus-redis==0.5.0
# via -r requirements/edx/kernel.in
@@ -496,11 +511,11 @@ edx-rest-api-client==5.7.1
# -r requirements/edx/kernel.in
# edx-enterprise
# edx-proctoring
-edx-search==3.9.1
+edx-search==4.0.0
# via -r requirements/edx/kernel.in
edx-sga==0.25.0
# via -r requirements/edx/bundled.in
-edx-submissions==3.7.5
+edx-submissions==3.7.6
# via
# -r requirements/edx/kernel.in
# ora2
@@ -534,8 +549,9 @@ enmerkar==0.7.1
# via enmerkar-underscore
enmerkar-underscore==2.3.0
# via -r requirements/edx/kernel.in
-event-tracking==2.4.0
+event-tracking==3.0.0
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# edx-completion
# edx-proctoring
@@ -544,6 +560,8 @@ fastavro==1.9.5
# via openedx-events
filelock==3.15.4
# via snowflake-connector-python
+firebase-admin==6.5.0
+ # via edx-ace
frozenlist==1.4.1
# via
# aiohttp
@@ -564,6 +582,49 @@ geoip2==4.8.0
# via -r requirements/edx/kernel.in
glob2==0.7
# via -r requirements/edx/kernel.in
+google-api-core[grpc]==2.19.1
+ # via
+ # firebase-admin
+ # google-api-python-client
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-api-python-client==2.139.0
+ # via firebase-admin
+google-auth==2.32.0
+ # via
+ # google-api-core
+ # google-api-python-client
+ # google-auth-httplib2
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-auth-httplib2==0.2.0
+ # via google-api-python-client
+google-cloud-core==2.4.1
+ # via
+ # google-cloud-firestore
+ # google-cloud-storage
+google-cloud-firestore==2.17.0
+ # via firebase-admin
+google-cloud-storage==2.18.0
+ # via firebase-admin
+google-crc32c==1.5.0
+ # via
+ # google-cloud-storage
+ # google-resumable-media
+google-resumable-media==2.7.1
+ # via google-cloud-storage
+googleapis-common-protos==1.63.2
+ # via
+ # google-api-core
+ # grpcio-status
+grpcio==1.65.4
+ # via
+ # google-api-core
+ # grpcio-status
+grpcio-status==1.62.3
+ # via google-api-core
gunicorn==22.0.0
# via -r requirements/edx/kernel.in
help-tokens==2.4.0
@@ -572,6 +633,10 @@ html5lib==1.1
# via
# -r requirements/edx/kernel.in
# ora2
+httplib2==0.22.0
+ # via
+ # google-api-python-client
+ # google-auth-httplib2
icalendar==5.0.13
# via -r requirements/edx/kernel.in
idna==3.7
@@ -603,7 +668,7 @@ jmespath==1.0.1
# botocore
joblib==1.4.2
# via nltk
-jsondiff==2.1.2
+jsondiff==2.2.0
# via edx-enterprise
jsonfield==3.1.0
# via
@@ -624,7 +689,7 @@ jwcrypto==1.5.6
# via
# django-oauth-toolkit
# pylti1p3
-kombu==5.3.7
+kombu==5.4.0
# via celery
laboratory==1.0.2
# via -r requirements/edx/kernel.in
@@ -696,6 +761,8 @@ more-itertools==10.3.0
# via cssutils
mpmath==1.3.0
# via sympy
+msgpack==1.0.8
+ # via cachecontrol
multidict==6.0.5
# via
# aiohttp
@@ -756,7 +823,7 @@ openedx-filters==1.9.0
# -r requirements/edx/kernel.in
# lti-consumer-xblock
# ora2
-openedx-learning==0.10.0
+openedx-learning==0.10.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -766,7 +833,7 @@ optimizely-sdk==4.1.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/bundled.in
-ora2==6.11.1
+ora2==6.11.2
# via -r requirements/edx/bundled.in
packaging==24.1
# via
@@ -812,6 +879,17 @@ polib==1.2.0
# via edx-i18n-tools
prompt-toolkit==3.0.47
# via click-repl
+proto-plus==1.24.0
+ # via
+ # google-api-core
+ # google-cloud-firestore
+protobuf==4.25.4
+ # via
+ # google-api-core
+ # google-cloud-firestore
+ # googleapis-common-protos
+ # grpcio-status
+ # proto-plus
psutil==6.0.0
# via
# -r requirements/edx/paver.txt
@@ -821,7 +899,12 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-
# -c requirements/edx/../constraints.txt
# -r requirements/edx/bundled.in
pyasn1==0.6.0
- # via pgpy
+ # via
+ # pgpy
+ # pyasn1-modules
+ # rsa
+pyasn1-modules==0.4.0
+ # via google-auth
pycountry==24.6.1
# via -r requirements/edx/kernel.in
pycparser==2.22
@@ -845,7 +928,7 @@ pyjwkest==1.4.2
# -r requirements/edx/kernel.in
# edx-token-utils
# lti-consumer-xblock
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/edx/kernel.in
# drf-jwt
@@ -853,6 +936,7 @@ pyjwt[crypto]==2.8.0
# edx-drf-extensions
# edx-proctoring
# edx-rest-api-client
+ # firebase-admin
# pylti1p3
# snowflake-connector-python
# social-auth-core
@@ -862,7 +946,7 @@ pylti1p3==2.0.0
# via -r requirements/edx/kernel.in
pymemcache==4.0.0
# via -r requirements/edx/paver.txt
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -877,13 +961,14 @@ pynacl==1.5.0
# paramiko
pynliner==0.8.0
# via -r requirements/edx/kernel.in
-pyopenssl==24.1.0
+pyopenssl==24.2.1
# via
# optimizely-sdk
# snowflake-connector-python
pyparsing==3.1.2
# via
# chem
+ # httplib2
# openedx-calc
pyrsistent==0.20.0
# via optimizely-sdk
@@ -953,7 +1038,7 @@ random2==1.0.2
# via -r requirements/edx/kernel.in
recommender-xblock==2.2.0
# via -r requirements/edx/bundled.in
-redis==5.0.7
+redis==5.0.8
# via
# -r requirements/edx/kernel.in
# walrus
@@ -961,19 +1046,22 @@ referencing==0.35.1
# via
# jsonschema
# jsonschema-specifications
-regex==2024.5.15
+regex==2024.7.24
# via nltk
requests==2.32.3
# via
# -r requirements/edx/paver.txt
# algoliasearch
# analytics-python
+ # cachecontrol
# django-oauth-toolkit
# edx-bulk-grades
# edx-drf-extensions
# edx-enterprise
# edx-rest-api-client
# geoip2
+ # google-api-core
+ # google-cloud-storage
# mailsnake
# meilisearch
# openai
@@ -991,10 +1079,12 @@ requests-oauthlib==2.0.0
# via
# -r requirements/edx/kernel.in
# social-auth-core
-rpds-py==0.19.0
+rpds-py==0.20.0
# via
# jsonschema
# referencing
+rsa==4.9
+ # via google-auth
rules==3.4
# via
# -r requirements/edx/kernel.in
@@ -1054,16 +1144,15 @@ slumber==0.7.1
# edx-bulk-grades
# edx-enterprise
# edx-rest-api-client
-snowflake-connector-python==3.11.0
+snowflake-connector-python==3.12.0
# via edx-enterprise
-social-auth-app-django==5.0.0
+social-auth-app-django==5.4.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# edx-auth-backends
-social-auth-core==4.3.0
+social-auth-core==4.5.4
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# edx-auth-backends
# social-auth-app-django
@@ -1092,7 +1181,7 @@ stevedore==5.2.0
# edx-opaque-keys
super-csv==3.2.0
# via edx-bulk-grades
-sympy==1.13.0
+sympy==1.13.1
# via openedx-calc
testfixtures==8.3.0
# via edx-enterprise
@@ -1102,7 +1191,7 @@ tinycss2==1.2.1
# via bleach
tomlkit==0.13.0
# via snowflake-connector-python
-tqdm==4.66.4
+tqdm==4.66.5
# via
# nltk
# openai
@@ -1126,6 +1215,7 @@ uritemplate==4.1.1
# via
# drf-spectacular
# drf-yasg
+ # google-api-python-client
urllib3==1.26.19
# via
# -c requirements/edx/../constraints.txt
@@ -1170,6 +1260,7 @@ wrapt==1.16.0
# via -r requirements/edx/paver.txt
xblock[django]==4.0.1
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
# acid-xblock
# crowdsourcehinter-xblock
diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt
index 8c3b834163d2..a004eeeb9ffa 100644
--- a/requirements/edx/coverage.txt
+++ b/requirements/edx/coverage.txt
@@ -6,9 +6,9 @@
#
chardet==5.2.0
# via diff-cover
-coverage==7.6.0
+coverage==7.6.1
# via -r requirements/edx/coverage.in
-diff-cover==9.1.0
+diff-cover==9.1.1
# via -r requirements/edx/coverage.in
jinja2==3.1.4
# via diff-cover
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index f6fa68363577..7adede611a59 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -16,7 +16,12 @@ acid-xblock==0.3.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-aiohttp==3.9.5
+aiohappyeyeballs==2.3.4
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # aiohttp
+aiohttp==3.10.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -27,7 +32,7 @@ aiosignal==1.3.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# aiohttp
-alabaster==0.7.16
+alabaster==1.0.0
# via
# -r requirements/edx/doc.txt
# sphinx
@@ -57,9 +62,7 @@ annotated-types==0.7.0
anyio==4.4.0
# via
# -r requirements/edx/testing.txt
- # httpcore
# starlette
- # watchfiles
appdirs==1.4.4
# via
# -r requirements/edx/doc.txt
@@ -82,7 +85,7 @@ astroid==2.13.5
# -r requirements/edx/testing.txt
# pylint
# pylint-celery
-attrs==23.2.0
+attrs==24.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -106,7 +109,7 @@ backoff==1.10.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# analytics-python
-bcrypt==4.1.3
+bcrypt==4.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -136,14 +139,14 @@ boto==2.49.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-boto3==1.34.144
+boto3==1.34.154
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.34.144
+botocore==1.34.154
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -157,9 +160,16 @@ build==1.2.1
# via
# -r requirements/edx/../pip-tools.txt
# pip-tools
+cachecontrol==0.14.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # firebase-admin
cachetools==5.4.0
# via
+ # -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
+ # google-auth
# tox
camel-converter[pydantic]==3.1.2
# via
@@ -182,16 +192,15 @@ certifi==2024.7.4
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# elasticsearch
- # httpcore
- # httpx
# py2neo
# requests
# snowflake-connector-python
-cffi==1.16.0
+cffi==1.17.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# cryptography
+ # pact-python
# pynacl
# snowflake-connector-python
chardet==5.2.0
@@ -232,7 +241,6 @@ click==8.1.6
# nltk
# pact-python
# pip-tools
- # typer
# user-util
# uvicorn
click-didyoumean==0.3.1
@@ -269,7 +277,7 @@ colorama==0.4.6
# via
# -r requirements/edx/testing.txt
# tox
-coverage[toml]==7.6.0
+coverage[toml]==7.6.1
# via
# -r requirements/edx/testing.txt
# pytest-cov
@@ -314,7 +322,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-diff-cover==9.1.0
+diff-cover==9.1.1
# via -r requirements/edx/testing.txt
dill==0.3.8
# via
@@ -324,7 +332,7 @@ distlib==0.3.8
# via
# -r requirements/edx/testing.txt
# virtualenv
-django==4.2.14
+django==4.2.15
# via
# -c requirements/edx/../common_constraints.txt
# -c requirements/edx/../constraints.txt
@@ -345,6 +353,7 @@ django==4.2.14
# django-multi-email-field
# django-mysql
# django-oauth-toolkit
+ # django-push-notifications
# django-sekizai
# django-ses
# django-statici18n
@@ -395,6 +404,7 @@ django==4.2.14
# openedx-filters
# openedx-learning
# ora2
+ # social-auth-app-django
# super-csv
# xblock-google-drive
# xss-utils
@@ -450,7 +460,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-enterprise
-django-filter==24.2
+django-filter==24.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -519,6 +529,11 @@ django-pipeline==3.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
+django-push-notifications==3.1.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # edx-ace
django-ratelimit==4.1.0
# via
# -r requirements/edx/doc.txt
@@ -549,8 +564,9 @@ django-statici18n==2.5.0
# lti-consumer-xblock
# xblock-drag-and-drop-v2
# xblock-poll
-django-storages==1.14.4
+django-storages==1.14.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edxval
@@ -559,7 +575,7 @@ django-stubs==1.16.0
# -c requirements/edx/../constraints.txt
# -r requirements/edx/development.in
# djangorestframework-stubs
-django-stubs-ext==5.0.2
+django-stubs-ext==5.0.4
# via django-stubs
django-user-tasks==3.2.0
# via
@@ -612,8 +628,9 @@ djangorestframework-xml==2.0.0
# edx-enterprise
dnspython==2.6.1
# via
+ # -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
- # email-validator
+ # pymongo
docutils==0.21.2
# via
# -r requirements/edx/doc.txt
@@ -639,7 +656,7 @@ drf-yasg==1.21.7
# -r requirements/edx/testing.txt
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.9.1
+edx-ace==1.11.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -678,7 +695,7 @@ edx-codejail==3.4.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-completion==4.6.6
+edx-completion==4.6.7
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -692,7 +709,7 @@ edx-django-sites-extensions==4.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-django-utils==5.14.2
+edx-django-utils==5.15.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -723,12 +740,12 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.21.5
+edx-enterprise==4.23.3
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-event-bus-kafka==5.7.0
+edx-event-bus-kafka==5.8.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -788,7 +805,7 @@ edx-rest-api-client==5.7.1
# -r requirements/edx/testing.txt
# edx-enterprise
# edx-proctoring
-edx-search==3.9.1
+edx-search==4.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -796,7 +813,7 @@ edx-sga==0.25.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-submissions==3.7.5
+edx-submissions==3.7.6
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -838,10 +855,6 @@ elasticsearch==7.13.4
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-search
-email-validator==2.2.0
- # via
- # -r requirements/edx/testing.txt
- # fastapi
enmerkar==0.7.1
# via
# -r requirements/edx/doc.txt
@@ -851,8 +864,9 @@ enmerkar-underscore==2.3.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-event-tracking==2.4.0
+event-tracking==3.0.0
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-completion
@@ -864,18 +878,14 @@ execnet==2.1.1
# pytest-xdist
factory-boy==3.3.0
# via -r requirements/edx/testing.txt
-faker==26.0.0
+faker==26.2.0
# via
# -r requirements/edx/testing.txt
# factory-boy
-fastapi==0.111.1
+fastapi==0.112.0
# via
# -r requirements/edx/testing.txt
# pact-python
-fastapi-cli==0.0.4
- # via
- # -r requirements/edx/testing.txt
- # fastapi
fastavro==1.9.5
# via
# -r requirements/edx/doc.txt
@@ -888,6 +898,11 @@ filelock==3.15.4
# snowflake-connector-python
# tox
# virtualenv
+firebase-admin==6.5.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # edx-ace
freezegun==1.5.1
# via -r requirements/edx/testing.txt
frozenlist==1.4.1
@@ -927,10 +942,83 @@ glob2==0.7
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
+google-api-core[grpc]==2.19.1
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # firebase-admin
+ # google-api-python-client
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-api-python-client==2.139.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # firebase-admin
+google-auth==2.32.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-core
+ # google-api-python-client
+ # google-auth-httplib2
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-auth-httplib2==0.2.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-python-client
+google-cloud-core==2.4.1
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-cloud-firestore
+ # google-cloud-storage
+google-cloud-firestore==2.17.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # firebase-admin
+google-cloud-storage==2.18.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # firebase-admin
+google-crc32c==1.5.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-cloud-storage
+ # google-resumable-media
+google-resumable-media==2.7.1
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-cloud-storage
+googleapis-common-protos==1.63.2
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-core
+ # grpcio-status
grimp==3.4.1
# via
# -r requirements/edx/testing.txt
# import-linter
+grpcio==1.65.4
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-core
+ # grpcio-status
+grpcio-status==1.62.3
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-core
gunicorn==22.0.0
# via
# -r requirements/edx/doc.txt
@@ -938,7 +1026,6 @@ gunicorn==22.0.0
h11==0.14.0
# via
# -r requirements/edx/testing.txt
- # httpcore
# uvicorn
help-tokens==2.4.0
# via
@@ -949,21 +1036,14 @@ html5lib==1.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# ora2
-httpcore==0.16.3
+httplib2==0.22.0
# via
+ # -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
- # httpx
+ # google-api-python-client
+ # google-auth-httplib2
httpretty==1.1.4
# via -r requirements/edx/testing.txt
-httptools==0.6.1
- # via
- # -r requirements/edx/testing.txt
- # uvicorn
-httpx==0.23.3
- # via
- # -r requirements/edx/testing.txt
- # fastapi
- # pact-python
icalendar==5.0.13
# via
# -r requirements/edx/doc.txt
@@ -973,10 +1053,8 @@ idna==3.7
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# anyio
- # email-validator
# optimizely-sdk
# requests
- # rfc3986
# snowflake-connector-python
# yarl
imagesize==1.4.1
@@ -1024,7 +1102,6 @@ jinja2==3.1.4
# -r requirements/edx/testing.txt
# code-annotations
# diff-cover
- # fastapi
# sphinx
jmespath==1.0.1
# via
@@ -1037,7 +1114,7 @@ joblib==1.4.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# nltk
-jsondiff==2.1.2
+jsondiff==2.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1070,7 +1147,7 @@ jwcrypto==1.5.6
# -r requirements/edx/testing.txt
# django-oauth-toolkit
# pylti1p3
-kombu==5.3.7
+kombu==5.4.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1141,10 +1218,6 @@ markdown==3.3.7
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markdown-it-py==3.0.0
- # via
- # -r requirements/edx/testing.txt
- # rich
markupsafe==2.1.5
# via
# -r requirements/edx/doc.txt
@@ -1163,10 +1236,6 @@ mccabe==0.7.0
# via
# -r requirements/edx/testing.txt
# pylint
-mdurl==0.1.2
- # via
- # -r requirements/edx/testing.txt
- # markdown-it-py
meilisearch==0.31.4
# via
# -r requirements/edx/doc.txt
@@ -1199,13 +1268,18 @@ mpmath==1.3.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# sympy
+msgpack==1.0.8
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # cachecontrol
multidict==6.0.5
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# aiohttp
# yarl
-mypy==1.10.1
+mypy==1.11.1
# via
# -r requirements/edx/development.in
# django-stubs
@@ -1298,7 +1372,7 @@ openedx-filters==1.9.0
# -r requirements/edx/testing.txt
# lti-consumer-xblock
# ora2
-openedx-learning==0.10.0
+openedx-learning==0.10.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -1312,7 +1386,7 @@ optimizely-sdk==4.1.1
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-ora2==6.11.1
+ora2==6.11.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1331,7 +1405,7 @@ packaging==24.1
# snowflake-connector-python
# sphinx
# tox
-pact-python==2.0.1
+pact-python==2.2.1
# via -r requirements/edx/testing.txt
pansi==2020.7.3
# via
@@ -1412,6 +1486,21 @@ prompt-toolkit==3.0.47
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# click-repl
+proto-plus==1.24.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-core
+ # google-cloud-firestore
+protobuf==4.25.4
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-api-core
+ # google-cloud-firestore
+ # googleapis-common-protos
+ # grpcio-status
+ # proto-plus
psutil==6.0.0
# via
# -r requirements/edx/doc.txt
@@ -1431,6 +1520,13 @@ pyasn1==0.6.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# pgpy
+ # pyasn1-modules
+ # rsa
+pyasn1-modules==0.4.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-auth
pycodestyle==2.8.0
# via
# -c requirements/edx/../constraints.txt
@@ -1474,7 +1570,6 @@ pygments==2.18.0
# diff-cover
# py2neo
# pydata-sphinx-theme
- # rich
# sphinx
# sphinx-mdinclude
pyjwkest==1.4.2
@@ -1483,7 +1578,7 @@ pyjwkest==1.4.2
# -r requirements/edx/testing.txt
# edx-token-utils
# lti-consumer-xblock
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1492,6 +1587,7 @@ pyjwt[crypto]==2.8.0
# edx-drf-extensions
# edx-proctoring
# edx-rest-api-client
+ # firebase-admin
# pylti1p3
# snowflake-connector-python
# social-auth-core
@@ -1532,7 +1628,7 @@ pymemcache==4.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -1551,7 +1647,7 @@ pynliner==0.8.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-pyopenssl==24.1.0
+pyopenssl==24.2.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1562,6 +1658,7 @@ pyparsing==3.1.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# chem
+ # httplib2
# openedx-calc
pyproject-api==1.7.1
# via
@@ -1584,7 +1681,7 @@ pysrt==1.1.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edxval
-pytest==8.2.2
+pytest==8.3.2
# via
# -r requirements/edx/testing.txt
# pylint-pytest
@@ -1627,10 +1724,6 @@ python-dateutil==2.9.0.post0
# olxcleaner
# ora2
# xblock
-python-dotenv==1.0.1
- # via
- # -r requirements/edx/testing.txt
- # uvicorn
python-ipware==3.0.0
# via
# -r requirements/edx/doc.txt
@@ -1640,10 +1733,6 @@ python-memcached==1.62
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-python-multipart==0.0.9
- # via
- # -r requirements/edx/testing.txt
- # fastapi
python-slugify==8.0.4
# via
# -r requirements/edx/doc.txt
@@ -1700,7 +1789,6 @@ pyyaml==6.0.1
# edx-i18n-tools
# jsondiff
# sphinxcontrib-openapi
- # uvicorn
# xblock
random2==1.0.2
# via
@@ -1710,7 +1798,7 @@ recommender-xblock==2.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-redis==5.0.7
+redis==5.0.8
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1721,7 +1809,7 @@ referencing==0.35.1
# -r requirements/edx/testing.txt
# jsonschema
# jsonschema-specifications
-regex==2024.5.15
+regex==2024.7.24
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1732,6 +1820,7 @@ requests==2.32.3
# -r requirements/edx/testing.txt
# algoliasearch
# analytics-python
+ # cachecontrol
# django-oauth-toolkit
# djangorestframework-stubs
# edx-bulk-grades
@@ -1739,6 +1828,8 @@ requests==2.32.3
# edx-enterprise
# edx-rest-api-client
# geoip2
+ # google-api-core
+ # google-cloud-storage
# mailsnake
# meilisearch
# openai
@@ -1759,20 +1850,17 @@ requests-oauthlib==2.0.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# social-auth-core
-rfc3986[idna2008]==1.5.0
- # via
- # -r requirements/edx/testing.txt
- # httpx
-rich==13.7.1
- # via
- # -r requirements/edx/testing.txt
- # typer
-rpds-py==0.19.0
+rpds-py==0.20.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# jsonschema
# referencing
+rsa==4.9
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # google-auth
rules==3.4
# via
# -r requirements/edx/doc.txt
@@ -1805,10 +1893,6 @@ shapely==2.0.5
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-shellingham==1.5.4
- # via
- # -r requirements/edx/testing.txt
- # typer
simplejson==3.19.2
# via
# -r requirements/edx/doc.txt
@@ -1866,26 +1950,23 @@ sniffio==1.3.1
# via
# -r requirements/edx/testing.txt
# anyio
- # httpcore
- # httpx
snowballstemmer==2.2.0
# via
# -r requirements/edx/doc.txt
# sphinx
-snowflake-connector-python==3.11.0
+snowflake-connector-python==3.12.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-enterprise
-social-auth-app-django==5.0.0
+social-auth-app-django==5.4.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-auth-backends
-social-auth-core==4.3.0
+social-auth-core==4.5.4
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-auth-backends
@@ -1905,7 +1986,7 @@ soupsieve==2.5
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# beautifulsoup4
-sphinx==7.4.4
+sphinx==8.0.2
# via
# -r requirements/edx/doc.txt
# pydata-sphinx-theme
@@ -1918,23 +1999,23 @@ sphinx==7.4.4
# sphinxext-rediraffe
sphinx-book-theme==1.1.3
# via -r requirements/edx/doc.txt
-sphinx-design==0.6.0
+sphinx-design==0.6.1
# via -r requirements/edx/doc.txt
-sphinx-mdinclude==0.6.1
+sphinx-mdinclude==0.6.2
# via
# -r requirements/edx/doc.txt
# sphinxcontrib-openapi
sphinx-reredirects==0.1.5
# via -r requirements/edx/doc.txt
-sphinxcontrib-applehelp==1.0.8
+sphinxcontrib-applehelp==2.0.0
# via
# -r requirements/edx/doc.txt
# sphinx
-sphinxcontrib-devhelp==1.0.6
+sphinxcontrib-devhelp==2.0.0
# via
# -r requirements/edx/doc.txt
# sphinx
-sphinxcontrib-htmlhelp==2.0.5
+sphinxcontrib-htmlhelp==2.1.0
# via
# -r requirements/edx/doc.txt
# sphinx
@@ -1948,11 +2029,11 @@ sphinxcontrib-jsmath==1.0.1
# sphinx
sphinxcontrib-openapi[markdown]==0.8.4
# via -r requirements/edx/doc.txt
-sphinxcontrib-qthelp==1.0.7
+sphinxcontrib-qthelp==2.0.0
# via
# -r requirements/edx/doc.txt
# sphinx
-sphinxcontrib-serializinghtml==1.1.10
+sphinxcontrib-serializinghtml==2.0.0
# via
# -r requirements/edx/doc.txt
# sphinx
@@ -1986,7 +2067,7 @@ super-csv==3.2.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-bulk-grades
-sympy==1.13.0
+sympy==1.13.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2014,21 +2095,17 @@ tomlkit==0.13.0
# -r requirements/edx/testing.txt
# pylint
# snowflake-connector-python
-tox==4.16.0
+tox==4.17.0
# via -r requirements/edx/testing.txt
-tqdm==4.66.4
+tqdm==4.66.5
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# nltk
# openai
-typer==0.12.3
- # via
- # -r requirements/edx/testing.txt
- # fastapi-cli
types-pytz==2024.1.0.20240417
# via django-stubs
-types-pyyaml==6.0.12.20240311
+types-pyyaml==6.0.12.20240724
# via
# django-stubs
# djangorestframework-stubs
@@ -2055,7 +2132,6 @@ typing-extensions==4.12.2
# pydata-sphinx-theme
# pylti1p3
# snowflake-connector-python
- # typer
tzdata==2024.1
# via
# -r requirements/edx/doc.txt
@@ -2074,6 +2150,7 @@ uritemplate==4.1.1
# -r requirements/edx/testing.txt
# drf-spectacular
# drf-yasg
+ # google-api-python-client
urllib3==1.26.19
# via
# -c requirements/edx/../constraints.txt
@@ -2081,22 +2158,16 @@ urllib3==1.26.19
# -r requirements/edx/testing.txt
# botocore
# elasticsearch
- # pact-python
# py2neo
# requests
user-util==1.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-uvicorn[standard]==0.30.1
+uvicorn==0.30.5
# via
# -r requirements/edx/testing.txt
- # fastapi
# pact-python
-uvloop==0.19.0
- # via
- # -r requirements/edx/testing.txt
- # uvicorn
vine==5.1.0
# via
# -r requirements/edx/doc.txt
@@ -2125,10 +2196,6 @@ watchdog==4.0.1
# -r requirements/edx/development.in
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-watchfiles==0.22.0
- # via
- # -r requirements/edx/testing.txt
- # uvicorn
wcwidth==0.2.13
# via
# -r requirements/edx/doc.txt
@@ -2155,11 +2222,7 @@ webob==1.8.7
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# xblock
-websockets==12.0
- # via
- # -r requirements/edx/testing.txt
- # uvicorn
-wheel==0.43.0
+wheel==0.44.0
# via
# -r requirements/edx/../pip-tools.txt
# pip-tools
@@ -2170,6 +2233,7 @@ wrapt==1.16.0
# astroid
xblock[django]==4.0.1
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# acid-xblock
@@ -2217,6 +2281,7 @@ yarl==1.9.4
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# aiohttp
+ # pact-python
zipp==3.19.2
# via
# -r requirements/edx/doc.txt
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index 3ab0f8ce8181..d61a022ecf1f 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -10,7 +10,11 @@ accessible-pygments==0.0.5
# via pydata-sphinx-theme
acid-xblock==0.3.1
# via -r requirements/edx/base.txt
-aiohttp==3.9.5
+aiohappyeyeballs==2.3.4
+ # via
+ # -r requirements/edx/base.txt
+ # aiohttp
+aiohttp==3.10.1
# via
# -r requirements/edx/base.txt
# geoip2
@@ -19,7 +23,7 @@ aiosignal==1.3.1
# via
# -r requirements/edx/base.txt
# aiohttp
-alabaster==0.7.16
+alabaster==1.0.0
# via sphinx
algoliasearch==3.0.0
# via -r requirements/edx/base.txt
@@ -51,7 +55,7 @@ asn1crypto==1.5.1
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
-attrs==23.2.0
+attrs==24.2.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -72,7 +76,7 @@ backoff==1.10.0
# via
# -r requirements/edx/base.txt
# analytics-python
-bcrypt==4.1.3
+bcrypt==4.2.0
# via
# -r requirements/edx/base.txt
# paramiko
@@ -96,19 +100,27 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
-boto3==1.34.144
+boto3==1.34.154
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.34.144
+botocore==1.34.154
# via
# -r requirements/edx/base.txt
# boto3
# s3transfer
bridgekeeper==0.9
# via -r requirements/edx/base.txt
+cachecontrol==0.14.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+cachetools==5.4.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-auth
camel-converter[pydantic]==3.1.2
# via
# -r requirements/edx/base.txt
@@ -130,7 +142,7 @@ certifi==2024.7.4
# py2neo
# requests
# snowflake-connector-python
-cffi==1.16.0
+cffi==1.17.0
# via
# -r requirements/edx/base.txt
# cryptography
@@ -208,7 +220,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.14
+django==4.2.15
# via
# -c requirements/edx/../common_constraints.txt
# -c requirements/edx/../constraints.txt
@@ -227,6 +239,7 @@ django==4.2.14
# django-multi-email-field
# django-mysql
# django-oauth-toolkit
+ # django-push-notifications
# django-sekizai
# django-ses
# django-statici18n
@@ -275,6 +288,7 @@ django==4.2.14
# openedx-filters
# openedx-learning
# ora2
+ # social-auth-app-django
# super-csv
# xblock-google-drive
# xss-utils
@@ -317,7 +331,7 @@ django-fernet-fields-v2==0.9
# via
# -r requirements/edx/base.txt
# edx-enterprise
-django-filter==24.2
+django-filter==24.3
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -372,6 +386,10 @@ django-object-actions==4.2.0
# edx-enterprise
django-pipeline==3.1.0
# via -r requirements/edx/base.txt
+django-push-notifications==3.1.0
+ # via
+ # -r requirements/edx/base.txt
+ # edx-ace
django-ratelimit==4.1.0
# via -r requirements/edx/base.txt
django-sekizai==4.1.0
@@ -395,8 +413,9 @@ django-statici18n==2.5.0
# lti-consumer-xblock
# xblock-drag-and-drop-v2
# xblock-poll
-django-storages==1.14.4
+django-storages==1.14.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edxval
django-user-tasks==3.2.0
@@ -438,6 +457,10 @@ djangorestframework-xml==2.0.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
+dnspython==2.6.1
+ # via
+ # -r requirements/edx/base.txt
+ # pymongo
docutils==0.21.2
# via
# pydata-sphinx-theme
@@ -456,7 +479,7 @@ drf-yasg==1.21.7
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.9.1
+edx-ace==1.11.1
# via -r requirements/edx/base.txt
edx-api-doc-tools==1.8.0
# via
@@ -484,7 +507,7 @@ edx-celeryutils==1.3.0
# super-csv
edx-codejail==3.4.1
# via -r requirements/edx/base.txt
-edx-completion==4.6.6
+edx-completion==4.6.7
# via -r requirements/edx/base.txt
edx-django-release-util==1.4.0
# via
@@ -493,7 +516,7 @@ edx-django-release-util==1.4.0
# edxval
edx-django-sites-extensions==4.2.0
# via -r requirements/edx/base.txt
-edx-django-utils==5.14.2
+edx-django-utils==5.15.0
# via
# -r requirements/edx/base.txt
# django-config-models
@@ -522,11 +545,11 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.21.5
+edx-enterprise==4.23.3
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-edx-event-bus-kafka==5.7.0
+edx-event-bus-kafka==5.8.1
# via -r requirements/edx/base.txt
edx-event-bus-redis==0.5.0
# via -r requirements/edx/base.txt
@@ -569,11 +592,11 @@ edx-rest-api-client==5.7.1
# -r requirements/edx/base.txt
# edx-enterprise
# edx-proctoring
-edx-search==3.9.1
+edx-search==4.0.0
# via -r requirements/edx/base.txt
edx-sga==0.25.0
# via -r requirements/edx/base.txt
-edx-submissions==3.7.5
+edx-submissions==3.7.6
# via
# -r requirements/edx/base.txt
# ora2
@@ -612,8 +635,9 @@ enmerkar==0.7.1
# enmerkar-underscore
enmerkar-underscore==2.3.0
# via -r requirements/edx/base.txt
-event-tracking==2.4.0
+event-tracking==3.0.0
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-completion
# edx-proctoring
@@ -626,6 +650,10 @@ filelock==3.15.4
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
+firebase-admin==6.5.0
+ # via
+ # -r requirements/edx/base.txt
+ # edx-ace
frozenlist==1.4.1
# via
# -r requirements/edx/base.txt
@@ -653,6 +681,67 @@ gitpython==3.1.43
# via -r requirements/edx/doc.in
glob2==0.7
# via -r requirements/edx/base.txt
+google-api-core[grpc]==2.19.1
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+ # google-api-python-client
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-api-python-client==2.139.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+google-auth==2.32.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # google-api-python-client
+ # google-auth-httplib2
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-auth-httplib2==0.2.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-python-client
+google-cloud-core==2.4.1
+ # via
+ # -r requirements/edx/base.txt
+ # google-cloud-firestore
+ # google-cloud-storage
+google-cloud-firestore==2.17.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+google-cloud-storage==2.18.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+google-crc32c==1.5.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-cloud-storage
+ # google-resumable-media
+google-resumable-media==2.7.1
+ # via
+ # -r requirements/edx/base.txt
+ # google-cloud-storage
+googleapis-common-protos==1.63.2
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # grpcio-status
+grpcio==1.65.4
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # grpcio-status
+grpcio-status==1.62.3
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
gunicorn==22.0.0
# via -r requirements/edx/base.txt
help-tokens==2.4.0
@@ -661,6 +750,11 @@ html5lib==1.1
# via
# -r requirements/edx/base.txt
# ora2
+httplib2==0.22.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-python-client
+ # google-auth-httplib2
icalendar==5.0.13
# via -r requirements/edx/base.txt
idna==3.7
@@ -705,7 +799,7 @@ joblib==1.4.2
# via
# -r requirements/edx/base.txt
# nltk
-jsondiff==2.1.2
+jsondiff==2.2.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -733,7 +827,7 @@ jwcrypto==1.5.6
# -r requirements/edx/base.txt
# django-oauth-toolkit
# pylti1p3
-kombu==5.3.7
+kombu==5.4.0
# via
# -r requirements/edx/base.txt
# celery
@@ -818,6 +912,10 @@ mpmath==1.3.0
# via
# -r requirements/edx/base.txt
# sympy
+msgpack==1.0.8
+ # via
+ # -r requirements/edx/base.txt
+ # cachecontrol
multidict==6.0.5
# via
# -r requirements/edx/base.txt
@@ -884,7 +982,7 @@ openedx-filters==1.9.0
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
-openedx-learning==0.10.0
+openedx-learning==0.10.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -894,7 +992,7 @@ optimizely-sdk==4.1.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-ora2==6.11.1
+ora2==6.11.2
# via -r requirements/edx/base.txt
packaging==24.1
# via
@@ -957,6 +1055,19 @@ prompt-toolkit==3.0.47
# via
# -r requirements/edx/base.txt
# click-repl
+proto-plus==1.24.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # google-cloud-firestore
+protobuf==4.25.4
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # google-cloud-firestore
+ # googleapis-common-protos
+ # grpcio-status
+ # proto-plus
psutil==6.0.0
# via
# -r requirements/edx/base.txt
@@ -969,6 +1080,12 @@ pyasn1==0.6.0
# via
# -r requirements/edx/base.txt
# pgpy
+ # pyasn1-modules
+ # rsa
+pyasn1-modules==0.4.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-auth
pycountry==24.6.1
# via -r requirements/edx/base.txt
pycparser==2.22
@@ -1004,7 +1121,7 @@ pyjwkest==1.4.2
# -r requirements/edx/base.txt
# edx-token-utils
# lti-consumer-xblock
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/edx/base.txt
# drf-jwt
@@ -1012,6 +1129,7 @@ pyjwt[crypto]==2.8.0
# edx-drf-extensions
# edx-proctoring
# edx-rest-api-client
+ # firebase-admin
# pylti1p3
# snowflake-connector-python
# social-auth-core
@@ -1023,7 +1141,7 @@ pylti1p3==2.0.0
# via -r requirements/edx/base.txt
pymemcache==4.0.0
# via -r requirements/edx/base.txt
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -1038,7 +1156,7 @@ pynacl==1.5.0
# paramiko
pynliner==0.8.0
# via -r requirements/edx/base.txt
-pyopenssl==24.1.0
+pyopenssl==24.2.1
# via
# -r requirements/edx/base.txt
# optimizely-sdk
@@ -1047,6 +1165,7 @@ pyparsing==3.1.2
# via
# -r requirements/edx/base.txt
# chem
+ # httplib2
# openedx-calc
pyrsistent==0.20.0
# via
@@ -1125,7 +1244,7 @@ random2==1.0.2
# via -r requirements/edx/base.txt
recommender-xblock==2.2.0
# via -r requirements/edx/base.txt
-redis==5.0.7
+redis==5.0.8
# via
# -r requirements/edx/base.txt
# walrus
@@ -1134,7 +1253,7 @@ referencing==0.35.1
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
-regex==2024.5.15
+regex==2024.7.24
# via
# -r requirements/edx/base.txt
# nltk
@@ -1143,12 +1262,15 @@ requests==2.32.3
# -r requirements/edx/base.txt
# algoliasearch
# analytics-python
+ # cachecontrol
# django-oauth-toolkit
# edx-bulk-grades
# edx-drf-extensions
# edx-enterprise
# edx-rest-api-client
# geoip2
+ # google-api-core
+ # google-cloud-storage
# mailsnake
# meilisearch
# openai
@@ -1167,11 +1289,15 @@ requests-oauthlib==2.0.0
# via
# -r requirements/edx/base.txt
# social-auth-core
-rpds-py==0.19.0
+rpds-py==0.20.0
# via
# -r requirements/edx/base.txt
# jsonschema
# referencing
+rsa==4.9
+ # via
+ # -r requirements/edx/base.txt
+ # google-auth
rules==3.4
# via
# -r requirements/edx/base.txt
@@ -1242,18 +1368,17 @@ smmap==5.0.1
# via gitdb
snowballstemmer==2.2.0
# via sphinx
-snowflake-connector-python==3.11.0
+snowflake-connector-python==3.12.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-social-auth-app-django==5.0.0
+social-auth-app-django==5.4.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
-social-auth-core==4.3.0
+social-auth-core==4.5.4
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
# social-auth-app-django
@@ -1269,7 +1394,7 @@ soupsieve==2.5
# via
# -r requirements/edx/base.txt
# beautifulsoup4
-sphinx==7.4.4
+sphinx==8.0.2
# via
# -r requirements/edx/doc.in
# pydata-sphinx-theme
@@ -1282,17 +1407,17 @@ sphinx==7.4.4
# sphinxext-rediraffe
sphinx-book-theme==1.1.3
# via -r requirements/edx/doc.in
-sphinx-design==0.6.0
+sphinx-design==0.6.1
# via -r requirements/edx/doc.in
-sphinx-mdinclude==0.6.1
+sphinx-mdinclude==0.6.2
# via sphinxcontrib-openapi
sphinx-reredirects==0.1.5
# via -r requirements/edx/doc.in
-sphinxcontrib-applehelp==1.0.8
+sphinxcontrib-applehelp==2.0.0
# via sphinx
-sphinxcontrib-devhelp==1.0.6
+sphinxcontrib-devhelp==2.0.0
# via sphinx
-sphinxcontrib-htmlhelp==2.0.5
+sphinxcontrib-htmlhelp==2.1.0
# via sphinx
sphinxcontrib-httpdomain==1.8.1
# via sphinxcontrib-openapi
@@ -1300,9 +1425,9 @@ sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-openapi[markdown]==0.8.4
# via -r requirements/edx/doc.in
-sphinxcontrib-qthelp==1.0.7
+sphinxcontrib-qthelp==2.0.0
# via sphinx
-sphinxcontrib-serializinghtml==1.1.10
+sphinxcontrib-serializinghtml==2.0.0
# via sphinx
sphinxext-rediraffe==0.2.7
# via -r requirements/edx/doc.in
@@ -1324,7 +1449,7 @@ super-csv==3.2.0
# via
# -r requirements/edx/base.txt
# edx-bulk-grades
-sympy==1.13.0
+sympy==1.13.1
# via
# -r requirements/edx/base.txt
# openedx-calc
@@ -1344,7 +1469,7 @@ tomlkit==0.13.0
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
-tqdm==4.66.4
+tqdm==4.66.5
# via
# -r requirements/edx/base.txt
# nltk
@@ -1373,6 +1498,7 @@ uritemplate==4.1.1
# -r requirements/edx/base.txt
# drf-spectacular
# drf-yasg
+ # google-api-python-client
urllib3==1.26.19
# via
# -c requirements/edx/../constraints.txt
@@ -1425,6 +1551,7 @@ wrapt==1.16.0
# via -r requirements/edx/base.txt
xblock[django]==4.0.1
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# acid-xblock
# crowdsourcehinter-xblock
diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt
index bf7337f4147a..0b82d71c91e6 100644
--- a/requirements/edx/paver.txt
+++ b/requirements/edx/paver.txt
@@ -10,6 +10,8 @@ charset-normalizer==2.0.12
# via
# -c requirements/edx/../constraints.txt
# requests
+dnspython==2.6.1
+ # via pymongo
edx-opaque-keys==2.10.0
# via -r requirements/edx/paver.in
idna==3.7
@@ -36,7 +38,7 @@ psutil==6.0.0
# via -r requirements/edx/paver.in
pymemcache==4.0.0
# via -r requirements/edx/paver.in
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/paver.in
diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt
index 5c3ec421aa32..292f1319048d 100644
--- a/requirements/edx/semgrep.txt
+++ b/requirements/edx/semgrep.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-attrs==23.2.0
+attrs==24.2.0
# via
# glom
# jsonschema
@@ -15,7 +15,7 @@ boltons==21.0.0
# face
# glom
# semgrep
-bracex==2.4
+bracex==2.5
# via wcmatch
certifi==2024.7.4
# via requests
@@ -62,7 +62,7 @@ requests==2.32.3
# via semgrep
rich==13.7.1
# via semgrep
-rpds-py==0.19.0
+rpds-py==0.20.0
# via
# jsonschema
# referencing
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 9d8c429800cc..b95e1f9f0ac9 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -8,7 +8,11 @@
# via -r requirements/edx/base.txt
acid-xblock==0.3.1
# via -r requirements/edx/base.txt
-aiohttp==3.9.5
+aiohappyeyeballs==2.3.4
+ # via
+ # -r requirements/edx/base.txt
+ # aiohttp
+aiohttp==3.10.1
# via
# -r requirements/edx/base.txt
# geoip2
@@ -34,10 +38,7 @@ annotated-types==0.7.0
# -r requirements/edx/base.txt
# pydantic
anyio==4.4.0
- # via
- # httpcore
- # starlette
- # watchfiles
+ # via starlette
appdirs==1.4.4
# via
# -r requirements/edx/base.txt
@@ -56,7 +57,7 @@ astroid==2.13.5
# via
# pylint
# pylint-celery
-attrs==23.2.0
+attrs==24.2.0
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -75,7 +76,7 @@ backoff==1.10.0
# via
# -r requirements/edx/base.txt
# analytics-python
-bcrypt==4.1.3
+bcrypt==4.2.0
# via
# -r requirements/edx/base.txt
# paramiko
@@ -99,21 +100,28 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
-boto3==1.34.144
+boto3==1.34.154
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.34.144
+botocore==1.34.154
# via
# -r requirements/edx/base.txt
# boto3
# s3transfer
bridgekeeper==0.9
# via -r requirements/edx/base.txt
+cachecontrol==0.14.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
cachetools==5.4.0
- # via tox
+ # via
+ # -r requirements/edx/base.txt
+ # google-auth
+ # tox
camel-converter[pydantic]==3.1.2
# via
# -r requirements/edx/base.txt
@@ -132,15 +140,14 @@ certifi==2024.7.4
# via
# -r requirements/edx/base.txt
# elasticsearch
- # httpcore
- # httpx
# py2neo
# requests
# snowflake-connector-python
-cffi==1.16.0
+cffi==1.17.0
# via
# -r requirements/edx/base.txt
# cryptography
+ # pact-python
# pynacl
# snowflake-connector-python
chardet==5.2.0
@@ -173,7 +180,6 @@ click==8.1.6
# import-linter
# nltk
# pact-python
- # typer
# user-util
# uvicorn
click-didyoumean==0.3.1
@@ -201,7 +207,7 @@ codejail-includes==1.0.0
# via -r requirements/edx/base.txt
colorama==0.4.6
# via tox
-coverage[toml]==7.6.0
+coverage[toml]==7.6.1
# via
# -r requirements/edx/coverage.txt
# pytest-cov
@@ -237,13 +243,13 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-diff-cover==9.1.0
+diff-cover==9.1.1
# via -r requirements/edx/coverage.txt
dill==0.3.8
# via pylint
distlib==0.3.8
# via virtualenv
-django==4.2.14
+django==4.2.15
# via
# -c requirements/edx/../common_constraints.txt
# -c requirements/edx/../constraints.txt
@@ -262,6 +268,7 @@ django==4.2.14
# django-multi-email-field
# django-mysql
# django-oauth-toolkit
+ # django-push-notifications
# django-sekizai
# django-ses
# django-statici18n
@@ -310,6 +317,7 @@ django==4.2.14
# openedx-filters
# openedx-learning
# ora2
+ # social-auth-app-django
# super-csv
# xblock-google-drive
# xss-utils
@@ -352,7 +360,7 @@ django-fernet-fields-v2==0.9
# via
# -r requirements/edx/base.txt
# edx-enterprise
-django-filter==24.2
+django-filter==24.3
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -407,6 +415,10 @@ django-object-actions==4.2.0
# edx-enterprise
django-pipeline==3.1.0
# via -r requirements/edx/base.txt
+django-push-notifications==3.1.0
+ # via
+ # -r requirements/edx/base.txt
+ # edx-ace
django-ratelimit==4.1.0
# via -r requirements/edx/base.txt
django-sekizai==4.1.0
@@ -430,8 +442,9 @@ django-statici18n==2.5.0
# lti-consumer-xblock
# xblock-drag-and-drop-v2
# xblock-poll
-django-storages==1.14.4
+django-storages==1.14.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edxval
django-user-tasks==3.2.0
@@ -474,7 +487,9 @@ djangorestframework-xml==2.0.0
# -r requirements/edx/base.txt
# edx-enterprise
dnspython==2.6.1
- # via email-validator
+ # via
+ # -r requirements/edx/base.txt
+ # pymongo
done-xblock==2.3.0
# via -r requirements/edx/base.txt
drf-jwt==1.19.2
@@ -488,7 +503,7 @@ drf-yasg==1.21.7
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.9.1
+edx-ace==1.11.1
# via -r requirements/edx/base.txt
edx-api-doc-tools==1.8.0
# via
@@ -516,7 +531,7 @@ edx-celeryutils==1.3.0
# super-csv
edx-codejail==3.4.1
# via -r requirements/edx/base.txt
-edx-completion==4.6.6
+edx-completion==4.6.7
# via -r requirements/edx/base.txt
edx-django-release-util==1.4.0
# via
@@ -525,7 +540,7 @@ edx-django-release-util==1.4.0
# edxval
edx-django-sites-extensions==4.2.0
# via -r requirements/edx/base.txt
-edx-django-utils==5.14.2
+edx-django-utils==5.15.0
# via
# -r requirements/edx/base.txt
# django-config-models
@@ -554,11 +569,11 @@ edx-drf-extensions==10.3.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.21.5
+edx-enterprise==4.23.3
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-edx-event-bus-kafka==5.7.0
+edx-event-bus-kafka==5.8.1
# via -r requirements/edx/base.txt
edx-event-bus-redis==0.5.0
# via -r requirements/edx/base.txt
@@ -603,11 +618,11 @@ edx-rest-api-client==5.7.1
# -r requirements/edx/base.txt
# edx-enterprise
# edx-proctoring
-edx-search==3.9.1
+edx-search==4.0.0
# via -r requirements/edx/base.txt
edx-sga==0.25.0
# via -r requirements/edx/base.txt
-edx-submissions==3.7.5
+edx-submissions==3.7.6
# via
# -r requirements/edx/base.txt
# ora2
@@ -640,16 +655,15 @@ elasticsearch==7.13.4
# -c requirements/edx/../common_constraints.txt
# -r requirements/edx/base.txt
# edx-search
-email-validator==2.2.0
- # via fastapi
enmerkar==0.7.1
# via
# -r requirements/edx/base.txt
# enmerkar-underscore
enmerkar-underscore==2.3.0
# via -r requirements/edx/base.txt
-event-tracking==2.4.0
+event-tracking==3.0.0
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-completion
# edx-proctoring
@@ -658,12 +672,10 @@ execnet==2.1.1
# via pytest-xdist
factory-boy==3.3.0
# via -r requirements/edx/testing.in
-faker==26.0.0
+faker==26.2.0
# via factory-boy
-fastapi==0.111.1
+fastapi==0.112.0
# via pact-python
-fastapi-cli==0.0.4
- # via fastapi
fastavro==1.9.5
# via
# -r requirements/edx/base.txt
@@ -674,6 +686,10 @@ filelock==3.15.4
# snowflake-connector-python
# tox
# virtualenv
+firebase-admin==6.5.0
+ # via
+ # -r requirements/edx/base.txt
+ # edx-ace
freezegun==1.5.1
# via -r requirements/edx/testing.in
frozenlist==1.4.1
@@ -699,40 +715,94 @@ geoip2==4.8.0
# via -r requirements/edx/base.txt
glob2==0.7
# via -r requirements/edx/base.txt
+google-api-core[grpc]==2.19.1
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+ # google-api-python-client
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-api-python-client==2.139.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+google-auth==2.32.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # google-api-python-client
+ # google-auth-httplib2
+ # google-cloud-core
+ # google-cloud-firestore
+ # google-cloud-storage
+google-auth-httplib2==0.2.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-python-client
+google-cloud-core==2.4.1
+ # via
+ # -r requirements/edx/base.txt
+ # google-cloud-firestore
+ # google-cloud-storage
+google-cloud-firestore==2.17.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+google-cloud-storage==2.18.0
+ # via
+ # -r requirements/edx/base.txt
+ # firebase-admin
+google-crc32c==1.5.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-cloud-storage
+ # google-resumable-media
+google-resumable-media==2.7.1
+ # via
+ # -r requirements/edx/base.txt
+ # google-cloud-storage
+googleapis-common-protos==1.63.2
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # grpcio-status
grimp==3.4.1
# via import-linter
+grpcio==1.65.4
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # grpcio-status
+grpcio-status==1.62.3
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
gunicorn==22.0.0
# via -r requirements/edx/base.txt
h11==0.14.0
- # via
- # httpcore
- # uvicorn
+ # via uvicorn
help-tokens==2.4.0
# via -r requirements/edx/base.txt
html5lib==1.1
# via
# -r requirements/edx/base.txt
# ora2
-httpcore==0.16.3
- # via httpx
+httplib2==0.22.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-python-client
+ # google-auth-httplib2
httpretty==1.1.4
# via -r requirements/edx/testing.in
-httptools==0.6.1
- # via uvicorn
-httpx==0.23.3
- # via
- # fastapi
- # pact-python
icalendar==5.0.13
# via -r requirements/edx/base.txt
idna==3.7
# via
# -r requirements/edx/base.txt
# anyio
- # email-validator
# optimizely-sdk
# requests
- # rfc3986
# snowflake-connector-python
# yarl
import-linter==2.0
@@ -768,7 +838,6 @@ jinja2==3.1.4
# -r requirements/edx/coverage.txt
# code-annotations
# diff-cover
- # fastapi
jmespath==1.0.1
# via
# -r requirements/edx/base.txt
@@ -778,7 +847,7 @@ joblib==1.4.2
# via
# -r requirements/edx/base.txt
# nltk
-jsondiff==2.1.2
+jsondiff==2.2.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -805,7 +874,7 @@ jwcrypto==1.5.6
# -r requirements/edx/base.txt
# django-oauth-toolkit
# pylti1p3
-kombu==5.3.7
+kombu==5.4.0
# via
# -r requirements/edx/base.txt
# celery
@@ -860,8 +929,6 @@ markdown==3.3.7
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markdown-it-py==3.0.0
- # via rich
markupsafe==2.1.5
# via
# -r requirements/edx/base.txt
@@ -877,8 +944,6 @@ maxminddb==2.6.2
# geoip2
mccabe==0.7.0
# via pylint
-mdurl==0.1.2
- # via markdown-it-py
meilisearch==0.31.4
# via -r requirements/edx/base.txt
mock==5.1.0
@@ -898,6 +963,10 @@ mpmath==1.3.0
# via
# -r requirements/edx/base.txt
# sympy
+msgpack==1.0.8
+ # via
+ # -r requirements/edx/base.txt
+ # cachecontrol
multidict==6.0.5
# via
# -r requirements/edx/base.txt
@@ -964,7 +1033,7 @@ openedx-filters==1.9.0
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
-openedx-learning==0.10.0
+openedx-learning==0.10.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -974,7 +1043,7 @@ optimizely-sdk==4.1.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-ora2==6.11.1
+ora2==6.11.2
# via -r requirements/edx/base.txt
packaging==24.1
# via
@@ -986,7 +1055,7 @@ packaging==24.1
# pytest
# snowflake-connector-python
# tox
-pact-python==2.0.1
+pact-python==2.2.1
# via -r requirements/edx/testing.in
pansi==2020.7.3
# via
@@ -1048,6 +1117,19 @@ prompt-toolkit==3.0.47
# via
# -r requirements/edx/base.txt
# click-repl
+proto-plus==1.24.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # google-cloud-firestore
+protobuf==4.25.4
+ # via
+ # -r requirements/edx/base.txt
+ # google-api-core
+ # google-cloud-firestore
+ # googleapis-common-protos
+ # grpcio-status
+ # proto-plus
psutil==6.0.0
# via
# -r requirements/edx/base.txt
@@ -1064,6 +1146,12 @@ pyasn1==0.6.0
# via
# -r requirements/edx/base.txt
# pgpy
+ # pyasn1-modules
+ # rsa
+pyasn1-modules==0.4.0
+ # via
+ # -r requirements/edx/base.txt
+ # google-auth
pycodestyle==2.8.0
# via
# -c requirements/edx/../constraints.txt
@@ -1095,13 +1183,12 @@ pygments==2.18.0
# -r requirements/edx/coverage.txt
# diff-cover
# py2neo
- # rich
pyjwkest==1.4.2
# via
# -r requirements/edx/base.txt
# edx-token-utils
# lti-consumer-xblock
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/edx/base.txt
# drf-jwt
@@ -1109,6 +1196,7 @@ pyjwt[crypto]==2.8.0
# edx-drf-extensions
# edx-proctoring
# edx-rest-api-client
+ # firebase-admin
# pylti1p3
# snowflake-connector-python
# social-auth-core
@@ -1138,7 +1226,7 @@ pylti1p3==2.0.0
# via -r requirements/edx/base.txt
pymemcache==4.0.0
# via -r requirements/edx/base.txt
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -1153,7 +1241,7 @@ pynacl==1.5.0
# paramiko
pynliner==0.8.0
# via -r requirements/edx/base.txt
-pyopenssl==24.1.0
+pyopenssl==24.2.1
# via
# -r requirements/edx/base.txt
# optimizely-sdk
@@ -1162,6 +1250,7 @@ pyparsing==3.1.2
# via
# -r requirements/edx/base.txt
# chem
+ # httplib2
# openedx-calc
pyproject-api==1.7.1
# via tox
@@ -1175,7 +1264,7 @@ pysrt==1.1.2
# via
# -r requirements/edx/base.txt
# edxval
-pytest==8.2.2
+pytest==8.3.2
# via
# -r requirements/edx/testing.in
# pylint-pytest
@@ -1217,16 +1306,12 @@ python-dateutil==2.9.0.post0
# olxcleaner
# ora2
# xblock
-python-dotenv==1.0.1
- # via uvicorn
python-ipware==3.0.0
# via
# -r requirements/edx/base.txt
# django-ipware
python-memcached==1.62
# via -r requirements/edx/base.txt
-python-multipart==0.0.9
- # via fastapi
python-slugify==8.0.4
# via
# -r requirements/edx/base.txt
@@ -1271,13 +1356,12 @@ pyyaml==6.0.1
# edx-django-release-util
# edx-i18n-tools
# jsondiff
- # uvicorn
# xblock
random2==1.0.2
# via -r requirements/edx/base.txt
recommender-xblock==2.2.0
# via -r requirements/edx/base.txt
-redis==5.0.7
+redis==5.0.8
# via
# -r requirements/edx/base.txt
# walrus
@@ -1286,7 +1370,7 @@ referencing==0.35.1
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
-regex==2024.5.15
+regex==2024.7.24
# via
# -r requirements/edx/base.txt
# nltk
@@ -1295,12 +1379,15 @@ requests==2.32.3
# -r requirements/edx/base.txt
# algoliasearch
# analytics-python
+ # cachecontrol
# django-oauth-toolkit
# edx-bulk-grades
# edx-drf-extensions
# edx-enterprise
# edx-rest-api-client
# geoip2
+ # google-api-core
+ # google-cloud-storage
# mailsnake
# meilisearch
# openai
@@ -1319,15 +1406,15 @@ requests-oauthlib==2.0.0
# via
# -r requirements/edx/base.txt
# social-auth-core
-rfc3986[idna2008]==1.5.0
- # via httpx
-rich==13.7.1
- # via typer
-rpds-py==0.19.0
+rpds-py==0.20.0
# via
# -r requirements/edx/base.txt
# jsonschema
# referencing
+rsa==4.9
+ # via
+ # -r requirements/edx/base.txt
+ # google-auth
rules==3.4
# via
# -r requirements/edx/base.txt
@@ -1353,8 +1440,6 @@ semantic-version==2.10.0
# edx-drf-extensions
shapely==2.0.5
# via -r requirements/edx/base.txt
-shellingham==1.5.4
- # via typer
simplejson==3.19.2
# via
# -r requirements/edx/base.txt
@@ -1400,22 +1485,18 @@ slumber==0.7.1
# edx-enterprise
# edx-rest-api-client
sniffio==1.3.1
- # via
- # anyio
- # httpcore
- # httpx
-snowflake-connector-python==3.11.0
+ # via anyio
+snowflake-connector-python==3.12.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-social-auth-app-django==5.0.0
+social-auth-app-django==5.4.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
-social-auth-core==4.3.0
+social-auth-core==4.5.4
# via
- # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-auth-backends
# social-auth-app-django
@@ -1451,7 +1532,7 @@ super-csv==3.2.0
# via
# -r requirements/edx/base.txt
# edx-bulk-grades
-sympy==1.13.0
+sympy==1.13.1
# via
# -r requirements/edx/base.txt
# openedx-calc
@@ -1473,15 +1554,13 @@ tomlkit==0.13.0
# -r requirements/edx/base.txt
# pylint
# snowflake-connector-python
-tox==4.16.0
+tox==4.17.0
# via -r requirements/edx/testing.in
-tqdm==4.66.4
+tqdm==4.66.5
# via
# -r requirements/edx/base.txt
# nltk
# openai
-typer==0.12.3
- # via fastapi-cli
typing-extensions==4.12.2
# via
# -r requirements/edx/base.txt
@@ -1495,7 +1574,6 @@ typing-extensions==4.12.2
# pydantic-core
# pylti1p3
# snowflake-connector-python
- # typer
tzdata==2024.1
# via
# -r requirements/edx/base.txt
@@ -1511,23 +1589,19 @@ uritemplate==4.1.1
# -r requirements/edx/base.txt
# drf-spectacular
# drf-yasg
+ # google-api-python-client
urllib3==1.26.19
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# botocore
# elasticsearch
- # pact-python
# py2neo
# requests
user-util==1.1.0
# via -r requirements/edx/base.txt
-uvicorn[standard]==0.30.1
- # via
- # fastapi
- # pact-python
-uvloop==0.19.0
- # via uvicorn
+uvicorn==0.30.5
+ # via pact-python
vine==5.1.0
# via
# -r requirements/edx/base.txt
@@ -1546,8 +1620,6 @@ walrus==0.9.4
# edx-event-bus-redis
watchdog==4.0.1
# via -r requirements/edx/base.txt
-watchfiles==0.22.0
- # via uvicorn
wcwidth==0.2.13
# via
# -r requirements/edx/base.txt
@@ -1570,14 +1642,13 @@ webob==1.8.7
# via
# -r requirements/edx/base.txt
# xblock
-websockets==12.0
- # via uvicorn
wrapt==1.16.0
# via
# -r requirements/edx/base.txt
# astroid
xblock[django]==4.0.1
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# acid-xblock
# crowdsourcehinter-xblock
@@ -1613,6 +1684,7 @@ yarl==1.9.4
# via
# -r requirements/edx/base.txt
# aiohttp
+ # pact-python
zipp==3.19.2
# via
# -r requirements/edx/base.txt
diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt
index 3630835e943d..f7b35489c353 100644
--- a/requirements/pip-tools.txt
+++ b/requirements/pip-tools.txt
@@ -18,7 +18,7 @@ pyproject-hooks==1.1.0
# via
# build
# pip-tools
-wheel==0.43.0
+wheel==0.44.0
# via pip-tools
# The following packages are considered to be unsafe in a requirements file:
diff --git a/requirements/pip.txt b/requirements/pip.txt
index df29e61c6017..7a6ada8e0a92 100644
--- a/requirements/pip.txt
+++ b/requirements/pip.txt
@@ -4,11 +4,11 @@
#
# make upgrade
#
-wheel==0.43.0
+wheel==0.44.0
# via -r requirements/pip.in
# The following packages are considered to be unsafe in a requirements file:
-pip==24.1.2
+pip==24.2
# via -r requirements/pip.in
-setuptools==70.3.0
+setuptools==72.1.0
# via -r requirements/pip.in
diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt
index 87aa858e9f8e..828a81a8d4ed 100644
--- a/scripts/structures_pruning/requirements/base.txt
+++ b/scripts/structures_pruning/requirements/base.txt
@@ -11,11 +11,13 @@ click==8.1.6
# click-log
click-log==0.4.0
# via -r scripts/structures_pruning/requirements/base.in
+dnspython==2.6.1
+ # via pymongo
edx-opaque-keys==2.10.0
# via -r scripts/structures_pruning/requirements/base.in
pbr==6.0.0
# via stevedore
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -c scripts/structures_pruning/requirements/../../../requirements/constraints.txt
# -r scripts/structures_pruning/requirements/base.in
diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt
index 2590ca8ca52b..47648d50fddb 100644
--- a/scripts/structures_pruning/requirements/testing.txt
+++ b/scripts/structures_pruning/requirements/testing.txt
@@ -12,6 +12,10 @@ click-log==0.4.0
# via -r scripts/structures_pruning/requirements/base.txt
ddt==1.7.2
# via -r scripts/structures_pruning/requirements/testing.in
+dnspython==2.6.1
+ # via
+ # -r scripts/structures_pruning/requirements/base.txt
+ # pymongo
edx-opaque-keys==2.10.0
# via -r scripts/structures_pruning/requirements/base.txt
iniconfig==2.0.0
@@ -24,11 +28,11 @@ pbr==6.0.0
# stevedore
pluggy==1.5.0
# via pytest
-pymongo==3.13.0
+pymongo==4.4.0
# via
# -r scripts/structures_pruning/requirements/base.txt
# edx-opaque-keys
-pytest==8.2.2
+pytest==8.3.2
# via -r scripts/structures_pruning/requirements/testing.in
stevedore==5.2.0
# via
diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt
index 30d5df674f2e..47e6e79c2240 100644
--- a/scripts/user_retirement/requirements/base.txt
+++ b/scripts/user_retirement/requirements/base.txt
@@ -6,13 +6,13 @@
#
asgiref==3.8.1
# via django
-attrs==23.2.0
+attrs==24.2.0
# via zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.in
-boto3==1.34.144
+boto3==1.34.154
# via -r scripts/user_retirement/requirements/base.in
-botocore==1.34.144
+botocore==1.34.154
# via
# boto3
# s3transfer
@@ -20,7 +20,7 @@ cachetools==5.4.0
# via google-auth
certifi==2024.7.4
# via requests
-cffi==1.16.0
+cffi==1.17.0
# via
# cryptography
# pynacl
@@ -33,9 +33,9 @@ click==8.1.6
# -c scripts/user_retirement/requirements/../../../requirements/constraints.txt
# -r scripts/user_retirement/requirements/base.in
# edx-django-utils
-cryptography==42.0.8
+cryptography==43.0.0
# via pyjwt
-django==4.2.14
+django==4.2.15
# via
# -c scripts/user_retirement/requirements/../../../requirements/common_constraints.txt
# -c scripts/user_retirement/requirements/../../../requirements/constraints.txt
@@ -46,13 +46,13 @@ django-crum==0.7.9
# via edx-django-utils
django-waffle==4.1.0
# via edx-django-utils
-edx-django-utils==5.14.2
+edx-django-utils==5.15.0
# via edx-rest-api-client
edx-rest-api-client==5.7.1
# via -r scripts/user_retirement/requirements/base.in
google-api-core==2.19.1
# via google-api-python-client
-google-api-python-client==2.137.0
+google-api-python-client==2.139.0
# via -r scripts/user_retirement/requirements/base.in
google-auth==2.32.0
# via
@@ -91,7 +91,7 @@ platformdirs==4.2.2
# via zeep
proto-plus==1.24.0
# via google-api-core
-protobuf==5.27.2
+protobuf==5.27.3
# via
# google-api-core
# googleapis-common-protos
@@ -106,7 +106,7 @@ pyasn1-modules==0.4.0
# via google-auth
pycparser==2.22
# via cffi
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# edx-rest-api-client
# simple-salesforce
diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt
index c10b36f0de60..006eabeef436 100644
--- a/scripts/user_retirement/requirements/testing.txt
+++ b/scripts/user_retirement/requirements/testing.txt
@@ -8,17 +8,17 @@ asgiref==3.8.1
# via
# -r scripts/user_retirement/requirements/base.txt
# django
-attrs==23.2.0
+attrs==24.2.0
# via
# -r scripts/user_retirement/requirements/base.txt
# zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.txt
-boto3==1.34.144
+boto3==1.34.154
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
-botocore==1.34.144
+botocore==1.34.154
# via
# -r scripts/user_retirement/requirements/base.txt
# boto3
@@ -32,7 +32,7 @@ certifi==2024.7.4
# via
# -r scripts/user_retirement/requirements/base.txt
# requests
-cffi==1.16.0
+cffi==1.17.0
# via
# -r scripts/user_retirement/requirements/base.txt
# cryptography
@@ -45,14 +45,14 @@ click==8.1.6
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
-cryptography==42.0.8
+cryptography==43.0.0
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
# pyjwt
ddt==1.7.2
# via -r scripts/user_retirement/requirements/testing.in
-django==4.2.14
+django==4.2.15
# via
# -r scripts/user_retirement/requirements/base.txt
# django-crum
@@ -66,7 +66,7 @@ django-waffle==4.1.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
-edx-django-utils==5.14.2
+edx-django-utils==5.15.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-rest-api-client
@@ -76,7 +76,7 @@ google-api-core==2.19.1
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-python-client
-google-api-python-client==2.137.0
+google-api-python-client==2.139.0
# via -r scripts/user_retirement/requirements/base.txt
google-auth==2.32.0
# via
@@ -152,7 +152,7 @@ proto-plus==1.24.0
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-core
-protobuf==5.27.2
+protobuf==5.27.3
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-core
@@ -175,7 +175,7 @@ pycparser==2.22
# via
# -r scripts/user_retirement/requirements/base.txt
# cffi
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-rest-api-client
@@ -188,7 +188,7 @@ pyparsing==3.1.2
# via
# -r scripts/user_retirement/requirements/base.txt
# httplib2
-pytest==8.2.2
+pytest==8.3.2
# via -r scripts/user_retirement/requirements/testing.in
python-dateutil==2.9.0.post0
# via
diff --git a/scripts/user_retirement/retire_one_learner.py b/scripts/user_retirement/retire_one_learner.py
index 2d298c0729b4..b8c40364e643 100755
--- a/scripts/user_retirement/retire_one_learner.py
+++ b/scripts/user_retirement/retire_one_learner.py
@@ -10,11 +10,9 @@
lms: http://localhost:18000/
ecommerce: http://localhost:18130/
credentials: http://localhost:18150/
- demographics: http://localhost:18360/
retirement_pipeline:
- ['RETIRING_CREDENTIALS', 'CREDENTIALS_COMPLETE', 'CREDENTIALS', 'retire_learner']
- ['RETIRING_ECOM', 'ECOM_COMPLETE', 'ECOMMERCE', 'retire_learner']
- - ['RETIRING_DEMOGRAPHICS', 'DEMOGRAPHICS_COMPLETE', 'DEMOGRAPHICS', 'retire_learner']
- ['RETIRING_LICENSE_MANAGER', 'LICENSE_MANAGER_COMPLETE', 'LICENSE_MANAGER', 'retire_learner']
- ['RETIRING_FORUMS', 'FORUMS_COMPLETE', 'LMS', 'retirement_retire_forum']
- ['RETIRING_EMAIL_LISTS', 'EMAIL_LISTS_COMPLETE', 'LMS', 'retirement_retire_mailings']
diff --git a/scripts/user_retirement/tests/utils/test_edx_api.py b/scripts/user_retirement/tests/utils/test_edx_api.py
index 706b1ce6d4a7..eb826cd1a4fd 100644
--- a/scripts/user_retirement/tests/utils/test_edx_api.py
+++ b/scripts/user_retirement/tests/utils/test_edx_api.py
@@ -21,7 +21,7 @@
FAKE_USERNAMES,
TEST_RETIREMENT_QUEUE_STATES,
TEST_RETIREMENT_STATE,
- get_fake_user_retirement
+ get_fake_user_retirement,
)
from scripts.user_retirement.utils import edx_api
@@ -499,46 +499,6 @@ def test_replace_usernames(self, mock_method):
)
-class TestDemographicsApi(OAuth2Mixin, unittest.TestCase):
- """
- Test the edX Demographics API client.
- """
-
- @responses.activate(registry=OrderedRegistry)
- def setUp(self):
- super().setUp()
- self.mock_access_token_response()
- self.lms_base_url = 'http://localhost:18000/'
- self.demographics_base_url = 'http://localhost:18360/'
- self.demographics_api = edx_api.DemographicsApi(
- self.lms_base_url,
- self.demographics_base_url,
- 'the_client_id',
- 'the_client_secret'
- )
-
- def tearDown(self):
- super().tearDown()
- responses.reset()
-
- @patch.object(edx_api.DemographicsApi, 'retire_learner')
- def test_retire_learner(self, mock_method):
- json_data = {
- 'lms_user_id': get_fake_user_retirement()['user']['id']
- }
- responses.add(
- POST,
- urljoin(self.demographics_base_url, 'demographics/api/v1/retire_demographics/'),
- match=[matchers.json_params_matcher(json_data)]
- )
- self.demographics_api.retire_learner(
- learner=get_fake_user_retirement()
- )
- mock_method.assert_called_once_with(
- learner=get_fake_user_retirement()
- )
-
-
class TestLicenseManagerApi(OAuth2Mixin, unittest.TestCase):
"""
Test the edX License Manager API client.
diff --git a/scripts/user_retirement/utils/edx_api.py b/scripts/user_retirement/utils/edx_api.py
index 10903640456a..e891f04019a9 100644
--- a/scripts/user_retirement/utils/edx_api.py
+++ b/scripts/user_retirement/utils/edx_api.py
@@ -1,6 +1,7 @@
"""
edX API classes which call edX service REST API endpoints using the edx-rest-api-client module.
"""
+
import logging
from urllib.parse import urljoin
@@ -28,6 +29,7 @@ class BaseApiClient:
"""
API client base class used to submit API requests to a particular web service.
"""
+
append_slash = True
_access_token = None
@@ -45,15 +47,15 @@ def get_api_url(self, path):
Args:
path (str): API endpoint path.
"""
- path = path.strip('/')
+ path = path.strip("/")
if self.append_slash:
- path += '/'
+ path += "/"
- return urljoin(f'{self.api_base_url}/', path)
+ return urljoin(f"{self.api_base_url}/", path)
def _request(self, method, url, log_404_as_error=True, **kwargs):
- if 'headers' not in kwargs:
- kwargs['headers'] = {'Content-type': 'application/json'}
+ if "headers" not in kwargs:
+ kwargs["headers"] = {"Content-type": "application/json"}
try:
response = requests.request(method, url, auth=SuppliedJwtAuth(self._access_token), **kwargs)
@@ -68,7 +70,7 @@ def _request(self, method, url, log_404_as_error=True, **kwargs):
# Immediately raise the error so that a 404 isn't logged as an API error in this case.
raise HttpDoesNotExistException(str(exc))
- LOG.error(f'API Error: {str(exc)} with status code: {status_code}')
+ LOG.error(f"API Error: {str(exc)} with status code: {status_code}")
if status_code == 504:
# Differentiate gateway errors so different backoff can be used.
@@ -92,31 +94,31 @@ def get_access_token(oauth_base_url, client_id, client_secret):
Returns:
str: JWT access token
"""
- oauth_access_token_url = urljoin(f'{oauth_base_url}/', OAUTH_ACCESS_TOKEN_URL)
+ oauth_access_token_url = urljoin(f"{oauth_base_url}/", OAUTH_ACCESS_TOKEN_URL)
data = {
- 'grant_type': 'client_credentials',
- 'client_id': client_id,
- 'client_secret': client_secret,
- 'token_type': 'jwt',
+ "grant_type": "client_credentials",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "token_type": "jwt",
}
try:
response = requests.post(
oauth_access_token_url,
data=data,
headers={
- 'User-Agent': 'scripts.user_retirement',
+ "User-Agent": "scripts.user_retirement",
},
- timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READ_TIMEOUT)
+ timeout=(REQUEST_CONNECT_TIMEOUT, REQUEST_READ_TIMEOUT),
)
response.raise_for_status()
- return response.json()['access_token']
+ return response.json()["access_token"]
except KeyError as exc:
- LOG.error(f'Failed to get token. {str(exc)} does not exist.')
+ LOG.error(f"Failed to get token. {str(exc)} does not exist.")
raise
except HTTPError as exc:
LOG.error(
- f'API Error: {str(exc)} with status code: {exc.response.status_code} fetching access token: {client_id}'
+ f"API Error: {str(exc)} with status code: {exc.response.status_code} fetching access token: {client_id}"
)
raise
@@ -125,7 +127,7 @@ def _backoff_handler(details):
"""
Simple logging handler for when timeout backoff occurs.
"""
- LOG.info('Trying again in {wait:0.1f} seconds after {tries} tries calling {target}'.format(**details))
+ LOG.info("Trying again in {wait:0.1f} seconds after {tries} tries calling {target}".format(**details))
def _wait_one_minute():
@@ -143,10 +145,7 @@ def _giveup_on_unexpected_exception(exc):
# Treat a ConnectionError as retryable.
isinstance(exc, ConnectionError)
# All 5xx status codes are retryable except for 504 Gateway Timeout.
- or (
- 500 <= exc.response.status_code < 600
- and exc.response.status_code != 504 # Gateway Timeout
- )
+ or (500 <= exc.response.status_code < 600 and exc.response.status_code != 504) # Gateway Timeout
# Status code 104 is unreserved, but we must have added this because we observed retryable 104 responses.
or exc.response.status_code == 104
)
@@ -167,7 +166,7 @@ def inner(func): # pylint: disable=missing-docstring
# Wrap the actual _backoff_handler so that we can patch the real one in unit tests. Otherwise, the func
# will get decorated on import, embedding this handler as a python object reference, precluding our ability
# to patch it in tests.
- on_backoff=lambda details: _backoff_handler(details) # pylint: disable=unnecessary-lambda
+ on_backoff=lambda details: _backoff_handler(details), # pylint: disable=unnecessary-lambda
)
func_with_timeout_backoff = backoff.on_exception(
_wait_one_minute,
@@ -176,7 +175,7 @@ def inner(func): # pylint: disable=missing-docstring
# Wrap the actual _backoff_handler so that we can patch the real one in unit tests. Otherwise, the func
# will get decorated on import, embedding this handler as a python object reference, precluding our ability
# to patch it in tests.
- on_backoff=lambda details: _backoff_handler(details) # pylint: disable=unnecessary-lambda
+ on_backoff=lambda details: _backoff_handler(details), # pylint: disable=unnecessary-lambda
)
return func_with_backoff(func_with_timeout_backoff(func))
@@ -193,14 +192,11 @@ def learners_to_retire(self, states_to_request, cool_off_days=7, limit=None):
"""
Retrieves a list of learners awaiting retirement actions.
"""
- params = {
- 'cool_off_days': cool_off_days,
- 'states': states_to_request
- }
+ params = {"cool_off_days": cool_off_days, "states": states_to_request}
if limit:
- params['limit'] = limit
- api_url = self.get_api_url('api/user/v1/accounts/retirement_queue')
- return self._request('GET', api_url, params=params)
+ params["limit"] = limit
+ api_url = self.get_api_url("api/user/v1/accounts/retirement_queue")
+ return self._request("GET", api_url, params=params)
@_retry_lms_api()
def get_learners_by_date_and_status(self, state_to_request, start_date, end_date):
@@ -214,20 +210,20 @@ def get_learners_by_date_and_status(self, state_to_request, start_date, end_date
:param end_date: Date or Datetime
"""
params = {
- 'start_date': start_date.strftime('%Y-%m-%d'),
- 'end_date': end_date.strftime('%Y-%m-%d'),
- 'state': state_to_request
+ "start_date": start_date.strftime("%Y-%m-%d"),
+ "end_date": end_date.strftime("%Y-%m-%d"),
+ "state": state_to_request,
}
- api_url = self.get_api_url('api/user/v1/accounts/retirements_by_status_and_date')
- return self._request('GET', api_url, params=params)
+ api_url = self.get_api_url("api/user/v1/accounts/retirements_by_status_and_date")
+ return self._request("GET", api_url, params=params)
@_retry_lms_api()
def get_learner_retirement_state(self, username):
"""
Retrieves the given learner's retirement state.
"""
- api_url = self.get_api_url(f'api/user/v1/accounts/{username}/retirement_status')
- return self._request('GET', api_url)
+ api_url = self.get_api_url(f"api/user/v1/accounts/{username}/retirement_status")
+ return self._request("GET", api_url)
@_retry_lms_api()
def update_learner_retirement_state(self, username, new_state_name, message, force=False):
@@ -235,26 +231,22 @@ def update_learner_retirement_state(self, username, new_state_name, message, for
Updates the given learner's retirement state to the retirement state name new_string
with the additional string information in message (for logging purposes).
"""
- data = {
- 'username': username,
- 'new_state': new_state_name,
- 'response': message
- }
+ data = {"username": username, "new_state": new_state_name, "response": message}
if force:
- data['force'] = True
+ data["force"] = True
- api_url = self.get_api_url('api/user/v1/accounts/update_retirement_status')
- return self._request('PATCH', api_url, json=data)
+ api_url = self.get_api_url("api/user/v1/accounts/update_retirement_status")
+ return self._request("PATCH", api_url, json=data)
@_retry_lms_api()
def retirement_deactivate_logout(self, learner):
"""
Performs the user deactivation and forced logout step of learner retirement
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/user/v1/accounts/deactivate_logout')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/user/v1/accounts/deactivate_logout")
+ return self._request("POST", api_url, json=data)
@_retry_lms_api()
def retirement_retire_forum(self, learner):
@@ -262,10 +254,10 @@ def retirement_retire_forum(self, learner):
Performs the forum retirement step of learner retirement
"""
# api/discussion/
- data = {'username': learner['original_username']}
+ data = {"username": learner["original_username"]}
try:
- api_url = self.get_api_url('api/discussion/v1/accounts/retire_forum')
- return self._request('POST', api_url, json=data)
+ api_url = self.get_api_url("api/discussion/v1/accounts/retire_forum")
+ return self._request("POST", api_url, json=data)
except HttpDoesNotExistException:
LOG.info("No information about learner retirement")
return True
@@ -275,18 +267,18 @@ def retirement_retire_mailings(self, learner):
"""
Performs the email list retirement step of learner retirement
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/user/v1/accounts/retire_mailings')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/user/v1/accounts/retire_mailings")
+ return self._request("POST", api_url, json=data)
@_retry_lms_api()
def retirement_unenroll(self, learner):
"""
Unenrolls the user from all courses
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/enrollment/v1/unenroll')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/enrollment/v1/unenroll")
+ return self._request("POST", api_url, json=data)
# This endpoint additionally returns 500 when the EdxNotes backend service is unavailable.
@_retry_lms_api()
@@ -294,9 +286,9 @@ def retirement_retire_notes(self, learner):
"""
Deletes all the user's notes (aka. annotations)
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/edxnotes/v1/retire_user')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/edxnotes/v1/retire_user")
+ return self._request("POST", api_url, json=data)
@_retry_lms_api()
def retirement_lms_retire_misc(self, learner):
@@ -304,27 +296,27 @@ def retirement_lms_retire_misc(self, learner):
Deletes, blanks, or one-way hashes personal information in LMS as
defined in EDUCATOR-2802 and sub-tasks.
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/user/v1/accounts/retire_misc')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/user/v1/accounts/retire_misc")
+ return self._request("POST", api_url, json=data)
@_retry_lms_api()
def retirement_lms_retire(self, learner):
"""
Deletes, blanks, or one-way hashes all remaining personal information in LMS
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/user/v1/accounts/retire')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/user/v1/accounts/retire")
+ return self._request("POST", api_url, json=data)
@_retry_lms_api()
def retirement_partner_queue(self, learner):
"""
Calls LMS to add the given user to the retirement reporting queue
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/user/v1/accounts/retirement_partner_report')
- return self._request('PUT', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/user/v1/accounts/retirement_partner_report")
+ return self._request("PUT", api_url, json=data)
@_retry_lms_api()
def retirement_partner_report(self):
@@ -332,16 +324,16 @@ def retirement_partner_report(self):
Retrieves the list of users to create partner reports for and set their status to
processing
"""
- api_url = self.get_api_url('api/user/v1/accounts/retirement_partner_report')
- return self._request('POST', api_url)
+ api_url = self.get_api_url("api/user/v1/accounts/retirement_partner_report")
+ return self._request("POST", api_url)
@_retry_lms_api()
def retirement_partner_cleanup(self, usernames):
"""
Removes the given users from the partner reporting queue
"""
- api_url = self.get_api_url('api/user/v1/accounts/retirement_partner_report_cleanup')
- return self._request('POST', api_url, json=usernames)
+ api_url = self.get_api_url("api/user/v1/accounts/retirement_partner_report_cleanup")
+ return self._request("POST", api_url, json=usernames)
@_retry_lms_api()
def retirement_retire_proctoring_data(self, learner):
@@ -349,7 +341,7 @@ def retirement_retire_proctoring_data(self, learner):
Deletes or hashes learner data from edx-proctoring
"""
api_url = self.get_api_url(f"api/edx_proctoring/v1/retire_user/{learner['user']['id']}")
- return self._request('POST', api_url)
+ return self._request("POST", api_url)
@_retry_lms_api()
def retirement_retire_proctoring_backend_data(self, learner):
@@ -357,16 +349,16 @@ def retirement_retire_proctoring_backend_data(self, learner):
Removes the given learner from 3rd party proctoring backends
"""
api_url = self.get_api_url(f"api/edx_proctoring/v1/retire_backend_user/{learner['user']['id']}")
- return self._request('POST', api_url)
+ return self._request("POST", api_url)
@_retry_lms_api()
def bulk_cleanup_retirements(self, usernames):
"""
Deletes the retirements for all given usernames
"""
- data = {'usernames': usernames}
- api_url = self.get_api_url('api/user/v1/accounts/retirement_cleanup')
- return self._request('POST', api_url, json=data)
+ data = {"usernames": usernames}
+ api_url = self.get_api_url("api/user/v1/accounts/retirement_cleanup")
+ return self._request("POST", api_url, json=data)
def replace_lms_usernames(self, username_mappings):
"""
@@ -377,8 +369,8 @@ def replace_lms_usernames(self, username_mappings):
[{current_un_1: desired_un_1}, {current_un_2: desired_un_2}]
"""
data = {"username_mappings": username_mappings}
- api_url = self.get_api_url('api/user/v1/accounts/replace_usernames')
- return self._request('POST', api_url, json=data)
+ api_url = self.get_api_url("api/user/v1/accounts/replace_usernames")
+ return self._request("POST", api_url, json=data)
def replace_forums_usernames(self, username_mappings):
"""
@@ -389,8 +381,8 @@ def replace_forums_usernames(self, username_mappings):
[{current_un_1: new_un_1}, {current_un_2: new_un_2}]
"""
data = {"username_mappings": username_mappings}
- api_url = self.get_api_url('api/discussion/v1/accounts/replace_usernames')
- return self._request('POST', api_url, json=data)
+ api_url = self.get_api_url("api/discussion/v1/accounts/replace_usernames")
+ return self._request("POST", api_url, json=data)
class EcommerceApi(BaseApiClient):
@@ -403,9 +395,9 @@ def retire_learner(self, learner):
"""
Performs the learner retirement step for Ecommerce
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('api/v2/user/retire')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("api/v2/user/retire")
+ return self._request("POST", api_url, json=data)
@_retry_lms_api()
def get_tracking_key(self, learner):
@@ -414,7 +406,7 @@ def get_tracking_key(self, learner):
ecommerce doesn't have access to the LMS user id.
"""
api_url = self.get_api_url(f"api/v2/retirement/tracking_id/{learner['original_username']}")
- return self._request('GET', api_url)['ecommerce_tracking_id']
+ return self._request("GET", api_url)["ecommerce_tracking_id"]
def replace_usernames(self, username_mappings):
"""
@@ -425,8 +417,8 @@ def replace_usernames(self, username_mappings):
[{current_un_1: new_un_1}, {current_un_2: new_un_2}]
"""
data = {"username_mappings": username_mappings}
- api_url = self.get_api_url('api/v2/user_management/replace_usernames')
- return self._request('POST', api_url, json=data)
+ api_url = self.get_api_url("api/v2/user_management/replace_usernames")
+ return self._request("POST", api_url, json=data)
class CredentialsApi(BaseApiClient):
@@ -439,9 +431,9 @@ def retire_learner(self, learner):
"""
Performs the learner retirement step for Credentials
"""
- data = {'username': learner['original_username']}
- api_url = self.get_api_url('user/retire')
- return self._request('POST', api_url, json=data)
+ data = {"username": learner["original_username"]}
+ api_url = self.get_api_url("user/retire")
+ return self._request("POST", api_url, json=data)
def replace_usernames(self, username_mappings):
"""
@@ -452,8 +444,8 @@ def replace_usernames(self, username_mappings):
[{current_un_1: new_un_1}, {current_un_2: new_un_2}]
"""
data = {"username_mappings": username_mappings}
- api_url = self.get_api_url('api/v2/replace_usernames')
- return self._request('POST', api_url, json=data)
+ api_url = self.get_api_url("api/v2/replace_usernames")
+ return self._request("POST", api_url, json=data)
class DiscoveryApi(BaseApiClient):
@@ -470,30 +462,8 @@ def replace_usernames(self, username_mappings):
[{current_un_1: new_un_1}, {current_un_2: new_un_2}]
"""
data = {"username_mappings": username_mappings}
- api_url = self.get_api_url('api/v1/replace_usernames')
- return self._request('POST', api_url, json=data)
-
-
-class DemographicsApi(BaseApiClient):
- """
- Demographics API client.
- """
-
- @_retry_lms_api()
- def retire_learner(self, learner):
- """
- Performs the learner retirement step for Demographics. Passes the learner's LMS User Id instead of username.
- """
- data = {'lms_user_id': learner['user']['id']}
- # If the user we are retiring has no data in the Demographics DB the request will return a 404. We
- # catch the HTTPError and return True in order to prevent this error getting raised and
- # incorrectly causing the learner to enter an ERROR state during retirement.
- try:
- api_url = self.get_api_url('demographics/api/v1/retire_demographics')
- return self._request('POST', api_url, log_404_as_error=False, json=data)
- except HttpDoesNotExistException:
- LOG.info("No demographics data found for user")
- return True
+ api_url = self.get_api_url("api/v1/replace_usernames")
+ return self._request("POST", api_url, json=data)
class LicenseManagerApi(BaseApiClient):
@@ -508,15 +478,15 @@ def retire_learner(self, learner):
username.
"""
data = {
- 'lms_user_id': learner['user']['id'],
- 'original_username': learner['original_username'],
+ "lms_user_id": learner["user"]["id"],
+ "original_username": learner["original_username"],
}
# If the user we are retiring has no data in the License Manager DB the request will return a 404. We
# catch the HTTPError and return True in order to prevent this error getting raised and
# incorrectly causing the learner to enter an ERROR state during retirement.
try:
- api_url = self.get_api_url('api/v1/retire_user')
- return self._request('POST', api_url, log_404_as_error=False, json=data)
+ api_url = self.get_api_url("api/v1/retire_user")
+ return self._request("POST", api_url, log_404_as_error=False, json=data)
except HttpDoesNotExistException:
LOG.info("No license manager data found for user")
return True
diff --git a/scripts/user_retirement/utils/helpers.py b/scripts/user_retirement/utils/helpers.py
index 8203e363593c..1bcbadb4b3c4 100644
--- a/scripts/user_retirement/utils/helpers.py
+++ b/scripts/user_retirement/utils/helpers.py
@@ -18,7 +18,7 @@
from six import text_type
from scripts.user_retirement.utils.edx_api import LmsApi # pylint: disable=wrong-import-position
-from scripts.user_retirement.utils.edx_api import CredentialsApi, DemographicsApi, EcommerceApi, LicenseManagerApi
+from scripts.user_retirement.utils.edx_api import CredentialsApi, EcommerceApi, LicenseManagerApi
from scripts.user_retirement.utils.thirdparty_apis.amplitude_api import \
AmplitudeApi # pylint: disable=wrong-import-position
from scripts.user_retirement.utils.thirdparty_apis.braze_api import BrazeApi # pylint: disable=wrong-import-position
@@ -143,17 +143,16 @@ def _setup_lms_api_or_exit(fail_func, fail_code, config):
def _setup_all_apis_or_exit(fail_func, fail_code, config):
"""
- Performs setup of EdxRestClientApi instances for LMS, E-Commerce, Credentials, and
- Demographics, as well as fetching the learner's record from LMS and validating that
- it is in a state to work on. Returns the learner dict and their current stage in the
- retirement flow.
+ Performs setup of EdxRestClientApi instances for LMS, E-Commerce, and Credentials,
+ as well as fetching the learner's record from LMS and validating that it is
+ in a state to work on. Returns the learner dict and their current stage in
+ the retirement flow.
"""
try:
lms_base_url = config['base_urls']['lms']
ecommerce_base_url = config['base_urls'].get('ecommerce', None)
credentials_base_url = config['base_urls'].get('credentials', None)
segment_base_url = config['base_urls'].get('segment', None)
- demographics_base_url = config['base_urls'].get('demographics', None)
license_manager_base_url = config['base_urls'].get('license_manager', None)
client_id = config['client_id']
client_secret = config['client_secret']
@@ -181,7 +180,6 @@ def _setup_all_apis_or_exit(fail_func, fail_code, config):
('CREDENTIALS', credentials_base_url),
('SEGMENT', segment_base_url),
('HUBSPOT', hubspot_api_key),
- ('DEMOGRAPHICS', demographics_base_url)
):
if state[2] == service and service_url is None:
fail_func(fail_code, 'Service URL is not configured, but required for state {}'.format(state))
@@ -223,9 +221,6 @@ def _setup_all_apis_or_exit(fail_func, fail_code, config):
if credentials_base_url:
config['CREDENTIALS'] = CredentialsApi(lms_base_url, credentials_base_url, client_id, client_secret)
- if demographics_base_url:
- config['DEMOGRAPHICS'] = DemographicsApi(lms_base_url, demographics_base_url, client_id, client_secret)
-
if license_manager_base_url:
config['LICENSE_MANAGER'] = LicenseManagerApi(
lms_base_url,
diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py
index 53bb93a56c18..54ca0cbc312f 100644
--- a/xmodule/capa_block.py
+++ b/xmodule/capa_block.py
@@ -795,12 +795,25 @@ def generate_report_data(self, user_state_iterator, limit_responses=None):
}
yield (user_state.username, report)
+ @property
+ def course_end_date(self):
+ """
+ Return the end date of the problem's course
+ """
+
+ try:
+ course_block_key = self.runtime.course_entry.structure['root']
+ return self.runtime.course_entry.structure['blocks'][course_block_key].fields['end']
+ except (AttributeError, KeyError):
+ return None
+
@property
def close_date(self):
"""
Return the date submissions should be closed from.
"""
- due_date = self.due
+
+ due_date = self.due or self.course_end_date
if self.graceperiod is not None and due_date:
return due_date + self.graceperiod
diff --git a/xmodule/contentstore/mongo.py b/xmodule/contentstore/mongo.py
index 66d9474cde7c..e44f03cede05 100644
--- a/xmodule/contentstore/mongo.py
+++ b/xmodule/contentstore/mongo.py
@@ -3,6 +3,7 @@
"""
+import hashlib
import json
import os
@@ -40,16 +41,29 @@ def __init__(
# GridFS will throw an exception if the Database is wrapped in a MongoProxy. So don't wrap it.
# The appropriate methods below are marked as autoretry_read - those methods will handle
# the AutoReconnect errors.
- proxy = False
- mongo_db = connect_to_mongodb(
- db, host,
- port=port, tz_aware=tz_aware, user=user, password=password, proxy=proxy, **kwargs
- )
+ self.connection_params = {
+ 'db': db,
+ 'host': host,
+ 'port': port,
+ 'tz_aware': tz_aware,
+ 'user': user,
+ 'password': password,
+ 'proxy': False,
+ **kwargs
+ }
+ self.bucket = bucket
+ self.do_connection()
+
+ def do_connection(self):
+ """
+ Connects to mongodb.
+ """
+ mongo_db = connect_to_mongodb(**self.connection_params)
- self.fs = gridfs.GridFS(mongo_db, bucket) # pylint: disable=invalid-name
+ self.fs = gridfs.GridFS(mongo_db, self.bucket) # pylint: disable=invalid-name
- self.fs_files = mongo_db[bucket + ".files"] # the underlying collection GridFS uses
- self.chunks = mongo_db[bucket + ".chunks"]
+ self.fs_files = mongo_db[self.bucket + ".files"] # the underlying collection GridFS uses
+ self.chunks = mongo_db[self.bucket + ".chunks"]
def close_connections(self):
"""
@@ -57,6 +71,25 @@ def close_connections(self):
"""
self.fs_files.database.client.close()
+ def ensure_connection(self):
+ """
+ Ensure that mongodb connection is open.
+ """
+ if self.check_connection():
+ return
+ self.do_connection()
+
+ def check_connection(self):
+ """
+ Check if mongodb connection is open or not.
+ """
+ connection = self.fs_files.database.client
+ try:
+ connection.admin.command('ping')
+ return True
+ except pymongo.errors.InvalidOperation:
+ return False
+
def _drop_database(self, database=True, collections=True, connections=True):
"""
A destructive operation to drop the underlying database and close all connections.
@@ -69,8 +102,8 @@ def _drop_database(self, database=True, collections=True, connections=True):
If connections is True, then close the connection to the database as well.
"""
+ self.ensure_connection()
connection = self.fs_files.database.client
-
if database:
connection.drop_database(self.fs_files.database.name)
elif collections:
@@ -103,16 +136,22 @@ def save(self, content):
# but many more objects have this in python3 and shouldn't be using the chunking logic. For string and
# byte streams we write them directly to gridfs and convert them to byetarrys if necessary.
if hasattr(content.data, '__iter__') and not isinstance(content.data, (bytes, (str,))):
+ custom_md5 = hashlib.md5()
for chunk in content.data:
fp.write(chunk)
+ custom_md5.update(chunk)
+ fp.custom_md5 = custom_md5.hexdigest()
else:
# Ideally we could just ensure that we don't get strings in here and only byte streams
# but being confident of that wolud be a lot more work than we have time for so we just
# handle both cases here.
if isinstance(content.data, str):
- fp.write(content.data.encode('utf-8'))
+ encoded_data = content.data.encode('utf-8')
+ fp.write(encoded_data)
+ fp.custom_md5 = hashlib.md5(encoded_data).hexdigest()
else:
fp.write(content.data)
+ fp.custom_md5 = hashlib.md5(content.data).hexdigest()
return content
@@ -142,12 +181,13 @@ def find(self, location, throw_on_not_found=True, as_stream=False): # lint-amne
'thumbnail',
thumbnail_location[4]
)
+
return StaticContentStream(
location, fp.displayname, fp.content_type, fp, last_modified_at=fp.uploadDate,
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
length=fp.length, locked=getattr(fp, 'locked', False),
- content_digest=getattr(fp, 'md5', None),
+ content_digest=getattr(fp, 'custom_md5', None),
)
else:
with self.fs.get(content_id) as fp:
@@ -161,12 +201,13 @@ def find(self, location, throw_on_not_found=True, as_stream=False): # lint-amne
'thumbnail',
thumbnail_location[4]
)
+
return StaticContent(
location, fp.displayname, fp.content_type, fp.read(), last_modified_at=fp.uploadDate,
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
length=fp.length, locked=getattr(fp, 'locked', False),
- content_digest=getattr(fp, 'md5', None),
+ content_digest=getattr(fp, 'custom_md5', None),
)
except NoFile:
if throw_on_not_found: # lint-amnesty, pylint: disable=no-else-raise
diff --git a/xmodule/modulestore/mongo/base.py b/xmodule/modulestore/mongo/base.py
index c5cc935861d2..16a8c134c1d6 100644
--- a/xmodule/modulestore/mongo/base.py
+++ b/xmodule/modulestore/mongo/base.py
@@ -473,30 +473,9 @@ def __init__(self, contentstore, doc_store_config, fs_root, render_template,
super().__init__(contentstore=contentstore, **kwargs)
- def do_connection(
- db, collection, host, port=27017, tz_aware=True, user=None, password=None, asset_collection=None, **kwargs
- ):
- """
- Create & open the connection, authenticate, and provide pointers to the collection
- """
- # Set a write concern of 1, which makes writes complete successfully to the primary
- # only before returning. Also makes pymongo report write errors.
- kwargs['w'] = 1
-
- self.database = connect_to_mongodb(
- db, host,
- port=port, tz_aware=tz_aware, user=user, password=password,
- retry_wait_time=retry_wait_time, **kwargs
- )
-
- self.collection = self.database[collection]
-
- # Collection which stores asset metadata.
- if asset_collection is None:
- asset_collection = self.DEFAULT_ASSET_COLLECTION_NAME
- self.asset_collection = self.database[asset_collection]
-
- do_connection(**doc_store_config)
+ self.doc_store_config = doc_store_config
+ self.retry_wait_time = retry_wait_time
+ self.do_connection(**self.doc_store_config)
if default_class is not None:
module_path, _, class_name = default_class.rpartition('.')
@@ -523,6 +502,48 @@ def do_connection(
self._course_run_cache = {}
self.signal_handler = signal_handler
+ def check_connection(self):
+ """
+ Check if mongodb connection is open or not.
+ """
+ try:
+ # The ismaster command is cheap and does not require auth.
+ self.database.client.admin.command('ismaster')
+ return True
+ except pymongo.errors.InvalidOperation:
+ return False
+
+ def ensure_connection(self):
+ """
+ Ensure that mongodb connection is open.
+ """
+ if self.check_connection():
+ return
+ self.do_connection(**self.doc_store_config)
+
+ def do_connection(
+ self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, asset_collection=None, **kwargs
+ ):
+ """
+ Create & open the connection, authenticate, and provide pointers to the collection
+ """
+ # Set a write concern of 1, which makes writes complete successfully to the primary
+ # only before returning. Also makes pymongo report write errors.
+ kwargs['w'] = 1
+
+ self.database = connect_to_mongodb(
+ db, host,
+ port=port, tz_aware=tz_aware, user=user, password=password,
+ retry_wait_time=self.retry_wait_time, **kwargs
+ )
+
+ self.collection = self.database[collection]
+
+ # Collection which stores asset metadata.
+ if asset_collection is None:
+ asset_collection = self.DEFAULT_ASSET_COLLECTION_NAME
+ self.asset_collection = self.database[asset_collection]
+
def close_connections(self):
"""
Closes any open connections to the underlying database
@@ -541,6 +562,7 @@ def _drop_database(self, database=True, collections=True, connections=True):
If connections is True, then close the connection to the database as well.
"""
+ self.ensure_connection()
# drop the assets
super()._drop_database(database, collections, connections)
@@ -872,6 +894,8 @@ def has_course(self, course_key, ignore_case=False, **kwargs): # lint-amnesty,
course_query[key] = re.compile(r"(?i)^{}$".format(course_query[key]))
else:
course_query = {'_id': location.to_deprecated_son()}
+
+ self.ensure_connection()
course = self.collection.find_one(course_query, projection={'_id': True})
if course:
return CourseKey.from_string('/'.join([
diff --git a/xmodule/modulestore/split_mongo/mongo_connection.py b/xmodule/modulestore/split_mongo/mongo_connection.py
index bfb20fe0f5d5..9c00b0c22fc8 100644
--- a/xmodule/modulestore/split_mongo/mongo_connection.py
+++ b/xmodule/modulestore/split_mongo/mongo_connection.py
@@ -279,20 +279,30 @@ def __init__(
#make sure the course index cache is fresh.
RequestCache(namespace="course_index_cache").clear()
- self.database = connect_to_mongodb(
- db, host,
- port=port, tz_aware=tz_aware, user=user, password=password,
- retry_wait_time=retry_wait_time, **kwargs
- )
-
- self.course_index = self.database[collection + '.active_versions']
- self.structures = self.database[collection + '.structures']
- self.definitions = self.database[collection + '.definitions']
+ self.collection = collection
+ self.connection_params = {
+ 'db': db,
+ 'host': host,
+ 'port': port,
+ 'tz_aware': tz_aware,
+ 'user': user,
+ 'password': password,
+ 'retry_wait_time': retry_wait_time,
+ **kwargs
+ }
+
+ self.do_connection()
# Is the MySQL subclass in use, passing through some reads/writes to us? If so this will be True.
# If this MongoPersistenceBackend is being used directly (only MongoDB is involved), this is False.
self.with_mysql_subclass = with_mysql_subclass
+ def do_connection(self):
+ self.database = connect_to_mongodb(**self.connection_params)
+ self.course_index = self.database[self.collection + '.active_versions']
+ self.structures = self.database[self.collection + '.structures']
+ self.definitions = self.database[self.collection + '.definitions']
+
def heartbeat(self):
"""
Check that the db is reachable.
@@ -304,6 +314,24 @@ def heartbeat(self):
except pymongo.errors.ConnectionFailure:
raise HeartbeatFailure(f"Can't connect to {self.database.name}", 'mongo') # lint-amnesty, pylint: disable=raise-missing-from
+ def check_connection(self):
+ """
+ Check if mongodb connection is open or not.
+ """
+ try:
+ self.database.client.admin.command("ping")
+ return True
+ except pymongo.errors.InvalidOperation:
+ return False
+
+ def ensure_connection(self):
+ """
+ Ensure that mongodb connection is open.
+ """
+ if self.check_connection():
+ return
+ self.do_connection()
+
def get_structure(self, key, course_context=None):
"""
Get the structure from the persistence mechanism whose id is the given key.
@@ -502,6 +530,7 @@ def delete_course_index(self, course_key):
"""
Delete the course_index from the persistence mechanism whose id is the given course_index
"""
+ self.ensure_connection()
with TIMER.timer("delete_course_index", course_key):
query = {
key_attr: getattr(course_key, key_attr)
@@ -561,7 +590,8 @@ def close_connections(self):
Closes any open connections to the underlying databases
"""
RequestCache(namespace="course_index_cache").clear()
- self.database.client.close()
+ if self.check_connection():
+ self.database.client.close()
def _drop_database(self, database=True, collections=True, connections=True):
"""
@@ -576,6 +606,8 @@ def _drop_database(self, database=True, collections=True, connections=True):
If connections is True, then close the connection to the database as well.
"""
RequestCache(namespace="course_index_cache").clear()
+
+ self.ensure_connection()
connection = self.database.client
if database:
diff --git a/xmodule/modulestore/tests/test_mixed_modulestore.py b/xmodule/modulestore/tests/test_mixed_modulestore.py
index 91292cd88d71..8adbfcb911a4 100644
--- a/xmodule/modulestore/tests/test_mixed_modulestore.py
+++ b/xmodule/modulestore/tests/test_mixed_modulestore.py
@@ -156,8 +156,8 @@ def setUp(self):
tz_aware=True,
)
self.connection.drop_database(self.DB)
- self.addCleanup(self.connection.drop_database, self.DB)
- self.addCleanup(self.connection.close)
+ self.addCleanup(self._drop_database)
+ self.addCleanup(self._close_connection)
# define attrs which get set in initdb to quell pylint
self.writable_chapter_location = self.store = self.fake_location = None
@@ -165,6 +165,43 @@ def setUp(self):
self.user_id = ModuleStoreEnum.UserID.test
+ def _check_connection(self):
+ """
+ Check mongodb connection is open or not.
+ """
+ try:
+ self.connection.admin.command('ping')
+ return True
+ except pymongo.errors.InvalidOperation:
+ return False
+
+ def _ensure_connection(self):
+ """
+ Make sure that mongodb connection is open.
+ """
+ if not self._check_connection():
+ self.connection = pymongo.MongoClient(
+ host=self.HOST,
+ port=self.PORT,
+ tz_aware=True,
+ )
+
+ def _drop_database(self):
+ """
+ Drop mongodb database.
+ """
+ self._ensure_connection()
+ self.connection.drop_database(self.DB)
+
+ def _close_connection(self):
+ """
+ Close mongodb connection.
+ """
+ try:
+ self.connection.close()
+ except pymongo.errors.InvalidOperation:
+ pass
+
def _create_course(self, course_key, asides=None):
"""
Create a course w/ one item in the persistence store using the given course & item location.
diff --git a/xmodule/mongo_utils.py b/xmodule/mongo_utils.py
index 5daeff034e99..b86abd28b466 100644
--- a/xmodule/mongo_utils.py
+++ b/xmodule/mongo_utils.py
@@ -51,27 +51,30 @@ def connect_to_mongodb(
if read_preference is not None:
kwargs['read_preference'] = read_preference
- mongo_conn = pymongo.database.Database(
- pymongo.MongoClient(
- host=host,
- port=port,
- tz_aware=tz_aware,
- document_class=dict,
- **kwargs
- ),
- db
- )
+ if 'replicaSet' in kwargs and kwargs['replicaSet'] == '':
+ kwargs['replicaSet'] = None
+
+ connection_params = {
+ 'host': host,
+ 'port': port,
+ 'tz_aware': tz_aware,
+ 'document_class': dict,
+ **kwargs,
+ }
+
+ if user is not None and password is not None and not db.startswith('test_'):
+ connection_params.update({'username': user, 'password': password, 'authSource': db})
+
+ mongo_conn = pymongo.MongoClient(**connection_params)
if proxy:
mongo_conn = MongoProxy(
- mongo_conn,
+ mongo_conn[db],
wait_time=retry_wait_time
)
- # If credentials were provided, authenticate the user.
- if user is not None and password is not None:
- mongo_conn.authenticate(user, password, source=auth_source)
+ return mongo_conn
- return mongo_conn
+ return mongo_conn[db]
def create_collection_index(
diff --git a/xmodule/studio_editable.py b/xmodule/studio_editable.py
index 29312014f96a..d190c966cab8 100644
--- a/xmodule/studio_editable.py
+++ b/xmodule/studio_editable.py
@@ -2,6 +2,7 @@
Mixin to support editing in Studio.
"""
from xblock.core import XBlock, XBlockMixin
+
from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
@@ -12,6 +13,7 @@ class StudioEditableBlock(XBlockMixin):
This class is only intended to be used with an XBlock!
"""
+
has_author_view = True
def render_children(self, context, fragment, can_reorder=False, can_add=False):
@@ -49,47 +51,6 @@ def get_preview_view_name(block):
"""
return AUTHOR_VIEW if has_author_view(block) else STUDENT_VIEW
- # Some parts of the code use getattr to dynamically check for the following methods on subclasses.
- # We'd like to refactor so that we can actually declare them here as overridable methods.
- # For now, we leave them here as documentation.
- # See https://github.com/openedx/edx-platform/issues/33715.
- #
- # def editor_saved(self, old_metadata, old_content) -> None: # pylint: disable=unused-argument
- # """
- # Called right *before* the block is written to the DB. Can be used, e.g., to modify fields before saving.
- #
- # By default, is a no-op. Can be overriden in subclasses.
- # """
- #
- # def post_editor_saved(self, old_metadata, old_content) -> None: # pylint: disable=unused-argument
- # """
- # Called right *after* the block is written to the DB. Can be used, e.g., to spin up followup tasks.
- #
- # By default, is a no-op. Can be overriden in subclasses.
- # """
- #
- # def studio_post_duplicate(self, store, source_block) -> bool: # pylint: disable=unused-argument
- # """
- # Called when a the block is duplicated. Can be used, e.g., for special handling of child duplication.
- #
- # Returns 'True' if children have been handled and thus shouldn't be handled by the standard
- # duplication logic.
- #
- # By default, is a no-op. Can be overriden in subclasses.
- # """
- # return False
- #
- # def studio_post_paste(self, store, source_node) -> bool: # pylint: disable=unused-argument
- # """
- # Called after a block is copy-pasted. Can be used, e.g., for special handling of child duplication.
- #
- # Returns 'True' if children have been handled and thus shouldn't be handled by the standard
- # duplication logic.
- #
- # By default, is a no-op. Can be overriden in subclasses.
- # """
- # return False
-
def has_author_view(block):
"""
diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py
index 96ec64de61e5..d1c01e109718 100644
--- a/xmodule/tests/test_capa_block.py
+++ b/xmodule/tests/test_capa_block.py
@@ -655,6 +655,37 @@ def test_closed(self):
due=self.yesterday_str)
assert block.closed()
+ @patch.object(ProblemBlock, 'course_end_date', new_callable=PropertyMock)
+ def test_closed_for_archive(self, mock_course_end_date):
+
+ # Utility to create a datetime object in the past
+ def past_datetime(days):
+ return (datetime.datetime.now(UTC) - datetime.timedelta(days=days))
+
+ # Utility to create a datetime object in the future
+ def future_datetime(days):
+ return (datetime.datetime.now(UTC) + datetime.timedelta(days=days))
+
+ block = CapaFactory.create(max_attempts="1", attempts="0")
+
+ # For active courses without graceperiod
+ mock_course_end_date.return_value = future_datetime(10)
+ assert not block.closed()
+
+ # For archive courses without graceperiod
+ mock_course_end_date.return_value = past_datetime(10)
+ assert block.closed()
+
+ # For active courses with graceperiod
+ mock_course_end_date.return_value = future_datetime(10)
+ block.graceperiod = datetime.timedelta(days=2)
+ assert not block.closed()
+
+ # For archive courses with graceperiod
+ mock_course_end_date.return_value = past_datetime(2)
+ block.graceperiod = datetime.timedelta(days=3)
+ assert not block.closed()
+
def test_parse_get_params(self):
# Valid GET param dict