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' - ) - ) { %> -
- - target="_blank" - rel="noopener noreferrer" - <% } %> - > - <% if (subscriptionState === 'active') { %> -
- <%- gettext('Manage my subscription') %> -
- <% // xss-lint: disable=underscore-not-escaped %> - <%= launchIcon %> -
-
- <% } else if (subscriptionState === 'inactive') { %> -
-
- <% // xss-lint: disable=underscore-not-escaped %> - <%= restartIcon %> -
- <%- gettext('Restart my subscription') %> -
- <% } else { %> - <%- StringUtils.interpolate( - gettext('Start {trialLength}-day free trial'), - { trialLength } - ) %> - <% } %> -
- - <%- StringUtils.interpolate( - ( - hasActiveTrial - ? gettext('Trial ends {trialEndDate} at {trialEndTime}') - : subscriptionState === 'active' - ? gettext('Your next billing date is {currentPeriodEnd}') - : subscriptionState === 'inactive' - ? gettext('{subscriptionPrice} subscription. Cancel anytime.') - : gettext('{subscriptionPrice} subscription after trial ends. Cancel anytime.') - ), - { - subscriptionPrice, - currentPeriodEnd, - trialEndDate, - trialEndTime, - } - ) %> - -
- <% } 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' - ) - ) { %> -
- - target="_blank" - rel="noopener noreferrer" - <% } %> - > - <% if (subscriptionState === 'active') { %> -
- <%- gettext('Manage my subscription') %> -
- <% // xss-lint: disable=underscore-not-escaped %> - <%= launchIcon %> -
-
- <% } else if (subscriptionState === 'inactive') { %> -
-
- <% // xss-lint: disable=underscore-not-escaped %> - <%= restartIcon %> -
- <%- gettext('Restart my subscription') %> -
- <% } else { %> - <%- StringUtils.interpolate( - gettext('Start {trialLength}-day free trial'), - { trialLength } - ) %> - <% } %> -
- - <%- StringUtils.interpolate( - ( - hasActiveTrial - ? gettext('Trial ends {trialEndDate} at {trialEndTime}') - : subscriptionState === 'active' - ? gettext('Your next billing date is {currentPeriodEnd}') - : subscriptionState === 'inactive' - ? gettext('{subscriptionPrice} subscription. Cancel anytime.') - : gettext('{subscriptionPrice} subscription after trial ends. Cancel anytime.') - ), - { - subscriptionPrice, - currentPeriodEnd, - trialEndDate, - trialEndTime, - } - ) %> - -
- <% } 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} commented on {author_name}\'s response in ' - 'a post you’re following <{strong}>{post_title}'), + 'content_template': _('<{p}><{strong}>{replier_name} commented on <{strong}>{author_name}' + ' response in a post you’re following <{strong}>{post_title}' + ''), '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}'), '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