From ab4179ec60bbb949c8e8efd7a1a590045d3b58b9 Mon Sep 17 00:00:00 2001 From: connorhaugh Date: Wed, 4 Oct 2023 12:46:36 -0400 Subject: [PATCH 01/63] fix: cohorts data can be private --- common/djangoapps/util/file.py | 11 ++++++++++- lms/djangoapps/instructor/views/api.py | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/util/file.py b/common/djangoapps/util/file.py index 66fc2a6f5f35..b884ca46a703 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/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 778cbe5c8b3e..d75cf08b21a2 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1600,7 +1600,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: From 3f15d1f2ad24982bbd1e3b01282202540e2f6d70 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 17 Jul 2024 13:41:43 -0400 Subject: [PATCH 02/63] build: Updating workflow `add-remove-label-on-comment.yml`. The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in sync with the current standard for this workflow as defined in the `.github` repo of the `openedx` GitHub org. --- .github/workflows/add-remove-label-on-comment.yml | 1 + 1 file changed, 1 insertion(+) 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 + From 8f7496d00e33f86d1abcb3eaf901689782378107 Mon Sep 17 00:00:00 2001 From: Muhammad Anas Date: Mon, 8 Jul 2024 14:23:19 +0000 Subject: [PATCH 03/63] fix: DiscussionsConfigurations admin error --- openedx/core/djangoapps/discussions/admin.py | 4 +++ .../discussions/tests/test_admin.py | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 openedx/core/djangoapps/discussions/tests/test_admin.py 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') From 004cd29cf39ed41d1eee462904d8aa927f6ae4ca Mon Sep 17 00:00:00 2001 From: Muhammad Soban Javed Date: Mon, 22 Jul 2024 18:27:57 +0500 Subject: [PATCH 04/63] chore!: uprgade social-auth-app-django to version 5.4.1 (#35045) * chore!: uprgade social-auth-app-django to version 5.4.1 * chore: add migration from social_django --- ...ricalusersocialauth_extra_data_and_more.py | 23 +++++++++++++++++++ requirements/constraints.txt | 8 ------- requirements/edx/base.txt | 7 +++--- requirements/edx/development.txt | 7 +++--- requirements/edx/doc.txt | 7 +++--- requirements/edx/testing.txt | 7 +++--- 6 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py 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/requirements/constraints.txt b/requirements/constraints.txt index b404440e04dd..cdb306e6a030 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -59,14 +59,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 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 1259d5bda7fb..5dd6732caeb0 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -227,6 +227,7 @@ django==4.2.14 # openedx-filters # openedx-learning # ora2 + # social-auth-app-django # super-csv # xblock-google-drive # xss-utils @@ -1056,14 +1057,12 @@ slumber==0.7.1 # edx-rest-api-client snowflake-connector-python==3.11.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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index f6fa68363577..aeda833921bf 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -395,6 +395,7 @@ django==4.2.14 # openedx-filters # openedx-learning # ora2 + # social-auth-app-django # super-csv # xblock-google-drive # xss-utils @@ -1877,15 +1878,13 @@ snowflake-connector-python==3.11.0 # -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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 3ab0f8ce8181..05542b5d586e 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -275,6 +275,7 @@ django==4.2.14 # openedx-filters # openedx-learning # ora2 + # social-auth-app-django # super-csv # xblock-google-drive # xss-utils @@ -1246,14 +1247,12 @@ snowflake-connector-python==3.11.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 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 9d8c429800cc..8f08a3308a5d 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -310,6 +310,7 @@ django==4.2.14 # openedx-filters # openedx-learning # ora2 + # social-auth-app-django # super-csv # xblock-google-drive # xss-utils @@ -1408,14 +1409,12 @@ snowflake-connector-python==3.11.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 From a19697786f843850a51271810feccb38263687b0 Mon Sep 17 00:00:00 2001 From: Juliana Kang Date: Mon, 22 Jul 2024 14:01:07 -0400 Subject: [PATCH 05/63] feat: Add First Purchase Discount override (#35143) REV-4098 --- .../outline/tests/test_view.py | 27 ++++++++++++++----- lms/envs/devstack.py | 3 +++ lms/envs/test.py | 3 +++ .../features/discounts/tests/test_utils.py | 14 ++++++++-- openedx/features/discounts/utils.py | 13 ++++++++- 5 files changed, 51 insertions(+), 9 deletions(-) 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..c092e9e2a506 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,28 @@ 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'), + (True, 'NOTEDXWELCOME'), + ) + @ddt.unpack + def test_offer(self, is_fpd_override_waffle_flag_on, fpd_code): + """ + 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) + with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE='NOTEDXWELCOME'): + 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'] == 'EDXWELCOME' + # Just a quick spot check that the dictionary looks like what we expect + assert response.data['offer']['code'] == fpd_code def test_access_expiration(self): enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 611017962852..7de34f9146e0 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -239,6 +239,9 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ########################## Authn MFE Context API ####################### ENABLE_DYNAMIC_REGISTRATION_FIELDS = True +########################## Discount/Coupons ####################### +FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE = '' + ############## ECOMMERCE API CONFIGURATION SETTINGS ############### ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:18130' ECOMMERCE_API_URL = 'http://edx.devstack.ecommerce:18130/api/v2' diff --git a/lms/envs/test.py b/lms/envs/test.py index 3c4bb9564927..2a41e5499060 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -92,6 +92,9 @@ # Enable a parental consent age limit for testing PARENTAL_CONSENT_AGE_LIMIT = 13 +# Enable First Purchase Discount offer override +FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE = '' + # Local Directories TEST_ROOT = path("test_root") # Want static files in the same dir for running on jenkins. diff --git a/openedx/features/discounts/tests/test_utils.py b/openedx/features/discounts/tests/test_utils.py index 1ff305831d74..d91d61839c8b 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,12 @@ 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_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' + 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, From d35d13b7335201a34ace74e4042cb3c36ddb5e47 Mon Sep 17 00:00:00 2001 From: katrinan029 <71999631+katrinan029@users.noreply.github.com> Date: Mon, 22 Jul 2024 20:35:16 +0000 Subject: [PATCH 06/63] feat: Upgrade Python dependency edx-enterprise version bump Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/common_constraints.txt | 8 ++++++++ requirements/constraints.txt | 2 +- requirements/edx/base.txt | 3 ++- requirements/edx/development.txt | 3 ++- requirements/edx/doc.txt | 3 ++- requirements/edx/testing.txt | 3 ++- 6 files changed, 17 insertions(+), 5 deletions(-) diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 4abc9ae22cb3..ef8bc86061b7 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 +event-tracking<2.4.1 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index cdb306e6a030..701841785ae7 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.21.7 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 5dd6732caeb0..db687ef9e7ce 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -451,7 +451,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.5 +edx-enterprise==4.21.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -537,6 +537,7 @@ enmerkar-underscore==2.3.0 # via -r requirements/edx/kernel.in event-tracking==2.4.0 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in # edx-completion # edx-proctoring diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index aeda833921bf..10415067b029 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -724,7 +724,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.5 +edx-enterprise==4.21.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -854,6 +854,7 @@ enmerkar-underscore==2.3.0 # -r requirements/edx/testing.txt event-tracking==2.4.0 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-completion diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 05542b5d586e..6c7d3ab8e543 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -523,7 +523,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.5 +edx-enterprise==4.21.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -615,6 +615,7 @@ enmerkar-underscore==2.3.0 # via -r requirements/edx/base.txt event-tracking==2.4.0 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 8f08a3308a5d..9029ceebebcc 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -555,7 +555,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.5 +edx-enterprise==4.21.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -651,6 +651,7 @@ enmerkar-underscore==2.3.0 # via -r requirements/edx/base.txt event-tracking==2.4.0 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring From e12ec1b1536ebed4d967d2f354523eb02c05e24d Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Tue, 23 Jul 2024 14:28:56 +0500 Subject: [PATCH 07/63] feat: added group_by_id field in notifications model (#35137) --- cms/envs/common.py | 2 +- lms/envs/common.py | 2 +- .../0006_notification_group_by_id.py | 18 ++++++++++++++++++ .../core/djangoapps/notifications/models.py | 1 + 4 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 openedx/core/djangoapps/notifications/migrations/0006_notification_group_by_id.py 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/lms/envs/common.py b/lms/envs/common.py index 065d059eeda1..9a56680d4da2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5382,7 +5382,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/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..8bf19edce56e 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -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}' From 896b011e88eb81fc969856adb39b754a3ef427d4 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 23 Jul 2024 13:15:01 -0400 Subject: [PATCH 08/63] fix: downgrade django-storages to 1.14.3 (#35156) * fix: downgrade django-storages to 1.14.3 * fix: change max version * feat: Recompile Python dependencies (#35164) Commit generated by workflow `openedx/edx-platform/.github/workflows/compile-python-requirements.yml@refs/heads/master` Co-authored-by: KristinAoki <42981026+KristinAoki@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- requirements/constraints.txt | 5 +++++ requirements/edx/base.txt | 3 ++- requirements/edx/development.txt | 3 ++- requirements/edx/doc.txt | 3 ++- requirements/edx/testing.txt | 3 ++- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 701841785ae7..1478e646ca17 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -121,3 +121,8 @@ 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 \ No newline at end of file diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index db687ef9e7ce..408534bdbb3e 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -334,8 +334,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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 10415067b029..4dd01d4989cc 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -550,8 +550,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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 6c7d3ab8e543..cc46728bc6e4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -396,8 +396,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 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 9029ceebebcc..b7bb5f81d546 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -431,8 +431,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 From 726835e2668b8089c8a97fdd1fa4f0e85deb9463 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:14:43 +0000 Subject: [PATCH 09/63] feat: Upgrade Python dependency edx-enterprise fixed 500 error for search filter for api request logs in admin view Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 1478e646ca17..df36493234c5 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.7 +edx-enterprise==4.21.8 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 408534bdbb3e..b56b280405c5 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -452,7 +452,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.7 +edx-enterprise==4.21.8 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4dd01d4989cc..360862385c34 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -725,7 +725,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.7 +edx-enterprise==4.21.8 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index cc46728bc6e4..fcc073f09520 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -524,7 +524,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.7 +edx-enterprise==4.21.8 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index b7bb5f81d546..caa5d3c6f9db 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -556,7 +556,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.7 +edx-enterprise==4.21.8 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 40ddfeb3b809b6ecf6762d7c4c70f29d86fafcb9 Mon Sep 17 00:00:00 2001 From: Juliana Kang Date: Tue, 23 Jul 2024 15:43:34 -0400 Subject: [PATCH 10/63] feat: Add override on percentage config to the First Purchase Discount (#35167) REV-4098 --- .../outline/tests/test_view.py | 22 +++++++++++-------- lms/envs/common.py | 4 ++++ lms/envs/devstack.py | 3 --- lms/envs/test.py | 3 --- openedx/features/discounts/applicability.py | 8 +++++++ .../features/discounts/tests/test_utils.py | 8 ++++--- 6 files changed, 30 insertions(+), 18 deletions(-) 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 c092e9e2a506..76928846f080 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -184,11 +184,11 @@ def test_welcome_message(self, welcome_message_is_dismissed): assert welcome_message_html == (None if welcome_message_is_dismissed else '

Welcome

') @ddt.data( - (False, 'EDXWELCOME'), - (True, 'NOTEDXWELCOME'), + (False, 'EDXWELCOME', 15), + (True, 'NOTEDXWELCOME', 30), ) @ddt.unpack - def test_offer(self, is_fpd_override_waffle_flag_on, fpd_code): + 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. @@ -199,12 +199,16 @@ def test_offer(self, is_fpd_override_waffle_flag_on, fpd_code): assert response.data['offer'] is None with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE='NOTEDXWELCOME'): - 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 + 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/envs/common.py b/lms/envs/common.py index 9a56680d4da2..26073335a267 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4295,6 +4295,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' diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 7de34f9146e0..611017962852 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -239,9 +239,6 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ########################## Authn MFE Context API ####################### ENABLE_DYNAMIC_REGISTRATION_FIELDS = True -########################## Discount/Coupons ####################### -FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE = '' - ############## ECOMMERCE API CONFIGURATION SETTINGS ############### ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:18130' ECOMMERCE_API_URL = 'http://edx.devstack.ecommerce:18130/api/v2' diff --git a/lms/envs/test.py b/lms/envs/test.py index 2a41e5499060..3c4bb9564927 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -92,9 +92,6 @@ # Enable a parental consent age limit for testing PARENTAL_CONSENT_AGE_LIMIT = 13 -# Enable First Purchase Discount offer override -FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE = '' - # Local Directories TEST_ROOT = path("test_root") # Want static files in the same dir for running on jenkins. diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index b158a5f45a42..2a9bc34c2dc0 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 @@ -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 d91d61839c8b..6bd9d1cd1593 100644 --- a/openedx/features/discounts/tests/test_utils.py +++ b/openedx/features/discounts/tests/test_utils.py @@ -90,9 +90,11 @@ def test_spanish_code(self): def test_override(self): with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE='NOTEDXWELCOME'): - 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' + 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 From b77b90a344c5aebb833b72cd815483d505f69d6b Mon Sep 17 00:00:00 2001 From: jawad khan Date: Wed, 24 Jul 2024 01:40:41 +0500 Subject: [PATCH 11/63] =?UTF-8?q?fix:=20Enable=20courseware=20access=20api?= =?UTF-8?q?=20for=20all=20types=20of=20course(expired,=20cl=E2=80=A6=20(#3?= =?UTF-8?q?5155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Enable courseware access api for all types of course(expired, closed etc) --- .../mobile_api/course_info/views.py | 14 +++- .../tests/test_course_info_views.py | 79 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) 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 From ce290db4c129263897d8f98286adda9c6c833f30 Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:39:02 +0500 Subject: [PATCH 12/63] feat: added snowflake events for email notifications (#35158) --- .../djangoapps/notifications/email/events.py | 40 +++++++++++++++++++ .../djangoapps/notifications/email/tasks.py | 2 + 2 files changed, 42 insertions(+) create mode 100644 openedx/core/djangoapps/notifications/email/events.py 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..75af99aa86c3 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, @@ -101,6 +102,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==') From 1fb20b359806155803b4c68719658cf1814134a4 Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:40:58 +0500 Subject: [PATCH 13/63] feat: update account verification email context (#35165) --- common/djangoapps/student/views/management.py | 4 +++- openedx/core/djangoapps/user_authn/tests/test_tasks.py | 2 ++ openedx/features/discounts/applicability.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) 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/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/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 2a9bc34c2dc0..97d6f74403bd 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -32,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 From f0507b7fc0b0d3b09dc181770c22bac18628f5ae Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Wed, 24 Jul 2024 20:50:07 +0000 Subject: [PATCH 14/63] fix: Delete translations more conservatively (#35169) This was deleting `conf/locale/en/LC_MESSAGES/.gitignore` which was introduced in https://github.com/openedx/edx-platform/pull/34628 such that `make pull_translations` (which depends on `clean_translations`) was leaving the git working directory dirty. Deleting just the mo/po files will avoid this. Relates to https://github.com/edx/edx-arch-experiments/issues/732 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 098236fed8cb..c70de65fb454 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/ From 03a8f5daf78db505d40c46e3c592680fd5475526 Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:09:00 +0500 Subject: [PATCH 15/63] refactor: refactored notifications app and removed unused code (#35160) --- .../notifications/audience_filters.py | 113 +++++++++- .../core/djangoapps/notifications/filters.py | 120 ----------- .../djangoapps/notifications/serializers.py | 54 ----- .../core/djangoapps/notifications/tasks.py | 2 +- .../notifications/tests/test_filters.py | 5 +- .../notifications/tests/test_views.py | 195 ------------------ openedx/core/djangoapps/notifications/urls.py | 6 - .../core/djangoapps/notifications/views.py | 51 +---- 8 files changed, 116 insertions(+), 430 deletions(-) delete mode 100644 openedx/core/djangoapps/notifications/filters.py 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/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/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/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_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 413c1ce1521b..389c9e7e06c9 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -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..858f5da9f5fc 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -38,7 +38,7 @@ NotificationCourseEnrollmentSerializer, NotificationSerializer, UserCourseNotificationPreferenceSerializer, - UserNotificationPreferenceUpdateSerializer, UserNotificationChannelPreferenceUpdateSerializer, + UserNotificationPreferenceUpdateSerializer, ) from .utils import get_show_notifications_tray @@ -239,55 +239,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): """ From cbd4904e1bac20e7c217e490c72d4be8b4b43b8a Mon Sep 17 00:00:00 2001 From: Muhammad Umar Khan <42294172+mumarkhan999@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:03:06 +0500 Subject: [PATCH 16/63] feat!: upgrade pymongo (#34675) --- Makefile | 2 + requirements/common_constraints.txt | 2 +- requirements/constraints.txt | 12 ++-- requirements/edx/base.txt | 10 ++- requirements/edx/development.txt | 8 ++- requirements/edx/doc.txt | 10 ++- requirements/edx/paver.txt | 4 +- requirements/edx/testing.txt | 11 +-- .../structures_pruning/requirements/base.txt | 4 +- .../requirements/testing.txt | 6 +- xmodule/contentstore/mongo.py | 65 +++++++++++++---- xmodule/modulestore/mongo/base.py | 72 ++++++++++++------- .../split_mongo/mongo_connection.py | 52 +++++++++++--- .../tests/test_mixed_modulestore.py | 41 ++++++++++- xmodule/mongo_utils.py | 34 +++++---- 15 files changed, 249 insertions(+), 84 deletions(-) diff --git a/Makefile b/Makefile index c70de65fb454..15bab5df67a9 100644 --- a/Makefile +++ b/Makefile @@ -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/requirements/common_constraints.txt b/requirements/common_constraints.txt index ef8bc86061b7..9405a605c520 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -40,4 +40,4 @@ importlib-metadata<7 # 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 -event-tracking<2.4.1 + diff --git a/requirements/constraints.txt b/requirements/constraints.txt index df36493234c5..ceb019231451 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -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 @@ -125,4 +129,4 @@ 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 \ No newline at end of file +django-storages<1.14.4 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b56b280405c5..9fa27e14c5b1 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -376,6 +376,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 @@ -536,9 +540,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-completion # edx-proctoring @@ -865,7 +869,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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 360862385c34..942c780a0f48 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -614,8 +614,10 @@ 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 @@ -853,9 +855,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-completion @@ -1535,7 +1537,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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index fcc073f09520..0791a21fefe4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -440,6 +440,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 @@ -614,9 +618,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring @@ -1026,7 +1030,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 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/testing.txt b/requirements/edx/testing.txt index caa5d3c6f9db..6800f096e9ab 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -476,7 +476,10 @@ 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 + # email-validator + # pymongo done-xblock==2.3.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 @@ -650,9 +653,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring @@ -1141,7 +1144,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 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..d74b204fad5c 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,7 +28,7 @@ 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 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..ad9ddcdc248b 100644 --- a/xmodule/mongo_utils.py +++ b/xmodule/mongo_utils.py @@ -51,27 +51,31 @@ 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, + 'directConnection': True, + **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( From 5198496703c181d2fea33030f1b82229d79acd4d Mon Sep 17 00:00:00 2001 From: Muhammad Umar Khan <42294172+mumarkhan999@users.noreply.github.com> Date: Thu, 25 Jul 2024 18:42:03 +0500 Subject: [PATCH 17/63] Revert "feat!: upgrade pymongo (#34675)" (#35178) This reverts commit cbd4904e1bac20e7c217e490c72d4be8b4b43b8a. --- Makefile | 2 - requirements/common_constraints.txt | 2 +- requirements/constraints.txt | 12 ++-- requirements/edx/base.txt | 10 +-- requirements/edx/development.txt | 8 +-- requirements/edx/doc.txt | 10 +-- requirements/edx/paver.txt | 4 +- requirements/edx/testing.txt | 11 ++- .../structures_pruning/requirements/base.txt | 4 +- .../requirements/testing.txt | 6 +- xmodule/contentstore/mongo.py | 65 ++++------------- xmodule/modulestore/mongo/base.py | 72 +++++++------------ .../split_mongo/mongo_connection.py | 52 +++----------- .../tests/test_mixed_modulestore.py | 41 +---------- xmodule/mongo_utils.py | 34 ++++----- 15 files changed, 84 insertions(+), 249 deletions(-) diff --git a/Makefile b/Makefile index 15bab5df67a9..c70de65fb454 100644 --- a/Makefile +++ b/Makefile @@ -137,8 +137,6 @@ 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/requirements/common_constraints.txt b/requirements/common_constraints.txt index 9405a605c520..ef8bc86061b7 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -40,4 +40,4 @@ importlib-metadata<7 # 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 - +event-tracking<2.4.1 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index ceb019231451..df36493234c5 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -32,13 +32,9 @@ django-oauth-toolkit==1.7.1 # incremental upgrade django-simple-history==3.4.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 +# 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 # greater version has breaking changes and requires some migration steps. django-webpack-loader==0.7.0 @@ -129,4 +125,4 @@ 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 +django-storages<1.14.4 \ No newline at end of file diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9fa27e14c5b1..b56b280405c5 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -376,10 +376,6 @@ 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 @@ -540,9 +536,9 @@ enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.3.0 # via -r requirements/edx/kernel.in -event-tracking==3.0.0 +event-tracking==2.4.0 # via - # -c requirements/edx/../constraints.txt + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in # edx-completion # edx-proctoring @@ -869,7 +865,7 @@ pylti1p3==2.0.0 # via -r requirements/edx/kernel.in pymemcache==4.0.0 # via -r requirements/edx/paver.txt -pymongo==4.4.0 +pymongo==3.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 942c780a0f48..360862385c34 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -614,10 +614,8 @@ 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 @@ -855,9 +853,9 @@ enmerkar-underscore==2.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -event-tracking==3.0.0 +event-tracking==2.4.0 # via - # -c requirements/edx/../constraints.txt + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-completion @@ -1537,7 +1535,7 @@ pymemcache==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pymongo==4.4.0 +pymongo==3.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 0791a21fefe4..fcc073f09520 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -440,10 +440,6 @@ 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 @@ -618,9 +614,9 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.3.0 # via -r requirements/edx/base.txt -event-tracking==3.0.0 +event-tracking==2.4.0 # via - # -c requirements/edx/../constraints.txt + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring @@ -1030,7 +1026,7 @@ pylti1p3==2.0.0 # via -r requirements/edx/base.txt pymemcache==4.0.0 # via -r requirements/edx/base.txt -pymongo==4.4.0 +pymongo==3.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt index 0b82d71c91e6..bf7337f4147a 100644 --- a/requirements/edx/paver.txt +++ b/requirements/edx/paver.txt @@ -10,8 +10,6 @@ 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 @@ -38,7 +36,7 @@ psutil==6.0.0 # via -r requirements/edx/paver.in pymemcache==4.0.0 # via -r requirements/edx/paver.in -pymongo==4.4.0 +pymongo==3.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.in diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 6800f096e9ab..caa5d3c6f9db 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -476,10 +476,7 @@ djangorestframework-xml==2.0.0 # -r requirements/edx/base.txt # edx-enterprise dnspython==2.6.1 - # via - # -r requirements/edx/base.txt - # email-validator - # pymongo + # via email-validator done-xblock==2.3.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 @@ -653,9 +650,9 @@ enmerkar==0.7.1 # enmerkar-underscore enmerkar-underscore==2.3.0 # via -r requirements/edx/base.txt -event-tracking==3.0.0 +event-tracking==2.4.0 # via - # -c requirements/edx/../constraints.txt + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring @@ -1144,7 +1141,7 @@ pylti1p3==2.0.0 # via -r requirements/edx/base.txt pymemcache==4.0.0 # via -r requirements/edx/base.txt -pymongo==4.4.0 +pymongo==3.13.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt index 828a81a8d4ed..87aa858e9f8e 100644 --- a/scripts/structures_pruning/requirements/base.txt +++ b/scripts/structures_pruning/requirements/base.txt @@ -11,13 +11,11 @@ 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==4.4.0 +pymongo==3.13.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 d74b204fad5c..2590ca8ca52b 100644 --- a/scripts/structures_pruning/requirements/testing.txt +++ b/scripts/structures_pruning/requirements/testing.txt @@ -12,10 +12,6 @@ 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 @@ -28,7 +24,7 @@ pbr==6.0.0 # stevedore pluggy==1.5.0 # via pytest -pymongo==4.4.0 +pymongo==3.13.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys diff --git a/xmodule/contentstore/mongo.py b/xmodule/contentstore/mongo.py index e44f03cede05..66d9474cde7c 100644 --- a/xmodule/contentstore/mongo.py +++ b/xmodule/contentstore/mongo.py @@ -3,7 +3,6 @@ """ -import hashlib import json import os @@ -41,29 +40,16 @@ 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. - 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) + proxy = False + mongo_db = connect_to_mongodb( + db, host, + port=port, tz_aware=tz_aware, user=user, password=password, proxy=proxy, **kwargs + ) - self.fs = gridfs.GridFS(mongo_db, self.bucket) # pylint: disable=invalid-name + self.fs = gridfs.GridFS(mongo_db, bucket) # pylint: disable=invalid-name - self.fs_files = mongo_db[self.bucket + ".files"] # the underlying collection GridFS uses - self.chunks = mongo_db[self.bucket + ".chunks"] + self.fs_files = mongo_db[bucket + ".files"] # the underlying collection GridFS uses + self.chunks = mongo_db[bucket + ".chunks"] def close_connections(self): """ @@ -71,25 +57,6 @@ 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. @@ -102,8 +69,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: @@ -136,22 +103,16 @@ 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): - encoded_data = content.data.encode('utf-8') - fp.write(encoded_data) - fp.custom_md5 = hashlib.md5(encoded_data).hexdigest() + fp.write(content.data.encode('utf-8')) else: fp.write(content.data) - fp.custom_md5 = hashlib.md5(content.data).hexdigest() return content @@ -181,13 +142,12 @@ 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, 'custom_md5', None), + content_digest=getattr(fp, 'md5', None), ) else: with self.fs.get(content_id) as fp: @@ -201,13 +161,12 @@ 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, 'custom_md5', None), + content_digest=getattr(fp, '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 16a8c134c1d6..c5cc935861d2 100644 --- a/xmodule/modulestore/mongo/base.py +++ b/xmodule/modulestore/mongo/base.py @@ -473,9 +473,30 @@ def __init__(self, contentstore, doc_store_config, fs_root, render_template, super().__init__(contentstore=contentstore, **kwargs) - self.doc_store_config = doc_store_config - self.retry_wait_time = retry_wait_time - self.do_connection(**self.doc_store_config) + 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) if default_class is not None: module_path, _, class_name = default_class.rpartition('.') @@ -502,48 +523,6 @@ def __init__(self, contentstore, doc_store_config, fs_root, render_template, 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 @@ -562,7 +541,6 @@ 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) @@ -894,8 +872,6 @@ 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 9c00b0c22fc8..bfb20fe0f5d5 100644 --- a/xmodule/modulestore/split_mongo/mongo_connection.py +++ b/xmodule/modulestore/split_mongo/mongo_connection.py @@ -279,30 +279,20 @@ def __init__( #make sure the course index cache is fresh. RequestCache(namespace="course_index_cache").clear() - 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() + 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'] # 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. @@ -314,24 +304,6 @@ 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. @@ -530,7 +502,6 @@ 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) @@ -590,8 +561,7 @@ def close_connections(self): Closes any open connections to the underlying databases """ RequestCache(namespace="course_index_cache").clear() - if self.check_connection(): - self.database.client.close() + self.database.client.close() def _drop_database(self, database=True, collections=True, connections=True): """ @@ -606,8 +576,6 @@ 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 8adbfcb911a4..91292cd88d71 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._drop_database) - self.addCleanup(self._close_connection) + self.addCleanup(self.connection.drop_database, self.DB) + self.addCleanup(self.connection.close) # define attrs which get set in initdb to quell pylint self.writable_chapter_location = self.store = self.fake_location = None @@ -165,43 +165,6 @@ 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 ad9ddcdc248b..5daeff034e99 100644 --- a/xmodule/mongo_utils.py +++ b/xmodule/mongo_utils.py @@ -51,31 +51,27 @@ def connect_to_mongodb( if read_preference is not None: kwargs['read_preference'] = read_preference - if 'replicaSet' in kwargs and kwargs['replicaSet'] == '': - kwargs['replicaSet'] = None - - connection_params = { - 'host': host, - 'port': port, - 'tz_aware': tz_aware, - 'document_class': dict, - 'directConnection': True, - **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) + mongo_conn = pymongo.database.Database( + pymongo.MongoClient( + host=host, + port=port, + tz_aware=tz_aware, + document_class=dict, + **kwargs + ), + db + ) if proxy: mongo_conn = MongoProxy( - mongo_conn[db], + mongo_conn, wait_time=retry_wait_time ) - return mongo_conn + # 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[db] + return mongo_conn def create_collection_index( From df635e0fae5dea48e5a0bf835393919f5aa60ad6 Mon Sep 17 00:00:00 2001 From: connorhaugh Date: Thu, 15 Feb 2024 18:04:49 +0000 Subject: [PATCH 18/63] fix: libraries across orgs --- cms/djangoapps/contentstore/utils.py | 4 +- cms/djangoapps/contentstore/views/library.py | 53 +++++++++++++++---- .../contentstore/views/tests/test_library.py | 39 +++++++++----- 3 files changed, 70 insertions(+), 26 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 9ed3f1ce4b36..d75ebdb3f23f 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,7 @@ 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), diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 17aa24c5712a..8ea6bf1e3657 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 @@ -68,13 +68,10 @@ def should_redirect_to_library_authoring_mfe(): REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND.is_enabled() ) - -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 +81,48 @@ 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. + + users can only create libraries in orgs they are a part of. + """ + 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 +145,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/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index f6b7a48a68e1..fdbb9d905c62 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) From 25437d2a728ea2fbb08c7023df2b5bed319a01a4 Mon Sep 17 00:00:00 2001 From: connorhaugh Date: Thu, 15 Feb 2024 21:26:45 +0000 Subject: [PATCH 19/63] docs: imporved comment --- cms/djangoapps/contentstore/views/library.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 8ea6bf1e3657..7ba80bcab9f6 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -98,7 +98,16 @@ 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. - users can only create libraries in orgs they are a part of. + 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 From 68ef001c919c04702d2c42c3629e2a24a5a93d63 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 25 Jul 2024 10:24:36 -0400 Subject: [PATCH 20/63] fix: Remove errant pluses from a bad merge. --- common/djangoapps/util/file.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/djangoapps/util/file.py b/common/djangoapps/util/file.py index b884ca46a703..b2892e6f42c9 100644 --- a/common/djangoapps/util/file.py +++ b/common/djangoapps/util/file.py @@ -80,10 +80,10 @@ def store_uploaded_file( 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, -+ ) + ACL='private', + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + Key=stored_file_name, + ) if validator: try: From 1fa8e07f1cb5771c05a4b20e13c464df0956992a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 25 Jul 2024 10:49:14 -0400 Subject: [PATCH 21/63] style: Fix a pylint and other style violations. --- cms/djangoapps/contentstore/utils.py | 3 ++- cms/djangoapps/contentstore/views/library.py | 2 +- cms/djangoapps/contentstore/views/tests/test_library.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index d75ebdb3f23f..b268bd6fcb5d 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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_view_create_library_button(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), diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 7ba80bcab9f6..870c192653d2 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -68,6 +68,7 @@ def should_redirect_to_library_authoring_mfe(): REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND.is_enabled() ) + def user_can_view_create_library_button(user): """ Helper method for displaying the visibilty of the create_library_button. @@ -92,7 +93,6 @@ def user_can_view_create_library_button(user): return not disable_course_creation - def user_can_create_library(user, org): """ Helper method for returning the library creation status for a particular user, diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index fdbb9d905c62..fa6505419725 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -111,7 +111,7 @@ def test_library_creator_status_with_course_staff_role_for_enabled_creator_group 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. + # 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() From 238dca732eabf2aafe292a312be938b265476bc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 25 Jul 2024 13:45:57 -0300 Subject: [PATCH 22/63] fix: skip block indexing if it raises an error while loading (#35139) * fix: skip block indexing if it raises an error while loading * test: add tests to the issue --- openedx/core/djangoapps/content/search/api.py | 21 +++-- .../content/search/tests/test_api.py | 82 +++++++++++++++---- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 17824dbc2a7e..a3dce74941f6 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -345,23 +345,26 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: 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/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 1c78b28506fe..b207d34e963a 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -119,8 +119,8 @@ def setUp(self): ) 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 = { + self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1") + self.doc_problem1 = { "id": "lborg1libproblemp1-a698218e", "usage_key": "lb:org1:lib:problem:p1", "block_id": "p1", @@ -133,6 +133,20 @@ def setUp(self): "type": "library_block", "access_id": lib_access.id, } + self.problem2 = library_api.create_library_block(self.library.key, "problem", "p2") + 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, + } # Create a couple of taxonomies with tags self.taxonomyA = tagging_api.create_taxonomy(name="A", export_id="A") @@ -159,14 +173,52 @@ 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, ) @@ -245,9 +297,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 +308,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 +340,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 +353,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] + ) From 78b691b56a8ef126ef5ea669a5cdaf2fc9e9f571 Mon Sep 17 00:00:00 2001 From: Daniel Valenzuela Date: Thu, 25 Jul 2024 13:30:37 -0400 Subject: [PATCH 23/63] refactor: inheritable authoring mixin callbacks for editing & duplication (#33756) --- cms/djangoapps/contentstore/views/block.py | 2 +- cms/djangoapps/contentstore/views/preview.py | 2 +- .../xblock_storage_handlers/view_handlers.py | 9 ++-- cms/lib/xblock/authoring_mixin.py | 16 +++++++ xmodule/studio_editable.py | 43 +------------------ 5 files changed, 23 insertions(+), 49 deletions(-) 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/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/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 6959e22b94dc..4caaefccc265 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -305,13 +305,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/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/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): """ From 6b5d812d38f91b1c911ed7375f1cc44ea1ed4bb4 Mon Sep 17 00:00:00 2001 From: Jillian Date: Fri, 26 Jul 2024 03:48:49 +0930 Subject: [PATCH 24/63] feat: adds sortable fields to studio content search index (#35103) --- openedx/core/djangoapps/content/search/api.py | 19 ++++++++++ .../djangoapps/content/search/documents.py | 11 +++++- .../djangoapps/content/search/handlers.py | 2 + .../content/search/tests/test_api.py | 38 +++++++++++++++++-- .../content/search/tests/test_handlers.py | 26 ++++++++++++- .../core/djangoapps/content_libraries/api.py | 18 ++++++--- .../content_libraries/library_context.py | 16 ++++++++ .../tests/test_objecttag_export_helpers.py | 2 +- .../learning_context/learning_context.py | 7 ++++ .../core/djangoapps/xblock/rest_api/views.py | 5 +++ 10 files changed, 131 insertions(+), 13 deletions(-) diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index a3dce74941f6..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,6 +341,24 @@ 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...") 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 b207d34e963a..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,8 +120,17 @@ def setUp(self): title="Library", ) lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key) - # Populate it with a problem: - self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1") + + # 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", @@ -132,8 +143,10 @@ 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.problem2 = library_api.create_library_block(self.library.key, "problem", "p2") self.doc_problem2 = { "id": "lborg1libproblemp2-b2f65e29", "usage_key": "lb:org1:lib:problem:p2", @@ -146,6 +159,9 @@ def setUp(self): "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 @@ -223,6 +239,22 @@ def mocked_from_component(lib_key, component): 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 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..888452c89028 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -194,7 +194,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 +206,8 @@ def from_component(cls, library_key, component): """ Construct a LibraryXBlockMetadata from a Component object. """ + last_publish_log = authoring_api.get_last_publish(component.pk) + return cls( usage_key=LibraryUsageLocatorV2( library_key, @@ -210,6 +215,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 ) @@ -660,13 +668,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): 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_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py index d3306844ac40..f84ad4e6df72 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py @@ -441,7 +441,7 @@ def test_build_library_object_tree(self) -> None: """ Test if we can export a library """ - with self.assertNumQueries(8): + with self.assertNumQueries(11): tagged_library = build_object_tree_with_objecttags(self.library.key, self.all_library_object_tags) assert tagged_library == self.expected_library_tagged_xblock 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..501386efba38 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_updated_event(usage_key) + return Response({ "id": str(block.location), "data": data, From 12569b459f76765ab1f13163685c9dc6623ad983 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 25 Jul 2024 15:06:24 -0400 Subject: [PATCH 25/63] feat: removing djangoapp `demographics`, step 1 (#35182) * feat: removing djangoapp `demographics`, step 1 This step removes the models, the references to the models, and adds a migration to drop both tables (`HistoricalUserDemographics` didn't have a corresponding model but was still a valid table). Once this has deployed, this will be removed from `INSTALLED_APPS` and completely removed. No other apps in the repository currently reference this djangoapp in code or tables. FIXES: APER-3560 --- openedx/core/djangoapps/demographics/admin.py | 19 ----- .../djangoapps/demographics/api/status.py | 13 +-- .../0005_remove_demographics_models.py | 23 ++++++ .../core/djangoapps/demographics/models.py | 27 ------- .../demographics/rest_api/v1/views.py | 30 ++----- .../djangoapps/demographics/tests/__init__.py | 0 .../demographics/tests/factories.py | 16 ---- .../demographics/tests/test_status.py | 81 ------------------- 8 files changed, 31 insertions(+), 178 deletions(-) create mode 100644 openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py delete mode 100644 openedx/core/djangoapps/demographics/tests/__init__.py delete mode 100644 openedx/core/djangoapps/demographics/tests/factories.py delete mode 100644 openedx/core/djangoapps/demographics/tests/test_status.py diff --git a/openedx/core/djangoapps/demographics/admin.py b/openedx/core/djangoapps/demographics/admin.py index 115581a7fb5f..7ae952ee1dbf 100644 --- a/openedx/core/djangoapps/demographics/admin.py +++ b/openedx/core/djangoapps/demographics/admin.py @@ -1,22 +1,3 @@ """ 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/status.py b/openedx/core/djangoapps/demographics/api/status.py index cb861cf92f81..5dfb1e8f1942 100644 --- a/openedx/core/djangoapps/demographics/api/status.py +++ b/openedx/core/djangoapps/demographics/api/status.py @@ -2,9 +2,8 @@ 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 +from openedx.features.enterprise_support.utils import is_enterprise_learner def show_user_demographics(user, enrollments=None, entitlements=None): @@ -13,10 +12,7 @@ def show_user_demographics(user, enrollments=None, entitlements=None): 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 + user, "microbachelors", enrollments=enrollments, entitlements=entitlements ) return is_user_in_microbachelors_program and not is_enterprise_learner(user) @@ -26,7 +22,4 @@ 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 + return False diff --git a/openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py b/openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py new file mode 100644 index 000000000000..aa33188d6269 --- /dev/null +++ b/openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2024-07-25 15:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('demographics', '0004_alter_historicaluserdemographics_options'), + ] + + operations = [ + migrations.RemoveField( + model_name='userdemographics', + name='user', + ), + migrations.DeleteModel( + name='HistoricalUserDemographics', + ), + migrations.DeleteModel( + name='UserDemographics', + ), + ] diff --git a/openedx/core/djangoapps/demographics/models.py b/openedx/core/djangoapps/demographics/models.py index 09c9251b0e40..be66352f64bd 100644 --- a/openedx/core/djangoapps/demographics/models.py +++ b/openedx/core/djangoapps/demographics/models.py @@ -1,30 +1,3 @@ """ 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/v1/views.py b/openedx/core/djangoapps/demographics/rest_api/v1/views.py index 35aacc61bd24..04c06ccdcd8b 100644 --- a/openedx/core/djangoapps/demographics/rest_api/v1/views.py +++ b/openedx/core/djangoapps/demographics/rest_api/v1/views.py @@ -1,12 +1,9 @@ # lint-amnesty, pylint: disable=missing-module-docstring -from rest_framework import permissions, status +from rest_framework import permissions 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 +from openedx.core.djangoapps.demographics.api.status import show_call_to_action_for_user, show_user_demographics class DemographicsStatusView(APIView): @@ -16,7 +13,8 @@ class DemographicsStatusView(APIView): 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, ) + + permission_classes = (permissions.IsAuthenticated,) def _response_context(self, user, user_demographics=None): """ @@ -26,10 +24,7 @@ def _response_context(self, user, user_demographics=None): 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 - } + return {"display": show_user_demographics(user), "show_call_to_action": show_call_to_action} def get(self, request): """ @@ -39,18 +34,3 @@ def get(self, request): """ 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) From 3b8973ad011444fc37464f0c6905f583138c9a8b Mon Sep 17 00:00:00 2001 From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com> Date: Fri, 26 Jul 2024 10:52:32 +0500 Subject: [PATCH 26/63] fix: disable submit button for archived courses (#34920) * fix: disable submit button for archived courses --- xmodule/capa_block.py | 15 ++++++++++++++- xmodule/tests/test_capa_block.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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/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 From 7620c434a5c95f398a42d6d49370f30c2d4203f8 Mon Sep 17 00:00:00 2001 From: MueezKhan246 <93375917+MueezKhan246@users.noreply.github.com> Date: Fri, 26 Jul 2024 02:18:31 +0000 Subject: [PATCH 27/63] feat: Upgrade Python dependency edx-enterprise fixed search fetch crashing because of server taking too long for api request logs Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index df36493234c5..22b982b9ddab 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.8 +edx-enterprise==4.21.9 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b56b280405c5..4b4356bd8a70 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -452,7 +452,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.8 +edx-enterprise==4.21.9 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 360862385c34..f2e8ec77cf75 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -725,7 +725,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.8 +edx-enterprise==4.21.9 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index fcc073f09520..20b3198c8048 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -524,7 +524,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.8 +edx-enterprise==4.21.9 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index caa5d3c6f9db..e443719e8b1f 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -556,7 +556,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.8 +edx-enterprise==4.21.9 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From e9e4a3d0419597033b8f663c90ad069601d1d8a7 Mon Sep 17 00:00:00 2001 From: Muhammad Umar Khan <42294172+mumarkhan999@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:34:17 +0500 Subject: [PATCH 28/63] feat!: upgrade pymongo (#35179) --- Makefile | 2 + requirements/common_constraints.txt | 2 +- requirements/constraints.txt | 12 ++-- requirements/edx/base.txt | 10 ++- requirements/edx/development.txt | 8 ++- requirements/edx/doc.txt | 10 ++- requirements/edx/paver.txt | 4 +- requirements/edx/testing.txt | 11 +-- .../structures_pruning/requirements/base.txt | 4 +- .../requirements/testing.txt | 6 +- xmodule/contentstore/mongo.py | 65 +++++++++++++---- xmodule/modulestore/mongo/base.py | 72 ++++++++++++------- .../split_mongo/mongo_connection.py | 52 +++++++++++--- .../tests/test_mixed_modulestore.py | 41 ++++++++++- xmodule/mongo_utils.py | 33 +++++---- 15 files changed, 248 insertions(+), 84 deletions(-) diff --git a/Makefile b/Makefile index c70de65fb454..15bab5df67a9 100644 --- a/Makefile +++ b/Makefile @@ -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/requirements/common_constraints.txt b/requirements/common_constraints.txt index ef8bc86061b7..9405a605c520 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -40,4 +40,4 @@ importlib-metadata<7 # 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 -event-tracking<2.4.1 + diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 22b982b9ddab..b32989514d5d 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -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 @@ -125,4 +129,4 @@ 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 \ No newline at end of file +django-storages<1.14.4 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4b4356bd8a70..a7d2d457cc22 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -376,6 +376,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 @@ -536,9 +540,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-completion # edx-proctoring @@ -865,7 +869,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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index f2e8ec77cf75..397923ac1e3b 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -614,8 +614,10 @@ 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 @@ -853,9 +855,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-completion @@ -1535,7 +1537,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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 20b3198c8048..182ff8eb8684 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -440,6 +440,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 @@ -614,9 +618,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring @@ -1026,7 +1030,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 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/testing.txt b/requirements/edx/testing.txt index e443719e8b1f..bf5c2817d519 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -476,7 +476,10 @@ 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 + # email-validator + # pymongo done-xblock==2.3.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 @@ -650,9 +653,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/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring @@ -1141,7 +1144,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 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..d74b204fad5c 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,7 +28,7 @@ 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 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( From 089b34a6bfc3b1a41f67a650b1dfccc6499263b2 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Fri, 26 Jul 2024 11:13:35 -0400 Subject: [PATCH 29/63] feat: removing the demographics application stage 2 (#35186) * removing the application folder * removing references in all other files FIXES: APER-3560 --- .github/workflows/pylint-checks.yml | 2 +- .github/workflows/unit-test-shards.json | 2 - lms/envs/common.py | 3 - lms/urls.py | 3 - .../core/djangoapps/demographics/README.rst | 19 ------- .../core/djangoapps/demographics/__init__.py | 0 openedx/core/djangoapps/demographics/admin.py | 3 - .../djangoapps/demographics/api/__init__.py | 0 .../djangoapps/demographics/api/status.py | 25 --------- .../0001-demographics-djangoapp-api.rst | 22 -------- .../demographics/migrations/0001_initial.py | 55 ------------------- .../0002_clean_duplicate_entries.py | 48 ---------------- .../migrations/0003_auto_20200827_1949.py | 20 ------- ...lter_historicaluserdemographics_options.py | 17 ------ .../0005_remove_demographics_models.py | 23 -------- .../demographics/migrations/__init__.py | 0 .../core/djangoapps/demographics/models.py | 3 - .../demographics/rest_api/__init__.py | 0 .../djangoapps/demographics/rest_api/urls.py | 12 ---- .../demographics/rest_api/v1/__init__.py | 0 .../demographics/rest_api/v1/urls.py | 12 ---- .../demographics/rest_api/v1/views.py | 36 ------------ scripts/user_retirement/retire_one_learner.py | 2 - scripts/user_retirement/utils/helpers.py | 15 ++--- 24 files changed, 6 insertions(+), 316 deletions(-) delete mode 100644 openedx/core/djangoapps/demographics/README.rst delete mode 100644 openedx/core/djangoapps/demographics/__init__.py delete mode 100644 openedx/core/djangoapps/demographics/admin.py delete mode 100644 openedx/core/djangoapps/demographics/api/__init__.py delete mode 100644 openedx/core/djangoapps/demographics/api/status.py delete mode 100644 openedx/core/djangoapps/demographics/docs/decisions/0001-demographics-djangoapp-api.rst delete mode 100644 openedx/core/djangoapps/demographics/migrations/0001_initial.py delete mode 100644 openedx/core/djangoapps/demographics/migrations/0002_clean_duplicate_entries.py delete mode 100644 openedx/core/djangoapps/demographics/migrations/0003_auto_20200827_1949.py delete mode 100644 openedx/core/djangoapps/demographics/migrations/0004_alter_historicaluserdemographics_options.py delete mode 100644 openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py delete mode 100644 openedx/core/djangoapps/demographics/migrations/__init__.py delete mode 100644 openedx/core/djangoapps/demographics/models.py delete mode 100644 openedx/core/djangoapps/demographics/rest_api/__init__.py delete mode 100644 openedx/core/djangoapps/demographics/rest_api/urls.py delete mode 100644 openedx/core/djangoapps/demographics/rest_api/v1/__init__.py delete mode 100644 openedx/core/djangoapps/demographics/rest_api/v1/urls.py delete mode 100644 openedx/core/djangoapps/demographics/rest_api/v1/views.py 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/lms/envs/common.py b/lms/envs/common.py index 26073335a267..9f9004976e0c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3350,9 +3350,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', 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/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 7ae952ee1dbf..000000000000 --- a/openedx/core/djangoapps/demographics/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Django admin page for demographics -""" 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 5dfb1e8f1942..000000000000 --- a/openedx/core/djangoapps/demographics/api/status.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Python API for Demographics Status -""" - -from openedx.core.djangoapps.programs.api import is_user_enrolled_in_program_type -from openedx.features.enterprise_support.utils import is_enterprise_learner - - -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. - """ - return False 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/0005_remove_demographics_models.py b/openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py deleted file mode 100644 index aa33188d6269..000000000000 --- a/openedx/core/djangoapps/demographics/migrations/0005_remove_demographics_models.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.14 on 2024-07-25 15:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('demographics', '0004_alter_historicaluserdemographics_options'), - ] - - operations = [ - migrations.RemoveField( - model_name='userdemographics', - name='user', - ), - migrations.DeleteModel( - name='HistoricalUserDemographics', - ), - migrations.DeleteModel( - name='UserDemographics', - ), - ] 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 be66352f64bd..000000000000 --- a/openedx/core/djangoapps/demographics/models.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Demographics models -""" 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 04c06ccdcd8b..000000000000 --- a/openedx/core/djangoapps/demographics/rest_api/v1/views.py +++ /dev/null @@ -1,36 +0,0 @@ -# lint-amnesty, pylint: disable=missing-module-docstring -from rest_framework import permissions -from rest_framework.response import Response -from rest_framework.views import APIView - -from openedx.core.djangoapps.demographics.api.status import show_call_to_action_for_user, show_user_demographics - - -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)) 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/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, From 8d0ada54c428b0c546119338d3661980ed4d23d2 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Fri, 26 Jul 2024 12:00:42 -0400 Subject: [PATCH 30/63] feat: add waffle flag for beta testing new problem editor parser (#35184) * feat: add waffle flag for beta testing new problem editor parser * chore: remove commented out code * fix: failing test --- cms/djangoapps/contentstore/toggles.py | 12 ++++++++++++ .../contentstore/views/tests/test_block.py | 7 ++++++- .../xblock_storage_handlers/view_handlers.py | 6 ++++++ 3 files changed, 24 insertions(+), 1 deletion(-) 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/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/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 4caaefccc265..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) From 085a2f581557c1fe26106426708a91eeb0b53a85 Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:57:51 +0500 Subject: [PATCH 31/63] refactor: deprecated course update notification waffle flag (#35190) --- cms/djangoapps/contentstore/config/waffle.py | 13 ------------- cms/djangoapps/contentstore/course_info_model.py | 8 +++----- 2 files changed, 3 insertions(+), 18 deletions(-) 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: From 39dd3c002b296a205e867f147a90a4eea6aee8c3 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Tue, 30 Jul 2024 13:18:49 +0500 Subject: [PATCH 32/63] feat: converting existing api to drf base api. (#35039) Adding generic permission class. Added standard authentication classes. --- lms/djangoapps/instructor/permissions.py | 12 +++- lms/djangoapps/instructor/tests/test_api.py | 51 ++++++++++++++- lms/djangoapps/instructor/views/api.py | 71 ++++++++++++++------- lms/djangoapps/instructor/views/api_urls.py | 3 +- 4 files changed, 111 insertions(+), 26 deletions(-) diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py index e1a1cbf466f6..24e0079fcce3 100644 --- a/lms/djangoapps/instructor/permissions.py +++ b/lms/djangoapps/instructor/permissions.py @@ -1,11 +1,13 @@ """ Permissions for the instructor dashboard and associated actions """ - from bridgekeeper import perms from bridgekeeper.rules import is_staff +from opaque_keys.edx.keys import CourseKey +from rest_framework.permissions import BasePermission from lms.djangoapps.courseware.rules import HasAccessRule, HasRolesRule +from openedx.core.lib.courses import get_course_by_id ALLOW_STUDENT_TO_BYPASS_ENTRANCE_EXAM = 'instructor.allow_student_to_bypass_entrance_exam' ASSIGN_TO_COHORTS = 'instructor.assign_to_cohorts' @@ -72,3 +74,11 @@ ) | HasAccessRule('staff') | HasAccessRule('instructor') perms[VIEW_ENROLLMENTS] = HasAccessRule('staff') perms[VIEW_FORUM_MEMBERS] = HasAccessRule('staff') + + +class InstructorPermission(BasePermission): + """Generic permissions""" + 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 02a91e7d84de..e7a82496456e 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -122,6 +122,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, @@ -1718,15 +1719,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. @@ -1736,21 +1757,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 diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 0b4a88d1b7c6..6b072ef0c12e 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. """ @@ -32,7 +33,7 @@ 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'), From 459b0a4907836de48f5f3112a42fe276bef01e67 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 <83753341+hamzawaleed01@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:54:43 +0000 Subject: [PATCH 33/63] feat: Upgrade Python dependency edx-enterprise created migration to create a system-wide enterprise role named `enterprise_provisioning_admin`. Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index b32989514d5d..1e54a39697bd 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.9 +edx-enterprise==4.22.1 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a7d2d457cc22..959f91b43e5f 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -456,7 +456,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.9 +edx-enterprise==4.22.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 397923ac1e3b..09126540239f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -727,7 +727,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.9 +edx-enterprise==4.22.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 182ff8eb8684..66c443252b70 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -528,7 +528,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.9 +edx-enterprise==4.22.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index bf5c2817d519..00c4fbad9b6b 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -559,7 +559,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.21.9 +edx-enterprise==4.22.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 46c972eb155369d303b6ada4e087c97b548e99cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:02:33 +0000 Subject: [PATCH 34/63] feat: Upgrade Python dependency edx-enterprise (#35205) fix: serialize 'course_key' from the CourseDetails model by @brobro10000 in #2185 Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` Co-authored-by: brobro10000 <82611798+brobro10000@users.noreply.github.com> --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 1e54a39697bd..1d9e42a51e3c 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.22.1 +edx-enterprise==4.22.2 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 959f91b43e5f..bed716c29398 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -456,7 +456,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.22.1 +edx-enterprise==4.22.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 09126540239f..217580be3ebe 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -727,7 +727,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.22.1 +edx-enterprise==4.22.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 66c443252b70..b5fd2b0a0665 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -528,7 +528,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.22.1 +edx-enterprise==4.22.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 00c4fbad9b6b..db113107e593 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -559,7 +559,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.22.1 +edx-enterprise==4.22.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 0359d5204d6cc3585ce6b79804188320dcc087ba Mon Sep 17 00:00:00 2001 From: Tim McCormack Date: Wed, 31 Jul 2024 17:36:08 +0000 Subject: [PATCH 35/63] fix: Prevent error page recursion (#35209) We sometimes see rendering errors in the error page itself, which then cause another attempt at rendering the error page. I'm not sure _exactly_ how the loop is occurring, but it looks something like this: 1. An error is raised in a view or middleware and is not caught by application code 2. Django catches the error and calls the registered uncaught error handler 3. Our handler tries to render an error page 4. The rendering code raises an error 5. GOTO 2 (until some sort of server limit is reached) By catching all errors raised during error-page render and substituting in a hardcoded string, we can reduce server resources, avoid logging massive sequences of recursive stack traces, and still give the user *some* indication that yes, there was a problem. This should help address https://github.com/openedx/edx-platform/issues/35151 At least one of these rendering errors is known to be due to a translation error. There's a separate issue for restoring translation quality so that we avoid those issues in the future (https://github.com/openedx/openedx-translations/issues/549) but in general we should catch all rendering errors, including unknown ones. Testing: - In `lms/envs/devstack.py` change `DEBUG` to `False` to ensure that the usual error page is displayed (rather than the debug error page). - Add line `1/0` to the top of the `student_dashboard` function in `common/djangoapps/student/views/dashboard.py` to make that view error. - In `lms/templates/static_templates/server-error.html` replace `static.get_platform_name()` with `None * 7` to make the error template itself produce an error. - Visit . Without the fix, the response takes 10 seconds and produces a 6 MB, 85k line set of stack traces and the page displays "A server error occurred. Please contact the administrator." With the fix, the response takes less than a second and produces three stack traces (one of which contains the error page's rendering error). --- lms/djangoapps/static_template_view/views.py | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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.") From 73c3211c77c1b2fffff17cb0a50c94436e247e72 Mon Sep 17 00:00:00 2001 From: feanil Date: Thu, 1 Aug 2024 03:06:57 +0000 Subject: [PATCH 36/63] chore: geoip2: update maxmind geolite country database --- .../static/data/geoip/GeoLite2-Country.mmdb | Bin 6587396 -> 6712563 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 5eae81b159e2e58a5af7b3a57ed5ef32e8440eca..b4dcff0b4ab86b891e5a7ffe6ae93435daaf37ab 100644 GIT binary patch literal 6712563 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�rDvP0 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

HFl;bd+YKG`(B8F8}XI-2YQ@=Wl<0AwDyu#VE}} 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

O(UhJPnk0pMN_!;772M*oV2Sv6! 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