From d94826762760d3edb4a6976e623c6368c0eb7773 Mon Sep 17 00:00:00 2001 From: Kaustav Banerjee Date: Tue, 26 Sep 2023 16:15:53 +0530 Subject: [PATCH 01/86] fix: clarify grade range numerical values --- cms/static/js/views/settings/grading.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index ac4d170352..dcb2ce9b3e 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -268,7 +268,7 @@ function(ValidatingView, _, $, ui, GraderView, StringUtils, HtmlUtils) { var cutoffs = this.descendingCutoffs; this.$el.find('.range').each(function(i) { var min = (i < cutoffs.length ? cutoffs[i].cutoff : 0); - var max = (i > 0 ? cutoffs[i - 1].cutoff : 100); + var max = (i > 0 ? cutoffs[i - 1].cutoff - 1 : 100); $(this).text(min + '-' + max); }); }, From 683145156bed229e08659f2f2abcf002ba46136a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 28 Sep 2023 15:16:21 -0400 Subject: [PATCH 02/86] feat: Update the minimum password length. --- cms/envs/common.py | 21 +++------------------ lms/envs/common.py | 2 +- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 2021d372bc..90227b4466 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -121,6 +121,9 @@ from lms.envs.common import ( # Methods to derive settings _make_mako_template_dirs, _make_locale_paths, + + # Password Validator Settings + AUTH_PASSWORD_VALIDATORS ) from path import Path as path from django.urls import reverse_lazy @@ -1878,24 +1881,6 @@ EVENT_TRACKING_PROCESSORS = [] EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST = [] #### PASSWORD POLICY SETTINGS ##### -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", - "OPTIONS": { - "min_length": 2 - } - }, - { - "NAME": "common.djangoapps.util.password_policy_validators.MaximumLengthValidator", - "OPTIONS": { - "max_length": 75 - } - }, -] - PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG = { 'ENFORCE_COMPLIANCE_ON_LOGIN': False } diff --git a/lms/envs/common.py b/lms/envs/common.py index 2c12037e92..e5089e86b0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3756,7 +3756,7 @@ AUTH_PASSWORD_VALIDATORS = [ { "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", "OPTIONS": { - "min_length": 2 + "min_length": 8 } }, { From ae42b094eaed70c3d8206e55a17e7a814cbaf2e2 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 28 Sep 2023 16:01:13 -0400 Subject: [PATCH 03/86] test: Fix validation tests. Make them resilient to the default changing where it makes sense. --- common/djangoapps/student/tests/test_email.py | 2 +- .../tests/test_password_policy_validators.py | 16 +++++++++++++--- .../user_authn/views/tests/test_register.py | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 7f1287fba0..f39591c779 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -136,7 +136,7 @@ class ActivationEmailTests(EmailTemplateTagMixin, CacheIsolationTestCase): params = { 'username': 'test_user', 'email': 'test_user@example.com', - 'password': 'edx', + 'password': 'long_password', 'name': 'Test User', 'honor_code': True, 'terms_of_service': True diff --git a/common/djangoapps/util/tests/test_password_policy_validators.py b/common/djangoapps/util/tests/test_password_policy_validators.py index f0ffe3c2b1..f5eea255bf 100644 --- a/common/djangoapps/util/tests/test_password_policy_validators.py +++ b/common/djangoapps/util/tests/test_password_policy_validators.py @@ -45,6 +45,11 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase): validate_password(password, user) assert msg in ' '.join(cm.value.messages) + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_unicode_password(self): """ Tests that validate_password enforces unicode """ unicode_str = '𤭮' @@ -55,12 +60,17 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase): assert len(unicode_str) == 1 # Test length check - self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 2 characters.') - self.validation_errors_checker(byte_str + byte_str, None) + self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 4 characters.') + self.validation_errors_checker(byte_str * 4, None) # Test badly encoded password self.validation_errors_checker(b'\xff\xff', 'Invalid password.') + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_password_unicode_normalization(self): """ Tests that validate_password normalizes passwords """ # s ̣ ̇ (s with combining dot below and combining dot above) @@ -70,7 +80,7 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase): # When we normalize we expect the not_normalized password to fail # because it should be normalized to '\u1E69' -> ṩ self.validation_errors_checker(not_normalized_password, - 'This password is too short. It must contain at least 2 characters.') + 'This password is too short. It must contain at least 4 characters.') @data( ([create_validator_config('common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 2})], # lint-amnesty, pylint: disable=line-too-long diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 0bc05545f7..8e73ab2ec3 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -2689,20 +2689,30 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase, OpenEdxEventsTestM {"username": str(USERNAME_INVALID_CHARS_ASCII)} ) + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_password_empty_validation_decision(self): # 2 is the default setting for minimum length found in lms/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator - msg = 'This password is too short. It must contain at least 2 characters.' + msg = 'This password is too short. It must contain at least 4 characters.' self.assertValidationDecision( {'password': ''}, {"password": msg} ) + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_password_bad_min_length_validation_decision(self): password = 'p' # 2 is the default setting for minimum length found in lms/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator - msg = 'This password is too short. It must contain at least 2 characters.' + msg = 'This password is too short. It must contain at least 4 characters.' self.assertValidationDecision( {'password': password}, {"password": msg} From 2255be06ad6d1cf6e6592802256925e7dfb014a4 Mon Sep 17 00:00:00 2001 From: hamzawaleed01 Date: Wed, 4 Oct 2023 12:41:00 +0000 Subject: [PATCH 04/86] feat: Upgrade Python dependency edx-enterprise Commit generated by workflow `openedx/edx-platform/.github/workflows/upgrade-one-python-dependency.yml@refs/heads/master` --- requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 77f60e9937..7f0643c045 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ django-storages==1.14 # 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.5.0 +edx-enterprise==4.5.4 # django-oauth-toolkit version >=2.0.0 has breaking changes. More details # mentioned on this issue https://github.com/openedx/edx-platform/issues/32884 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 63dfd49229..192f932843 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -480,7 +480,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.5.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index d95bf65d1a..07d3d9ae09 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -750,7 +750,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.5.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 2ccab0b634..70c15bc738 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -556,7 +556,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.5.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 1e854dda09..235918f555 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -583,7 +583,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.5.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 5bc0a575b86f198a6fb59ae704aec9ee5567e0b3 Mon Sep 17 00:00:00 2001 From: Marcos Date: Thu, 28 Sep 2023 15:45:28 -0300 Subject: [PATCH 05/86] feat: Added Waffle Flags for Courseware Search feature --- .gitignore | 1 + lms/djangoapps/courseware/tests/test_views.py | 44 ++++++++++++++++++- lms/djangoapps/courseware/toggles.py | 20 +++++++++ lms/djangoapps/courseware/views/views.py | 18 ++++++-- lms/urls.py | 10 +++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index d6f1202bf4..1866979edc 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,7 @@ build \#*\# .env/ openedx/core/djangoapps/django_comment_common/comment_client/python +openedx/core/djangoapps/cache_toolbox/__pycache__ autodeploy.properties .ws_migrations_complete dist diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c7d7ce7b17..e71f3f07ec 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -30,6 +30,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from pytz import UTC from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel from rest_framework import status +from rest_framework.test import APIClient from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Scope, String @@ -76,7 +77,10 @@ from lms.djangoapps.courseware.block_render import get_block, handle_xblock_call from lms.djangoapps.courseware.tests.factories import StudentModuleFactory from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, get_expiration_banner_text, set_preview_mode from lms.djangoapps.courseware.testutils import RenderXBlockTestMixin -from lms.djangoapps.courseware.toggles import COURSEWARE_OPTIMIZED_RENDER_XBLOCK +from lms.djangoapps.courseware.toggles import ( + COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, + COURSEWARE_OPTIMIZED_RENDER_XBLOCK, +) from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.courseware.views.views import ( BasePublicVideoXBlockView, @@ -3683,3 +3687,41 @@ class TestPublicVideoXBlockEmbedView(TestBasePublicVideoXBlock): assert template == 'public_video_share_embed.html' assert context['fragment'] == fragment assert context['course'] == self.course + + +class TestCoursewareMFESearchAPI(SharedModuleStoreTestCase): + """ + Tests the endpoint to fetch the Courseware Search waffle flag enabled status. + """ + + def setUp(self): + super().setUp() + + self.course = CourseFactory.create() + + self.client = APIClient() + self.apiUrl = reverse('courseware_search_enabled_view', kwargs={'course_id': str(self.course.id)}) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=True) + def test_courseware_mfe_search_enabled(self): + """ + Getter to check if user is allowed to use Courseware Search. + """ + + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(body, {'enabled': True}) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=False) + def test_is_mfe_search_disabled(self): + """ + Getter to check if user is allowed to use Courseware Search. + """ + + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(body, {'enabled': False}) diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 3e13755123..78cecac7f5 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -55,6 +55,19 @@ COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION = CourseWaffleFl f'{WAFFLE_FLAG_NAMESPACE}.mfe_progress_milestones_streak_celebration', __name__ ) +# .. toggle_name: courseware.mfe_courseware_search +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables Courseware Search on Learning MFE +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-09-28 +# .. toggle_target_removal_date: None +# .. toggle_tickets: KBK-20 +# .. toggle_warning: None. +COURSEWARE_MICROFRONTEND_SEARCH_ENABLED = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.mfe_courseware_search', __name__ +) + # .. toggle_name: courseware.mfe_progress_milestones_streak_discount_enabled # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False @@ -153,3 +166,10 @@ def course_is_invitation_only(courselike) -> bool: def learning_assistant_is_active(course_key): return COURSEWARE_LEARNING_ASSISTANT.is_enabled(course_key) + + +def courseware_mfe_search_is_enabled(course_key=None): + """ + Return whether the courseware.mfe_courseware_search flag is on. + """ + return COURSEWARE_MICROFRONTEND_SEARCH_ENABLED.is_enabled(course_key) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 085e9e5a19..fdc90a46a5 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -18,8 +18,8 @@ from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pyli from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q, prefetch_related_objects -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import redirect +from django.http import JsonResponse, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.template.context_processors import csrf from django.urls import reverse from django.utils.decorators import method_decorator @@ -38,8 +38,8 @@ from markupsafe import escape from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_filters.learning.filters import CourseAboutRenderStarted -from pytz import UTC from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin +from pytz import UTC from rest_framework import status from rest_framework.decorators import api_view, throttle_classes from rest_framework.response import Response @@ -87,7 +87,7 @@ from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_stu from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule from lms.djangoapps.courseware.permissions import MASQUERADE_AS_STUDENT, VIEW_COURSE_HOME, VIEW_COURSEWARE -from lms.djangoapps.courseware.toggles import course_is_invitation_only +from lms.djangoapps.courseware.toggles import course_is_invitation_only, courseware_mfe_search_is_enabled from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.courseware.utils import ( _use_new_financial_assistance_flow, @@ -2275,3 +2275,15 @@ def get_learner_username(learner_identifier): learner = User.objects.filter(Q(username=learner_identifier) | Q(email=learner_identifier)).first() if learner: return learner.username + + +@api_view(['GET']) +def courseware_mfe_search_enabled(request, course_id=None): + """ + Simple GET endpoint to expose whether the course may use Courseware Search. + """ + + course_key = CourseKey.from_string(course_id) if course_id else None + + payload = {"enabled": courseware_mfe_search_is_enabled(course_key)} + return JsonResponse(payload) diff --git a/lms/urls.py b/lms/urls.py index dc673054df..8b314f9f1e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -752,6 +752,16 @@ urlpatterns += [ ), ] +urlpatterns += [ + re_path( + r'^courses/{}/courseware-search/enabled/$'.format( + settings.COURSE_ID_PATTERN, + ), + courseware_views.courseware_mfe_search_enabled, + name='courseware_search_enabled_view', + ), +] + urlpatterns += [ re_path( r'^courses/{}/lti_tab/(?P[^/]+)/$'.format( From c1163717dfc4f4a65db8f081e2e02eaa4d155721 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Wed, 4 Oct 2023 13:34:16 -0400 Subject: [PATCH 06/86] build: log `npm run postinstall` steps to STDOUT so they are visible in CI (#33142) Without this change, `npm run postinstall` (aka scripts/copy-node-modules.sh) uses `set -x`, which echo steps to STDERR. By default, it seems that GitHub Actions doesn't show STDERR. Having the steps visible in CI was very useful while debugging some Tutor build improvements, so I figured it would be good to upstream the change. --- scripts/copy-node-modules.sh | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/scripts/copy-node-modules.sh b/scripts/copy-node-modules.sh index db997da957..f49514b6de 100755 --- a/scripts/copy-node-modules.sh +++ b/scripts/copy-node-modules.sh @@ -26,28 +26,33 @@ log ( ) { echo -e "${COL_LOG}$* $COL_OFF" } +# Print a command (prefixed with '+') and then run it, to aid in debugging. +# This is just like `set -x`, except that `set -x` prints to STDERR, which is hidden +# by GitHub Actions logs. This functions prints to STDOUT, which is visible. +log_and_run ( ) { + log "+$*" + "$@" # Joins arguments to form a command (quoting as necessary) and runs the command. +} + log "=====================================================================================" log "Copying required assets from node_modules..." log "-------------------------------------------------------------------------------" -# Start echoing all commands back to user for ease of debugging. -set -x - log "Ensuring vendor directories exist..." -mkdir -p "$vendor_js" -mkdir -p "$vendor_css" +log_and_run mkdir -p "$vendor_js" +log_and_run mkdir -p "$vendor_css" log "Copying studio-frontend JS & CSS from node_modules into vendor directores..." while read -r -d $'\0' src_file ; do if [[ "$src_file" = *.css ]] || [[ "$src_file" = *.css.map ]] ; then - cp --force "$src_file" "$vendor_css" + log_and_run cp --force "$src_file" "$vendor_css" else - cp --force "$src_file" "$vendor_js" + log_and_run cp --force "$src_file" "$vendor_js" fi done < <(find "$node_modules/@edx/studio-frontend/dist" -type f -print0) log "Copying certain JS modules from node_modules into vendor directory..." -cp --force \ +log_and_run cp --force \ "$node_modules/backbone.paginator/lib/backbone.paginator.js" \ "$node_modules/backbone/backbone.js" \ "$node_modules/bootstrap/dist/js/bootstrap.bundle.js" \ @@ -66,8 +71,8 @@ cp --force \ log "Copying certain JS developer modules into vendor directory..." if [[ "${NODE_ENV:-production}" = development ]] ; then - cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" - cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" + log_and_run cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" + log_and_run cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" else # TODO: https://github.com/openedx/edx-platform/issues/31768 # In the old implementation of this scipt (pavelib/assets.py), these two @@ -77,13 +82,10 @@ else # However, in the future, it would be good to only copy them for dev # builds. Furthermore, these libraries should not be `npm install`ed # into prod builds in the first place. - cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" || true # "|| true" means "tolerate errors"; in this case, - cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" || true # that's "tolerate if these files don't exist." + log_and_run cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" || true # "|| true" means "tolerate errors"; in this case, + log_and_run cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" || true # that's "tolerate if these files don't exist." fi -# Done echoing. -set +x - log "-------------------------------------------------------------------------------" log " Done copying required assets from node_modules." log "=====================================================================================" From 64eba521f8d9e2e1542e84110f72ceb082fe3050 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Thu, 5 Oct 2023 15:56:55 +0500 Subject: [PATCH 07/86] Revert "[django42] sha1 is removed in django42 version. " (#33213) * Revert "[django42] sha1 is removed in django42 version. (#33129)" --- .../djangoapps/safe_sessions/middleware.py | 10 ++---- .../tests/test_safe_cookie_data.py | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py index a9ade2ac3b..5f4449d93c 100644 --- a/openedx/core/djangoapps/safe_sessions/middleware.py +++ b/openedx/core/djangoapps/safe_sessions/middleware.py @@ -267,10 +267,7 @@ class SafeCookieData: SHA256(version '|' session_id '|' user_id '|'). """ data_to_sign = self._compute_digest(user_id) - - self.signature = signing.TimestampSigner( - salt=self.key_salt, algorithm=settings.DEFAULT_HASHING_ALGORITHM - ).sign_object(data_to_sign, serializer=signing.JSONSerializer, compress=False) + self.signature = signing.dumps(data_to_sign, salt=self.key_salt) def verify(self, user_id): """ @@ -279,10 +276,7 @@ class SafeCookieData: (not expired) and bound to the given user. """ try: - unsigned_data = signing.TimestampSigner( - salt=self.key_salt, algorithm=settings.DEFAULT_HASHING_ALGORITHM - ).unsign_object(self.signature, serializer=signing.JSONSerializer, max_age=settings.SESSION_COOKIE_AGE) - + unsigned_data = signing.loads(self.signature, salt=self.key_salt, max_age=settings.SESSION_COOKIE_AGE) if unsigned_data == self._compute_digest(user_id): return True log.error("SafeCookieData '%r' is not bound to user '%s'.", str(self), user_id) diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py index b8a23c567d..47efa626ec 100644 --- a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py +++ b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest import ddt +import django from django.test import TestCase from ..middleware import SafeCookieData, SafeCookieError @@ -204,6 +205,7 @@ class TestSafeCookieData(TestSafeSessionsLogMixin, TestCase): #---- Test roundtrip with pinned values ----# + @pytest.mark.skipif(django.VERSION[0] >= 4, reason="For django32 default algorithm is sha1. No need for django42.") def test_pinned_values(self): """ Compute a cookie with all inputs held constant and assert that the @@ -236,3 +238,37 @@ class TestSafeCookieData(TestSafeSessionsLogMixin, TestCase): ":1m6Hve" ":OMhY2FL2pudJjSSXChtI-zR8QVA" ) + + @pytest.mark.skipif(django.VERSION[0] < 4, reason="For django42 default algorithm is sha256. No need for django32.") + def test_pinned_values_django42(self): + """ + Compute a cookie with all inputs held constant and assert that the + exact output never changes. This protects against unintentional + changes to the algorithm. + """ + user_id = '8523' + session_id = 'SSdtIGEgc2Vzc2lvbiE' + a_random_string = 'HvGnjXf1b3jU' + timestamp = 1626895850 + + module = 'openedx.core.djangoapps.safe_sessions.middleware' + with patch(f"{module}.signing.time.time", return_value=timestamp): + with patch(f"{module}.get_random_string", return_value=a_random_string): + safe_cookie_data = SafeCookieData.create(session_id, user_id) + serialized_value = str(safe_cookie_data) + + # **IMPORTANT**: If a change to the algorithm causes this test + # to start failing, you will either need to allow both the old + # and new format or all users will become logged out upon + # deploy of the changes. + # + # Also assumes SECRET_KEY is '85920908f28904ed733fe576320db18cabd7b6cd' + # (set in lms or cms.envs.test) + assert serialized_value == ( + "1" + "|SSdtIGEgc2Vzc2lvbiE" + "|HvGnjXf1b3jU" + "|ImExZWZiNzVlZGFmM2FkZWZmYjM4YjI0ZmZkOWU4MzExODU0MTk4NmVlNGRiYzBlODdhYWUzOGM5MzVlNzk4NjUi" + ":1m6Hve" + ":Pra4iochviPvKUoIV33gdVZFDgG-cMDlIYfl8iFIMaY" + ) From 98d04351676ca6a582fcb2f025fada844a07ee04 Mon Sep 17 00:00:00 2001 From: Yagnesh Nayi <127923546+Yagnesh1998@users.noreply.github.com> Date: Thu, 5 Oct 2023 18:49:59 +0530 Subject: [PATCH 08/86] feat: remove askbot changes in depr216 (#33408) * feat: remove askbot changes in depr216 * feat: update cms test.py * feat: update cms test.py --- cms/envs/test.py | 3 +-- lms/static/sass/course/base/_extends.scss | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cms/envs/test.py b/cms/envs/test.py index ebaaf09171..e7be26b73c 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -142,8 +142,7 @@ COURSE_AUTHORING_MICROFRONTEND_URL = "http://course-authoring-mfe" DISCUSSIONS_MICROFRONTEND_URL = "http://discussions-mfe" CACHES = { - # This is the cache used for most things. Askbot will not work without a - # functioning cache -- it relies on caching to load its settings in places. + # This is the cache used for most things. # In staging/prod envs, the sessions also live here. 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index b1f3f96c3b..72d2eedf44 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -30,7 +30,7 @@ h1.top-header { } .light-button, -a.light-button, // only used in askbot as classes +a.light-button, .gray-button { @include simple($gray-l5); From 6710af6d136f234b236d75cef2f53b11bb2aee61 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 5 Oct 2023 10:21:32 -0400 Subject: [PATCH 09/86] feat!: remove Matlab key from Studio Problem settings The Matlab-specific code is coupled with XQueue in places, making its removal a little tricky. But we at least want to hide this now-useless field from end-users before we cut the Quince release. https://github.com/openedx/public-engineering/issues/192 --- xmodule/capa_block.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 67d5fd58c2..7b58b5aa9a 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -504,6 +504,11 @@ class ProblemBlock( ProblemBlock.markdown, ProblemBlock.use_latex_compiler, ProblemBlock.show_correctness, + + # Temporarily remove the ability to see MATLAB API key in Studio, as + # a pre-cursor to removing it altogether. + # https://github.com/openedx/public-engineering/issues/192 + ProblemBlock.matlab_api_key, ]) return non_editable_fields From 0a6eb51166d2c035191d5b9ed5acffb3a913212e Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 5 Oct 2023 09:38:24 -0700 Subject: [PATCH 10/86] feat: Simplify Content Tagging Models (#33378) * feat: Simplify tagging - remove Content*Taxonomy models * chore: bump version of openedx-learning * chore: fix lint errors --- .../core/djangoapps/content_tagging/api.py | 58 ++++++----- .../0002_system_defined_taxonomies.py | 6 +- .../migrations/0006_simplify_models.py | 22 +++++ .../content_tagging/models/__init__.py | 5 - .../djangoapps/content_tagging/models/base.py | 85 +++------------- .../content_tagging/models/system_defined.py | 27 ----- .../core/djangoapps/content_tagging/tasks.py | 64 +++++------- .../content_tagging/tests/test_api.py | 98 +++++++++---------- .../content_tagging/tests/test_models.py | 67 ------------- .../content_tagging/tests/test_rules.py | 32 ------ .../content_tagging/tests/test_tasks.py | 56 ++++------- .../core/djangoapps/content_tagging/types.py | 8 ++ requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 17 files changed, 173 insertions(+), 365 deletions(-) create mode 100644 openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py delete mode 100644 openedx/core/djangoapps/content_tagging/models/system_defined.py delete mode 100644 openedx/core/djangoapps/content_tagging/tests/test_models.py create mode 100644 openedx/core/djangoapps/content_tagging/types.py diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index bc420b9084..4ff21dff3f 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -3,15 +3,15 @@ Content Tagging APIs """ from __future__ import annotations -from typing import Iterator, List, Type +from typing import Iterator import openedx_tagging.core.tagging.api as oel_tagging -from django.db.models import QuerySet -from opaque_keys.edx.keys import CourseKey, UsageKey +from django.db.models import Q, QuerySet, Exists, OuterRef from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization -from .models import ContentObjectTag, ContentTaxonomy, TaxonomyOrg +from .models import ContentObjectTag, TaxonomyOrg +from .types import ContentKey def create_taxonomy( @@ -21,12 +21,9 @@ def create_taxonomy( required=False, allow_multiple=False, allow_free_text=False, - taxonomy_class: Type = ContentTaxonomy, ) -> Taxonomy: """ Creates, saves, and returns a new Taxonomy with the given attributes. - - If `taxonomy_class` not provided, then uses ContentTaxonomy. """ return oel_tagging.create_taxonomy( name=name, @@ -35,14 +32,13 @@ def create_taxonomy( required=required, allow_multiple=allow_multiple, allow_free_text=allow_free_text, - taxonomy_class=taxonomy_class, ) def set_taxonomy_orgs( taxonomy: Taxonomy, all_orgs=False, - orgs: List[Organization] = None, + orgs: list[Organization] = None, relationship: TaxonomyOrg.RelType = TaxonomyOrg.RelType.OWNER, ): """ @@ -81,7 +77,7 @@ def set_taxonomy_orgs( def get_taxonomies_for_org( enabled=True, - org_owner: Organization = None, + org_owner: Organization | None = None, ) -> QuerySet: """ Generates a list of the enabled Taxonomies available for the given org, sorted by name. @@ -94,32 +90,38 @@ def get_taxonomies_for_org( If you want the disabled Taxonomies, pass enabled=False. If you want all Taxonomies (both enabled and disabled), pass enabled=None. """ - taxonomies = oel_tagging.get_taxonomies(enabled=enabled) - return ContentTaxonomy.taxonomies_for_org( - org=org_owner, - queryset=taxonomies, + org_short_name = org_owner.short_name if org_owner else None + return oel_tagging.get_taxonomies(enabled=enabled).filter( + Exists( + TaxonomyOrg.get_relationships( + taxonomy=OuterRef("pk"), + rel_type=TaxonomyOrg.RelType.OWNER, + org_short_name=org_short_name, + ) + ) ) def get_content_tags( - object_id: str, taxonomy_id: str = None + object_key: ContentKey, + taxonomy_id: str | None = None, ) -> Iterator[ContentObjectTag]: """ Generates a list of content tags for a given object. Pass taxonomy to limit the returned object_tags to a specific taxonomy. """ - for object_tag in oel_tagging.get_object_tags( - object_id=object_id, + return oel_tagging.get_object_tags( + object_id=str(object_key), taxonomy_id=taxonomy_id, - ): - yield ContentObjectTag.cast(object_tag) + object_tag_class=ContentObjectTag, + ) def tag_content_object( + object_key: ContentKey, taxonomy: Taxonomy, tags: list, - object_id: CourseKey | UsageKey, ) -> list[ContentObjectTag]: """ This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or @@ -136,14 +138,18 @@ def tag_content_object( Raises ValueError if the proposed tags are invalid for this taxonomy. Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags. """ - content_tags = [] - for object_tag in oel_tagging.tag_object( + if not taxonomy.system_defined: + # We require that this taxonomy is linked to the content object's "org" or linked to "all orgs" (None): + org_short_name = object_key.org + if not taxonomy.taxonomyorg_set.filter(Q(org__short_name=org_short_name) | Q(org=None)).exists(): + raise ValueError(f"The specified Taxonomy is not enabled for the content object's org ({org_short_name})") + oel_tagging.tag_object( taxonomy=taxonomy, tags=tags, - object_id=str(object_id), - ): - content_tags.append(ContentObjectTag.cast(object_tag)) - return content_tags + object_id=str(object_key), + object_tag_class=ContentObjectTag, + ) + return get_content_tags(str(object_key), taxonomy_id=taxonomy.id) # Expose the oel_tagging APIs diff --git a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py index 78b0baa949..14a3e6ace1 100644 --- a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py +++ b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): 'indexes': [], 'constraints': [], }, - bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.usersystemdefinedtaxonomy'), + bases=('oel_tagging.usersystemdefinedtaxonomy', ), ), migrations.CreateModel( name='ContentLanguageTaxonomy', @@ -32,7 +32,7 @@ class Migration(migrations.Migration): 'indexes': [], 'constraints': [], }, - bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.languagetaxonomy'), + bases=('oel_tagging.languagetaxonomy', ), ), migrations.CreateModel( name='ContentOrganizationTaxonomy', @@ -43,7 +43,7 @@ class Migration(migrations.Migration): 'indexes': [], 'constraints': [], }, - bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.modelsystemdefinedtaxonomy'), + bases=('oel_tagging.modelsystemdefinedtaxonomy', ), ), migrations.CreateModel( name='OrganizationModelObjectTag', diff --git a/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py new file mode 100644 index 0000000000..7e8eb99ee7 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.21 on 2023-09-29 23:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_tagging', '0005_auto_20230830_1517'), + ] + + operations = [ + migrations.DeleteModel( + name='ContentAuthorTaxonomy', + ), + migrations.DeleteModel( + name='ContentLanguageTaxonomy', + ), + migrations.DeleteModel( + name='ContentTaxonomy', + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/models/__init__.py b/openedx/core/djangoapps/content_tagging/models/__init__.py index 4606c5b853..f330b8ae81 100644 --- a/openedx/core/djangoapps/content_tagging/models/__init__.py +++ b/openedx/core/djangoapps/content_tagging/models/__init__.py @@ -4,9 +4,4 @@ Content Tagging and System defined models from .base import ( TaxonomyOrg, ContentObjectTag, - ContentTaxonomy, -) -from .system_defined import ( - ContentLanguageTaxonomy, - ContentAuthorTaxonomy, ) diff --git a/openedx/core/djangoapps/content_tagging/models/base.py b/openedx/core/djangoapps/content_tagging/models/base.py index 38dcfb5b75..77061e4611 100644 --- a/openedx/core/djangoapps/content_tagging/models/base.py +++ b/openedx/core/djangoapps/content_tagging/models/base.py @@ -3,12 +3,12 @@ Content Tagging models """ from __future__ import annotations +from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Exists, OuterRef, Q, QuerySet +from django.db.models import Q, QuerySet from django.utils.translation import gettext as _ from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import LearningContextKey -from opaque_keys.edx.locator import BlockUsageLocator +from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy from organizations.models import Organization @@ -91,7 +91,7 @@ class ContentObjectTag(ObjectTag): proxy = True @property - def object_key(self) -> BlockUsageLocator | LearningContextKey: + def object_key(self) -> UsageKey | LearningContextKey: """ Returns the object ID parsed as a UsageKey or LearningContextKey. Raises InvalidKeyError object_id cannot be parse into one of those key types. @@ -99,75 +99,14 @@ class ContentObjectTag(ObjectTag): Returns None if there's no object_id. """ try: - return LearningContextKey.from_string(str(self.object_id)) + return LearningContextKey.from_string(self.object_id) except InvalidKeyError: - return BlockUsageLocator.from_string(str(self.object_id)) + return UsageKey.from_string(self.object_id) - -class ContentTaxonomyMixin: - """ - Taxonomy which can only tag Content objects (e.g. XBlocks or Courses) via ContentObjectTag. - - Also ensures a valid TaxonomyOrg owner relationship with the content object. - """ - - @classmethod - def taxonomies_for_org( - cls, - queryset: QuerySet, - org: Organization | None = None, - ) -> QuerySet: - """ - Filters the given QuerySet to those ContentTaxonomies which are available for the given organization. - - If no `org` is provided, then only ContentTaxonomies available to all organizations are returned. - If `org` is provided, then ContentTaxonomies available to this organizations are also returned. - """ - org_short_name = org.short_name if org else None - return queryset.filter( - Exists( - TaxonomyOrg.get_relationships( - taxonomy=OuterRef("pk"), - rel_type=TaxonomyOrg.RelType.OWNER, - org_short_name=org_short_name, - ) - ) - ) - - def _check_object(self, object_tag: ObjectTag) -> bool: - """ - Returns True if this ObjectTag has a valid object_id. - """ - content_tag = ContentObjectTag.cast(object_tag) + def clean(self): + super().clean() + # Make sure that object_id is a valid key try: - content_tag.object_key - except InvalidKeyError: - return False - return super()._check_object(content_tag) - - def _check_taxonomy(self, object_tag: ObjectTag) -> bool: - """ - Returns True if this taxonomy is owned by the tag's org. - """ - content_tag = ContentObjectTag.cast(object_tag) - try: - object_key = content_tag.object_key - except InvalidKeyError: - return False - if not TaxonomyOrg.get_relationships( - taxonomy=self, - rel_type=TaxonomyOrg.RelType.OWNER, - org_short_name=object_key.org, - ).exists(): - return False - return super()._check_taxonomy(content_tag) - - -class ContentTaxonomy(ContentTaxonomyMixin, Taxonomy): - """ - Taxonomy that accepts ContentTags, - and ensures a valid TaxonomyOrg owner relationship with the content object. - """ - - class Meta: - proxy = True + self.object_key + except InvalidKeyError as err: + raise ValidationError("object_id is not a valid opaque key string.") from err diff --git a/openedx/core/djangoapps/content_tagging/models/system_defined.py b/openedx/core/djangoapps/content_tagging/models/system_defined.py deleted file mode 100644 index 9948625455..0000000000 --- a/openedx/core/djangoapps/content_tagging/models/system_defined.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -System defined models -""" -from openedx_tagging.core.tagging.models import ( - UserSystemDefinedTaxonomy, - LanguageTaxonomy, -) - -from .base import ContentTaxonomyMixin - - -class ContentLanguageTaxonomy(ContentTaxonomyMixin, LanguageTaxonomy): - """ - Language system-defined taxonomy that accepts ContentTags - """ - - class Meta: - proxy = True - - -class ContentAuthorTaxonomy(ContentTaxonomyMixin, UserSystemDefinedTaxonomy): - """ - Author system-defined taxonomy that accepts ContentTags - """ - - class Meta: - proxy = True diff --git a/openedx/core/djangoapps/content_tagging/tasks.py b/openedx/core/djangoapps/content_tagging/tasks.py index 3ffa6c29f1..f28b6d156d 100644 --- a/openedx/core/djangoapps/content_tagging/tasks.py +++ b/openedx/core/djangoapps/content_tagging/tasks.py @@ -10,12 +10,13 @@ from celery_utils.logged_task import LoggedTask from django.conf import settings from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_tagging.core.tagging.models import Taxonomy from xmodule.modulestore.django import modulestore from . import api +from .types import ContentKey LANGUAGE_TAXONOMY_ID = -1 @@ -23,53 +24,32 @@ log = logging.getLogger(__name__) User = get_user_model() -def _has_taxonomy(taxonomy: Taxonomy, content_object: CourseKey | UsageKey) -> bool: - """ - Return True if this Taxonomy have some Tag set in the content_object - """ - _exausted = object() - - content_tags = api.get_content_tags(object_id=str(content_object), taxonomy_id=taxonomy.id) - return next(content_tags, _exausted) is not _exausted - - -def _set_initial_language_tag(content_object: CourseKey | UsageKey, lang: str) -> None: +def _set_initial_language_tag(content_key: ContentKey, lang_code: str) -> None: """ Create a tag for the language taxonomy in the content_object if it doesn't exist. + lang_code is the two-letter language code, optionally with country suffix. + If the language is not configured in the plataform or the language tag doesn't exist, use the default language of the platform. """ - lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) + lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID).cast() - if lang and not _has_taxonomy(lang_taxonomy, content_object): - tags = api.get_tags(lang_taxonomy) - is_language_configured = any(lang_code == lang for lang_code, _ in settings.LANGUAGES) is not None - if not is_language_configured: + if lang_code and not api.get_content_tags(object_key=content_key, taxonomy_id=lang_taxonomy.id).exists(): + try: + lang_tag = lang_taxonomy.tag_for_external_id(lang_code) + except api.oel_tagging.TagDoesNotExist: + default_lang_code = settings.LANGUAGE_CODE logging.warning( "Language not configured in the plataform: %s. Using default language: %s", - lang, - settings.LANGUAGE_CODE, + lang_code, + default_lang_code, ) - lang = settings.LANGUAGE_CODE - - lang_tag = next((tag for tag in tags if tag.external_id == lang), None) - if lang_tag is None: - if not is_language_configured: - logging.error( - "Language tag not found for default language: %s. Skipping", lang - ) - return - - logging.warning( - "Language tag not found for language: %s. Using default language: %s", lang, settings.LANGUAGE_CODE - ) - lang_tag = next(tag for tag in tags if tag.external_id == settings.LANGUAGE_CODE) - - api.tag_content_object(lang_taxonomy, [lang_tag.id], content_object) + lang_tag = lang_taxonomy.tag_for_external_id(default_lang_code) + api.tag_content_object(content_key, lang_taxonomy, [lang_tag.value]) -def _delete_tags(content_object: CourseKey | UsageKey) -> None: +def _delete_tags(content_object: ContentKey) -> None: api.delete_object_tags(str(content_object)) @@ -84,14 +64,14 @@ def update_course_tags(course_key_str: str) -> bool: course_key_str (str): identifier of the Course """ try: - course_key = CourseKey.from_string(course_key_str) + course_key = LearningContextKey.from_string(course_key_str) log.info("Updating tags for Course with id: %s", course_key) course = modulestore().get_course(course_key) if course: - lang = course.language - _set_initial_language_tag(course_key, lang) + lang_code = course.language + _set_initial_language_tag(course_key, lang_code) return True except Exception as e: # pylint: disable=broad-except @@ -109,7 +89,7 @@ def delete_course_tags(course_key_str: str) -> bool: course_key_str (str): identifier of the Course """ try: - course_key = CourseKey.from_string(course_key_str) + course_key = LearningContextKey.from_string(course_key_str) log.info("Deleting tags for Course with id: %s", course_key) @@ -140,11 +120,11 @@ def update_xblock_tags(usage_key_str: str) -> bool: course = modulestore().get_course(usage_key.course_key) if course is None: return True - lang = course.language + lang_code = course.language else: return True - _set_initial_language_tag(usage_key, lang) + _set_initial_language_tag(usage_key, lang_code) return True except Exception as e: # pylint: disable=broad-except diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py index 263ae761ef..c0595dffb6 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_api.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py @@ -2,7 +2,7 @@ import ddt from django.test.testcases import TestCase from opaque_keys.edx.keys import CourseKey, UsageKey -from openedx_tagging.core.tagging.models import ObjectTag, Tag +from openedx_tagging.core.tagging.models import Tag from organizations.models import Organization from .. import api @@ -65,59 +65,42 @@ class TestTaxonomyMixin: ) # ObjectTags self.all_orgs_course_tag = api.tag_content_object( + object_key=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), taxonomy=self.taxonomy_all_orgs, - tags=[self.tag_all_orgs.id], - object_id=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), + tags=[self.tag_all_orgs.value], )[0] self.all_orgs_block_tag = api.tag_content_object( - taxonomy=self.taxonomy_all_orgs, - tags=[self.tag_all_orgs.id], - object_id=UsageKey.from_string( + object_key=UsageKey.from_string( "block-v1:Ax+DemoX+Demo_Course+type@vertical+block@abcde" ), + taxonomy=self.taxonomy_all_orgs, + tags=[self.tag_all_orgs.value], )[0] self.both_orgs_course_tag = api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), taxonomy=self.taxonomy_both_orgs, - tags=[self.tag_both_orgs.id], - object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + tags=[self.tag_both_orgs.value], )[0] self.both_orgs_block_tag = api.tag_content_object( - taxonomy=self.taxonomy_both_orgs, - tags=[self.tag_both_orgs.id], - object_id=UsageKey.from_string( + object_key=UsageKey.from_string( "block-v1:OeX+DemoX+Demo_Course+type@video+block@abcde" ), + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.value], )[0] self.one_org_block_tag = api.tag_content_object( - taxonomy=self.taxonomy_one_org, - tags=[self.tag_one_org.id], - object_id=UsageKey.from_string( + object_key=UsageKey.from_string( "block-v1:OeX+DemoX+Demo_Course+type@html+block@abcde" ), + taxonomy=self.taxonomy_one_org, + tags=[self.tag_one_org.value], )[0] self.disabled_course_tag = api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), taxonomy=self.taxonomy_disabled, - tags=[self.tag_disabled.id], - object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + tags=[self.tag_disabled.value], )[0] - # Invalid object tags must be manually created - self.all_orgs_invalid_tag = ObjectTag.objects.create( - taxonomy=self.taxonomy_all_orgs, - tag=self.tag_all_orgs, - object_id="course-v1_OpenedX_DemoX_Demo_Course", - ) - self.one_org_invalid_org_tag = ObjectTag.objects.create( - taxonomy=self.taxonomy_one_org, - tag=self.tag_one_org, - object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde", - ) - self.no_orgs_invalid_tag = ObjectTag.objects.create( - taxonomy=self.taxonomy_no_orgs, - tag=self.tag_no_orgs, - object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), - ) - @ddt.ddt class TestAPITaxonomy(TestTaxonomyMixin, TestCase): @@ -190,11 +173,11 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase): ): taxonomy_id = getattr(self, taxonomy_attr).id object_tag = getattr(self, object_tag_attr) - with self.assertNumQueries(2): + with self.assertNumQueries(1): valid_tags = list( api.get_content_tags( + object_key=object_tag.object_key, taxonomy_id=taxonomy_id, - object_id=object_tag.object_id, ) ) assert len(valid_tags) == 1 @@ -204,38 +187,55 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase): ("taxonomy_disabled", "disabled_course_tag"), ("taxonomy_all_orgs", "all_orgs_course_tag"), ("taxonomy_all_orgs", "all_orgs_block_tag"), - ("taxonomy_all_orgs", "all_orgs_invalid_tag"), ("taxonomy_both_orgs", "both_orgs_course_tag"), ("taxonomy_both_orgs", "both_orgs_block_tag"), ("taxonomy_one_org", "one_org_block_tag"), - ("taxonomy_one_org", "one_org_invalid_org_tag"), ) @ddt.unpack - def test_get_content_tags_include_invalid( + def test_get_content_tags( self, taxonomy_attr, object_tag_attr, ): taxonomy_id = getattr(self, taxonomy_attr).id object_tag = getattr(self, object_tag_attr) - with self.assertNumQueries(2): + with self.assertNumQueries(1): valid_tags = list( api.get_content_tags( + object_key=object_tag.object_key, taxonomy_id=taxonomy_id, - object_id=object_tag.object_id, ) ) assert len(valid_tags) == 1 assert valid_tags[0].id == object_tag.id - @ddt.data( - "all_orgs_invalid_tag", - "one_org_invalid_org_tag", - "no_orgs_invalid_tag", - ) - def test_object_tag_not_valid_check_object(self, tag_attr): - object_tag = getattr(self, tag_attr) - assert not object_tag.is_valid() - def test_get_tags(self): assert api.get_tags(self.taxonomy_all_orgs) == [self.tag_all_orgs] + + def test_cannot_tag_across_orgs(self): + """ + Ensure that I cannot apply tags from a taxonomy that's linked to another + org. + """ + # This taxonomy is only linked to the "OpenedX org", so it can't be used for "Axim" content. + taxonomy = self.taxonomy_one_org + tags = [self.tag_one_org.value] + with self.assertRaises(ValueError) as exc: + api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + taxonomy=taxonomy, + tags=tags, + ) + assert "The specified Taxonomy is not enabled for the content object's org (Ax)" in str(exc.exception) + # But this will work fine: + api.tag_content_object( + object_key=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), + taxonomy=taxonomy, + tags=tags, + ) + # As will this: + api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.value], + ) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_models.py b/openedx/core/djangoapps/content_tagging/tests/test_models.py deleted file mode 100644 index 81c5da8641..0000000000 --- a/openedx/core/djangoapps/content_tagging/tests/test_models.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Test for Content models -""" -import ddt -from django.test.testcases import TestCase - -from openedx_tagging.core.tagging.models import ( - ObjectTag, - Tag, -) -from openedx_tagging.core.tagging.api import create_taxonomy -from ..models import ( - ContentLanguageTaxonomy, - ContentAuthorTaxonomy, -) - - -@ddt.ddt -class TestSystemDefinedModels(TestCase): - """ - Test for System defined models - """ - - @ddt.data( - (ContentLanguageTaxonomy, "taxonomy"), # Invalid object key - (ContentLanguageTaxonomy, "tag"), # Invalid external_id, invalid language - (ContentLanguageTaxonomy, "object"), # Invalid object key - (ContentAuthorTaxonomy, "taxonomy"), # Invalid object key - (ContentAuthorTaxonomy, "tag"), # Invalid external_id, User don't exits - (ContentAuthorTaxonomy, "object"), # Invalid object key - ) - @ddt.unpack - def test_validations( - self, - taxonomy_cls, - check, - ): - """ - Test that the respective validations are being called - """ - taxonomy = create_taxonomy( - name='Test taxonomy', - taxonomy_class=taxonomy_cls, - ) - - tag = Tag( - value="value", - external_id="external_id", - taxonomy=taxonomy, - ) - tag.save() - - object_tag = ObjectTag( - object_id='object_id', - taxonomy=taxonomy, - tag=tag, - ) - - check_taxonomy = check == 'taxonomy' - check_object = check == 'object' - check_tag = check == 'tag' - assert not taxonomy.validate_object_tag( - object_tag=object_tag, - check_taxonomy=check_taxonomy, - check_object=check_object, - check_tag=check_tag, - ) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_rules.py b/openedx/core/djangoapps/content_tagging/tests/test_rules.py index 442fda5a71..013b822558 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_rules.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_rules.py @@ -3,7 +3,6 @@ import ddt from django.contrib.auth import get_user_model from django.test.testcases import TestCase, override_settings -from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from openedx_tagging.core.tagging.models import ( Tag, @@ -161,19 +160,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): object_id=str(self.course2), ) - self.all_orgs_invalid_tag_perm = ChangeObjectTagPermissionItem( - taxonomy=self.taxonomy_all_orgs, - object_id="course-v1_OpenedX_DemoX_Demo_Course", - ) - self.one_org_invalid_org_tag_perm = ChangeObjectTagPermissionItem( - taxonomy=self.taxonomy_one_org, - object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde", - ) - self.no_orgs_invalid_tag_perm = ChangeObjectTagPermissionItem( - taxonomy=self.taxonomy_no_orgs, - object_id=str(self.course1), - ) - self.all_org_perms = ( self.tax_all_course1, self.tax_all_course2, @@ -544,30 +530,12 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert not self.user_org2.has_perm(perm, perm_item) assert not self.learner.has_perm(perm, perm_item) - @ddt.data( - - ("oel_tagging.add_object_tag", "one_org_invalid_org_tag_perm"), - ("oel_tagging.add_object_tag", "all_orgs_invalid_tag_perm"), - ("oel_tagging.change_object_tag", "one_org_invalid_org_tag_perm"), - ("oel_tagging.change_object_tag", "all_orgs_invalid_tag_perm"), - ("oel_tagging.delete_object_tag", "one_org_invalid_org_tag_perm"), - ("oel_tagging.delete_object_tag", "all_orgs_invalid_tag_perm"), - ) - @ddt.unpack - def test_change_object_tag_invalid_key(self, perm, tag_attr): - perm_item = getattr(self, tag_attr) - with self.assertRaises(InvalidKeyError): - assert self.staff.has_perm(perm, perm_item) - @ddt.data( "all_orgs_course_tag", "all_orgs_block_tag", "both_orgs_course_tag", "both_orgs_block_tag", "one_org_block_tag", - "all_orgs_invalid_tag", - "one_org_invalid_org_tag", - "no_orgs_invalid_tag", "disabled_course_tag", ) def test_view_object_tag(self, tag_attr): diff --git a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py index c1c45e9449..f3c964b836 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py @@ -8,7 +8,7 @@ from unittest.mock import patch from django.core.management import call_command from django.test import override_settings from edx_toggles.toggles.testutils import override_waffle_flag -from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from openedx_tagging.core.tagging.models import LanguageTaxonomy, Tag, Taxonomy from organizations.models import Organization from common.djangoapps.student.tests.factories import UserFactory @@ -16,8 +16,9 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from .. import api -from ..models import ContentLanguageTaxonomy, TaxonomyOrg +from ..models import TaxonomyOrg from ..toggles import CONTENT_TAGGING_AUTO +from ..types import ContentKey LANGUAGE_TAXONOMY_ID = -1 @@ -31,13 +32,16 @@ class TestAutoTagging(ModuleStoreTestCase): MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - def _check_tag(self, object_id: str, taxonomy_id: int, value: str | None): + def _check_tag(self, object_key: ContentKey, taxonomy_id: int, value: str | None): """ Check if the ObjectTag exists for the given object_id and taxonomy_id If value is None, check if the ObjectTag does not exists """ - object_tag = ObjectTag.objects.filter(object_id=object_id, taxonomy_id=taxonomy_id).first() + object_tags = api.get_content_tags(object_key, taxonomy_id=taxonomy_id) + object_tag = object_tags[0] if len(object_tags) == 1 else None + if len(object_tags) > 1: + raise ValueError("Found too many object tags") if value is None: assert not object_tag, f"Expected no tag for taxonomy_id={taxonomy_id}, " \ f"but one found with value={object_tag.value}" @@ -52,13 +56,9 @@ class TestAutoTagging(ModuleStoreTestCase): # Run fixtures to create the system defined tags call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml") - # Configure language taxonomy - language_taxonomy = Taxonomy.objects.get(id=-1) - language_taxonomy.taxonomy_class = ContentLanguageTaxonomy - language_taxonomy.save() - # Enable Language taxonomy for all orgs - TaxonomyOrg.objects.create(id=-1, taxonomy=language_taxonomy, org=None) + language_taxonomy = LanguageTaxonomy.objects.get(id=LANGUAGE_TAXONOMY_ID) + TaxonomyOrg.objects.create(taxonomy=language_taxonomy, org=None) super().setUpClass() @@ -80,13 +80,13 @@ class TestAutoTagging(ModuleStoreTestCase): "test_course", "test_run", self.user_id, - fields={"language": "pt"}, + fields={"language": "pl"}, ) # Check if the tags are created in the Course - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Polski") - @override_settings(LANGUAGE_CODE='pt') + @override_settings(LANGUAGE_CODE='pt-br') def test_create_course_invalid_language(self): # Create course course = self.store.create_course( @@ -98,9 +98,9 @@ class TestAutoTagging(ModuleStoreTestCase): ) # Check if the tags are created in the Course is the system default - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Português (Brasil)") - @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_CODE='pt') + @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_DICT={'pt': 'Portuguese'}, LANGUAGE_CODE='pt') def test_create_course_unsuported_language(self): # Create course course = self.store.create_course( @@ -114,22 +114,6 @@ class TestAutoTagging(ModuleStoreTestCase): # Check if the tags are created in the Course is the system default assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") - @override_settings(LANGUAGE_CODE='pt') - def test_create_course_no_tag_language(self): - # Remove English tag - Tag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID, value="English").delete() - # Create course - course = self.store.create_course( - self.orgA.short_name, - "test_course", - "test_run", - self.user_id, - fields={"language": "en"}, - ) - - # Check if the tags are created in the Course is the system default - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") - @override_settings(LANGUAGE_CODE='pt') def test_create_course_no_tag_default_language(self): # Remove Portuguese tag @@ -153,19 +137,19 @@ class TestAutoTagging(ModuleStoreTestCase): "test_course", "test_run", self.user_id, - fields={"language": "pt"}, + fields={"language": "pt-br"}, ) # Simulates user manually changing a tag lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) - api.tag_content_object(lang_taxonomy, ["Spanish"], course.id) + api.tag_content_object(course.id, lang_taxonomy, ["Español (España)"]) # Update course language course.language = "en" self.store.update_item(course, self.user_id) # Does not automatically update the tag - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Spanish") + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Español (España)") def test_create_delete_xblock(self): # Create course @@ -174,7 +158,7 @@ class TestAutoTagging(ModuleStoreTestCase): "test_course", "test_run", self.user_id, - fields={"language": "pt"}, + fields={"language": "pt-br"}, ) # Create XBlocks @@ -184,7 +168,7 @@ class TestAutoTagging(ModuleStoreTestCase): usage_key_str = str(vertical.location) # Check if the tags are created in the XBlock - assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Portuguese") + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Português (Brasil)") # Delete the XBlock self.store.delete_item(vertical.location, self.user_id) diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py new file mode 100644 index 0000000000..3a9f6ec549 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -0,0 +1,8 @@ +""" +Types used by content tagging API and implementation +""" +from typing import Union + +from opaque_keys.edx.keys import LearningContextKey, UsageKey + +ContentKey = Union[LearningContextKey, UsageKey] diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 7f0643c045..bae9a5be22 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -121,7 +121,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.1.7 +openedx-learning==0.2.0 # lti-consumer-xblock 9.6.2 contains a breaking change that makes # existing custom parameter configurations unusable. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 192f932843..e2e9014d32 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -783,7 +783,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.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 07d3d9ae09..2c95eabd18 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1316,7 +1316,7 @@ openedx-filters==1.6.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.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 70c15bc738..b636f128f4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -923,7 +923,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 235918f555..c8a9dc1131 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -990,7 +990,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From db252978f3f315c896b07443f70febdc043faee4 Mon Sep 17 00:00:00 2001 From: alangsto <46360176+alangsto@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:54:35 -0400 Subject: [PATCH 11/86] feat: receiver for verified exam event (#33390) --- lms/djangoapps/grades/signals/handlers.py | 24 ++++- lms/djangoapps/grades/tests/test_handlers.py | 103 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/grades/tests/test_handlers.py diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index af33279515..15741197aa 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -8,6 +8,7 @@ from logging import getLogger from django.dispatch import receiver from opaque_keys.edx.keys import LearningContextKey +from openedx_events.learning.signals import EXAM_ATTEMPT_VERIFIED from submissions.models import score_reset, score_set from xblock.scorable import ScorableXBlockMixin, Score @@ -25,7 +26,7 @@ from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERS from openedx.core.lib.grade_utils import is_score_higher_or_equal from .. import events -from ..constants import ScoreDatabaseTableEnum +from ..constants import GradeOverrideFeatureEnum, ScoreDatabaseTableEnum from ..course_grade_factory import CourseGradeFactory from ..scores import weighted_score from .signals import ( @@ -122,6 +123,10 @@ def submissions_score_reset_handler(sender, **kwargs): # pylint: disable=unused def disconnect_submissions_signal_receiver(signal): """ Context manager to be used for temporarily disconnecting edx-submission's set or reset signal. + + Clear Student State on ORA problems currently results in a set->reset signal pair getting fired + from submissions which leads to tasks being enqueued, one of which can never succeed. This context manager + fixes the issue by disconnecting the "set" handler during the clear_state operation. """ if signal == score_set: handler = submissions_score_set_handler @@ -300,3 +305,20 @@ def listen_for_course_grade_passed_first_time(sender, user_id, course_id, **kwar """ events.course_grade_passed_first_time(user_id, course_id) events.fire_segment_event_on_course_grade_passed_first_time(user_id, course_id) + + +@receiver(EXAM_ATTEMPT_VERIFIED) +def exam_attempt_verified_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Consume `EXAM_ATTEMPT_VERIFIED` events from the event bus. This will trigger + an undo section override, if one exists. + """ + from ..api import should_override_grade_on_rejected_exam, undo_override_subsection_grade + + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + course_key = event_data.course_key + usage_key = event_data.usage_key + + if should_override_grade_on_rejected_exam(course_key): + undo_override_subsection_grade(user_data.id, course_key, usage_key, GradeOverrideFeatureEnum.proctoring) diff --git a/lms/djangoapps/grades/tests/test_handlers.py b/lms/djangoapps/grades/tests/test_handlers.py new file mode 100644 index 0000000000..42ad9aa1bf --- /dev/null +++ b/lms/djangoapps/grades/tests/test_handlers.py @@ -0,0 +1,103 @@ +""" +Tests for the grades handlers +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +import ddt +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_VERIFIED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.grades.signals.handlers import exam_attempt_verified_event_handler +from ..constants import GradeOverrideFeatureEnum + + +@ddt.ddt +class ExamCompletionEventBusTests(TestCase): + """ + Tests for exam events from the event bus + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.usage_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + requesting_user=requesting_user_data, + exam_type=exam_type, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @ddt.data( + True, + False + ) + @mock.patch('lms.djangoapps.grades.api.should_override_grade_on_rejected_exam') + @mock.patch('lms.djangoapps.grades.api.undo_override_subsection_grade') + def test_exam_attempt_verified_event_handler(self, override_enabled, mock_undo_override, mock_should_override): + mock_should_override.return_value = override_enabled + + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_VERIFIED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + exam_attempt_verified_event_handler(None, EXAM_ATTEMPT_VERIFIED, ** event_kwargs) + + if override_enabled: + mock_undo_override.assert_called_once_with( + self.student_user.id, + self.course_key, + self.usage_key, + GradeOverrideFeatureEnum.proctoring + ) + else: + mock_undo_override.assert_not_called() From b4cb93ede73293bb51f807496404b5c0a3819410 Mon Sep 17 00:00:00 2001 From: michaelroytman Date: Tue, 3 Oct 2023 17:02:21 -0400 Subject: [PATCH 12/86] feat: add grades event bus event handler for rejected special exam This commit adds an event bus event handler to the grades application. This event handler handles the EXAM_ATTEMPT_REJECTED Open edX event, which is emitted when a learner's exam attempt is rejected. The event handler creates a subsection grade override, overriding the grade to 0.0. --- lms/djangoapps/grades/signals/handlers.py | 27 +++++++++++++++- lms/djangoapps/grades/tests/test_handlers.py | 34 ++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index 15741197aa..58c5fb90aa 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -8,7 +8,7 @@ from logging import getLogger from django.dispatch import receiver from opaque_keys.edx.keys import LearningContextKey -from openedx_events.learning.signals import EXAM_ATTEMPT_VERIFIED +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED from submissions.models import score_reset, score_set from xblock.scorable import ScorableXBlockMixin, Score @@ -322,3 +322,28 @@ def exam_attempt_verified_event_handler(sender, signal, **kwargs): # pylint: di if should_override_grade_on_rejected_exam(course_key): undo_override_subsection_grade(user_data.id, course_key, usage_key, GradeOverrideFeatureEnum.proctoring) + + +@receiver(EXAM_ATTEMPT_REJECTED) +def exam_attempt_rejected_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Consume `EXAM_ATTEMPT_REJECTED` events from the event bus. This will trigger a subsection override. + """ + from ..api import override_subsection_grade + + event_data = kwargs.get('exam_attempt') + override_grade_value = 0.0 + user_data = event_data.student_user + course_key = event_data.course_key + usage_key = event_data.usage_key + + override_subsection_grade( + user_data.id, + course_key, + usage_key, + earned_all=override_grade_value, + earned_graded=override_grade_value, + feature=GradeOverrideFeatureEnum.proctoring, + overrider=None, + comment=None, + ) diff --git a/lms/djangoapps/grades/tests/test_handlers.py b/lms/djangoapps/grades/tests/test_handlers.py index 42ad9aa1bf..72424987b5 100644 --- a/lms/djangoapps/grades/tests/test_handlers.py +++ b/lms/djangoapps/grades/tests/test_handlers.py @@ -10,10 +10,13 @@ from django.test import TestCase from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_events.data import EventsMetadata from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData -from openedx_events.learning.signals import EXAM_ATTEMPT_VERIFIED +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.grades.signals.handlers import exam_attempt_verified_event_handler +from lms.djangoapps.grades.signals.handlers import ( + exam_attempt_rejected_event_handler, + exam_attempt_verified_event_handler +) from ..constants import GradeOverrideFeatureEnum @@ -101,3 +104,30 @@ class ExamCompletionEventBusTests(TestCase): ) else: mock_undo_override.assert_not_called() + + @mock.patch('lms.djangoapps.grades.api.override_subsection_grade') + def test_exam_attempt_rejected_event_handler(self, mock_override): + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + exam_attempt_rejected_event_handler(None, EXAM_ATTEMPT_REJECTED, ** event_kwargs) + + override_grade_value = 0.0 + + mock_override.assert_called_once_with( + self.student_user.id, + self.course_key, + self.usage_key, + earned_all=override_grade_value, + earned_graded=override_grade_value, + feature=GradeOverrideFeatureEnum.proctoring, + overrider=None, + comment=None, + ) From 2ae07387b2ffc1d820a24b83327df6ffc423fb87 Mon Sep 17 00:00:00 2001 From: Isaac Lee <124631592+ilee2u@users.noreply.github.com> Date: Thu, 5 Oct 2023 15:39:14 -0400 Subject: [PATCH 13/86] feat: receiver for invalidate certificate (#33319) * feat: receiver for invalidate certificate - consumes event of exam attempt rejected - initial commit, need to make tests * temp: moving consumer from signals to handlers.py - Still need to make this work - Need to make tests work too * feat: refactored underlying code to api.py - tests still need to be tweaked * fix: commit history * fix: improve api func name + add source param --- lms/djangoapps/certificates/api.py | 33 +++++++ lms/djangoapps/certificates/handlers.py | 28 ++++++ lms/djangoapps/certificates/services.py | 39 +-------- .../certificates/tests/test_handlers.py | 87 +++++++++++++++++++ 4 files changed, 152 insertions(+), 35 deletions(-) create mode 100644 lms/djangoapps/certificates/handlers.py create mode 100644 lms/djangoapps/certificates/tests/test_handlers.py diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index c76e34a95b..c93bc85a42 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -49,6 +49,8 @@ from lms.djangoapps.certificates.utils import ( certificate_status_for_student as _certificate_status_for_student, ) from lms.djangoapps.instructor import access +from lms.djangoapps.utils import _get_key +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -920,3 +922,34 @@ def _has_passed_or_is_allowlisted(course, student, course_grade): has_passed = course_grade and course_grade.passed return has_passed or is_allowlisted + + +def invalidate_certificate(user_id, course_key_or_id, source): + """ + Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this + course run. + + This function is called in services.py and handlers.py within the certificates folder. As of now, + The call in services.py occurs when an exam attempt is rejected in the legacy exams backend, edx-proctoring. + The call in handlers.py is occurs when an exam attempt is rejected in the newer exams backend, edx-exams. + """ + course_key = _get_key(course_key_or_id, CourseKey) + if _is_on_certificate_allowlist(user_id, course_key): + log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.') + return False + + try: + generated_certificate = GeneratedCertificate.objects.get( + user=user_id, + course_id=course_key + ) + generated_certificate.invalidate(source=source) + except ObjectDoesNotExist: + log.warning( + 'Invalidation failed because a certificate for user %d in course %s does not exist.', + user_id, + course_key + ) + return False + + return True diff --git a/lms/djangoapps/certificates/handlers.py b/lms/djangoapps/certificates/handlers.py new file mode 100644 index 0000000000..8d45468497 --- /dev/null +++ b/lms/djangoapps/certificates/handlers.py @@ -0,0 +1,28 @@ +""" +Handlers for credits +""" +import logging + +from django.contrib.auth import get_user_model +from django.dispatch import receiver +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED + +from lms.djangoapps.certificates.api import invalidate_certificate + +User = get_user_model() + +log = logging.getLogger(__name__) + + +@receiver(EXAM_ATTEMPT_REJECTED) +def handle_exam_attempt_rejected_event(sender, signal, **kwargs): + """ + Consume `EXAM_ATTEMPT_REJECTED` events from the event bus. + Pass the received data to invalidate_certificate in the services.py file in this folder. + """ + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + course_key = event_data.course_key + + # Note that the course_key is the same as the course_key_or_id, and is being passed in as the course_key param + invalidate_certificate(user_data.id, course_key, source='exam_event') diff --git a/lms/djangoapps/certificates/services.py b/lms/djangoapps/certificates/services.py index 29ee5a05d3..508bb3ad6d 100644 --- a/lms/djangoapps/certificates/services.py +++ b/lms/djangoapps/certificates/services.py @@ -2,17 +2,7 @@ Certificate service """ - -import logging - -from django.core.exceptions import ObjectDoesNotExist -from opaque_keys.edx.keys import CourseKey - -from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist -from lms.djangoapps.certificates.models import GeneratedCertificate -from lms.djangoapps.utils import _get_key - -log = logging.getLogger(__name__) +from lms.djangoapps.certificates.api import invalidate_certificate class CertificateService: @@ -21,27 +11,6 @@ class CertificateService: """ def invalidate_certificate(self, user_id, course_key_or_id): - """ - Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this - course run. - """ - course_key = _get_key(course_key_or_id, CourseKey) - if is_on_certificate_allowlist(user_id, course_key): - log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.') - return False - - try: - generated_certificate = GeneratedCertificate.objects.get( - user=user_id, - course_id=course_key - ) - generated_certificate.invalidate(source='certificate_service') - except ObjectDoesNotExist: - log.warning( - 'Invalidation failed because a certificate for user %d in course %s does not exist.', - user_id, - course_key - ) - return False - - return True + # The original code for this function was moved to this helper function to be call-able + # By both the legacy and current exams backends (edx-proctoring and edx-exams). + return invalidate_certificate(user_id, course_key_or_id, source='certificate_service') diff --git a/lms/djangoapps/certificates/tests/test_handlers.py b/lms/djangoapps/certificates/tests/test_handlers.py new file mode 100644 index 0000000000..241d9500bd --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_handlers.py @@ -0,0 +1,87 @@ +""" +Unit tests for certificates signals +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.certificates.handlers import handle_exam_attempt_rejected_event + + +class ExamCompletionEventBusTests(TestCase): + """ + Tests completion events from the event bus. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.usage_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + requesting_user=requesting_user_data, + exam_type=exam_type, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @mock.patch('lms.djangoapps.certificates.handlers.invalidate_certificate') + def test_exam_attempt_rejected_event(self, mock_api_function): + """ + Assert that CertificateService api's invalidate_certificate is called upon consuming the event + """ + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + handle_exam_attempt_rejected_event(None, EXAM_ATTEMPT_REJECTED, **event_kwargs) + mock_api_function.assert_called_once_with(self.student_user.id, self.course_key, source='exam_event') From a59d7e5381dde999f1b189da3b63c38d75865422 Mon Sep 17 00:00:00 2001 From: jszewczulak <128841175+jszewczulak@users.noreply.github.com> Date: Thu, 5 Oct 2023 17:49:52 -0400 Subject: [PATCH 14/86] chore: ORA bump to 5.5.4 (#33426) --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index e2e9014d32..7d45b92ca3 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -791,7 +791,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==5.5.3 +ora2==5.5.4 # via -r requirements/edx/bundled.in oscrypto==1.3.0 # via snowflake-connector-python diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 2c95eabd18..4f904772ec 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1329,7 +1329,7 @@ optimizely-sdk==4.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==5.5.3 +ora2==5.5.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index b636f128f4..da21782626 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -931,7 +931,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 # via -r requirements/edx/base.txt -ora2==5.5.3 +ora2==5.5.4 # via -r requirements/edx/base.txt oscrypto==1.3.0 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index c8a9dc1131..781a353565 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -998,7 +998,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 # via -r requirements/edx/base.txt -ora2==5.5.3 +ora2==5.5.4 # via -r requirements/edx/base.txt oscrypto==1.3.0 # via From 29c83ca56b33053e8d3f825566a76a179630adf9 Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:27:22 +0500 Subject: [PATCH 15/86] feat: notifications and preferences will be created in batches (#33418) --- lms/envs/common.py | 1 + .../notifications/base_notification.py | 13 +++ .../core/djangoapps/notifications/models.py | 10 +- .../core/djangoapps/notifications/tasks.py | 72 +++++++----- .../notifications/tests/test_tasks.py | 108 ++++++++++++++++++ .../core/djangoapps/notifications/utils.py | 9 ++ 6 files changed, 181 insertions(+), 32 deletions(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 6c78327d07..3a6f8d95d0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5380,6 +5380,7 @@ SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker' ############## NOTIFICATIONS EXPIRY ############## NOTIFICATIONS_EXPIRY = 60 EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000 +NOTIFICATION_CREATION_BATCH_SIZE = 99 #### django-simple-history## # disable indexing on date field its coming from django-simple-history. diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index ec3018c007..d7d5f58574 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -322,3 +322,16 @@ def get_notification_content(notification_type, context): if notification_type_content_template: return notification_type_content_template.format(**context, **html_tags_context) return '' + + +def get_default_values_of_preference(notification_app, notification_type): + """ + Returns default preference for notification_type + """ + default_prefs = NotificationAppManager().get_notification_app_preferences() + app_prefs = default_prefs.get(notification_app, {}) + core_notification_types = app_prefs.get('core_notification_types', []) + notification_types = app_prefs.get('notification_types', {}) + if notification_type in core_notification_types: + return notification_types.get('core', {}) + return notification_types.get(notification_type, {}) diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index d0f8daf0e5..66bd3a109b 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -130,12 +130,12 @@ class CourseNotificationPreference(TimeStampedModel): return f'{self.user.username} - {self.course_id}' @staticmethod - def get_updated_user_course_preferences(user, course_id): + def get_user_course_preference(user_id, course_id): """ Returns updated courses preferences for a user """ preferences, _ = CourseNotificationPreference.objects.get_or_create( - user=user, + user_id=user_id, course_id=course_id, is_active=True, ) @@ -149,9 +149,13 @@ class CourseNotificationPreference(TimeStampedModel): preferences.save() # pylint: disable-next=broad-except except Exception as e: - log.error(f'Unable to update notification preference for {user.username} to new config. {e}') + log.error(f'Unable to update notification preference to new config. {e}') return preferences + @staticmethod + def get_updated_user_course_preferences(user, course_id): + return CourseNotificationPreference.get_user_course_preference(user.id, course_id) + def get_app_config(self, app_name) -> Dict: """ Returns the app config for the given app name. diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index e59ff77eb1..1401a2d9ab 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -13,6 +13,7 @@ from opaque_keys.edx.keys import CourseKey from pytz import UTC from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.notifications.base_notification import get_default_values_of_preference 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.models import ( @@ -20,6 +21,7 @@ from openedx.core.djangoapps.notifications.models import ( Notification, get_course_notification_preference_config_version ) +from openedx.core.djangoapps.notifications.utils import get_list_in_batches logger = get_task_logger(__name__) @@ -90,49 +92,61 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c if not ENABLE_NOTIFICATIONS.is_enabled(course_key): return user_ids = list(set(user_ids)) + batch_size = settings.NOTIFICATION_CREATION_BATCH_SIZE - # check if what is preferences of user and make decision to send notification or not - preferences = CourseNotificationPreference.objects.filter( - user_id__in=user_ids, - course_id=course_key, - ) - preferences = create_notification_pref_if_not_exists(user_ids, list(preferences), course_key) - notifications = [] audience = [] - for preference in preferences: - preference = update_user_preference(preference, preference.user, course_key) - if ( - preference and - preference.get_web_config(app_name, notification_type) and - preference.get_app_config(app_name).get('enabled', False) - ): - notifications.append( - Notification( - user_id=preference.user_id, - app_name=app_name, - notification_type=notification_type, - content_context=context, - content_url=content_url, - course_id=course_key, + notifications_generated = False + notification_content = '' + default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', True) + for batch_user_ids in get_list_in_batches(user_ids, batch_size): + # check if what is preferences of user and make decision to send notification or not + preferences = CourseNotificationPreference.objects.filter( + user_id__in=batch_user_ids, + course_id=course_key, + ) + preferences = list(preferences) + + if default_web_config: + preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key) + + notifications = [] + for preference in preferences: + preference = update_user_preference(preference, preference.user_id, course_key) + if ( + preference and + preference.get_web_config(app_name, notification_type) and + preference.get_app_config(app_name).get('enabled', False) + ): + notifications.append( + Notification( + user_id=preference.user_id, + app_name=app_name, + notification_type=notification_type, + content_context=context, + content_url=content_url, + course_id=course_key, + ) ) - ) - audience.append(preference.user_id) - # send notification to users but use bulk_create - notifications_generated = Notification.objects.bulk_create(notifications) + audience.append(preference.user_id) + # send notification to users but use bulk_create + notification_objects = Notification.objects.bulk_create(notifications) + if notification_objects and not notifications_generated: + notifications_generated = True + notification_content = notification_objects[0].content + if notifications_generated: - notification_content = notifications_generated[0].content notification_generated_event( audience, app_name, notification_type, course_key, content_url, notification_content, ) -def update_user_preference(preference: CourseNotificationPreference, user, course_id): +def update_user_preference(preference: CourseNotificationPreference, user_id, course_id): """ Update user preference if config version is changed. """ current_version = get_course_notification_preference_config_version() if preference.config_version != current_version: - return preference.get_updated_user_course_preferences(user, course_id) + return preference.get_user_course_preference(user_id, course_id) return preference diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py index 51cdc741d6..3164f9a9ba 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py @@ -5,8 +5,10 @@ Tests for notifications tasks. from unittest.mock import patch import ddt +from django.conf import settings from edx_toggles.toggles.testutils import override_waffle_flag +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -159,3 +161,109 @@ class SendNotificationsTest(ModuleStoreTestCase): # Assert that `Notification` objects are not created for the users. notification = Notification.objects.filter(user_id=self.user.id).first() self.assertIsNone(notification) + + +@ddt.ddt +class SendBatchNotificationsTest(ModuleStoreTestCase): + """ + Test that notification and notification preferences are created in batches + """ + def setUp(self): + """ + Setups test case + """ + super().setUp() + self.course = CourseFactory.create( + org='test_org', + number='test_course', + run='test_run' + ) + + def _create_users(self, num_of_users): + """ + Create users and enroll them in course + """ + users = [ + UserFactory.create(username=f'user{i}', email=f'user{i}@example.com') + for i in range(num_of_users) + ] + for user in users: + CourseEnrollment.enroll(user=user, course_key=self.course.id) + return users + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @ddt.data( + (settings.NOTIFICATION_CREATION_BATCH_SIZE, 1, 2), + (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 2, 4), + (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 1, 2), + ) + @ddt.unpack + def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): + """ + Tests notifications and notification preferences are created in batches + """ + notification_app = "discussion" + notification_type = "new_discussion_post" + users = self._create_users(creation_size) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "username": "Test Author" + } + + # Creating preferences and asserting query count + with self.assertNumQueries(prefs_query_count): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + # Updating preferences for notification creation + preferences = CourseNotificationPreference.objects.filter( + user_id__in=user_ids, + course_id=self.course.id + ) + for preference in preferences: + discussion_config = preference.notification_preference_config['discussion'] + discussion_config['notification_types'][notification_type]['web'] = True + preference.save() + + # Creating notifications and asserting query count + with self.assertNumQueries(notifications_query_count): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def test_preference_not_created_for_default_off_preference(self): + """ + Tests if new preferences are NOT created when default preference for + notification type is False + """ + notification_app = "discussion" + notification_type = "new_discussion_post" + users = self._create_users(20) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "username": "Test Author" + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): + with self.assertNumQueries(1): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def test_preference_created_for_default_on_preference(self): + """ + Tests if new preferences are created when default preference for + notification type is True + """ + notification_app = "discussion" + notification_type = "new_comment" + users = self._create_users(20) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "author_name": "Test Author", + "replier_name": "Replier Name" + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): + with self.assertNumQueries(3): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index 4ee3a614a7..39bf8755da 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -42,3 +42,12 @@ def get_show_notifications_tray(user): break return show_notifications_tray + + +def get_list_in_batches(input_list, batch_size): + """ + Divides the list of objects into list of list of objects each of length batch_size. + """ + list_length = len(input_list) + for index in range(0, list_length, batch_size): + yield input_list[index: index + batch_size] From e3df004989cee0dedb52395c18b7973498870bd8 Mon Sep 17 00:00:00 2001 From: saleem-latif Date: Fri, 6 Oct 2023 10:42:22 +0000 Subject: [PATCH 16/86] feat: Upgrade Python dependency edx-enterprise edx-enterprise version upgrade to deploy a fix. 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 bae9a5be22..bb8123527a 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ django-storages==1.14 # 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.5.4 +edx-enterprise==4.5.7 # django-oauth-toolkit version >=2.0.0 has breaking changes. More details # mentioned on this issue https://github.com/openedx/edx-platform/issues/32884 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7d45b92ca3..7deca97f35 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -480,7 +480,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.4 +edx-enterprise==4.5.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4f904772ec..c875bec879 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -750,7 +750,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.4 +edx-enterprise==4.5.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index da21782626..3cb2357b8d 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -556,7 +556,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.4 +edx-enterprise==4.5.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 781a353565..60defce3b2 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -583,7 +583,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.4 +edx-enterprise==4.5.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 46776e92e2f3b4c287a9c6f21ebb3d39a12c1a6a Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Fri, 6 Oct 2023 17:52:05 +0500 Subject: [PATCH 17/86] =?UTF-8?q?chore:=20using=20`edx-proctoring-proctort?= =?UTF-8?q?rack`=20hash=20for=20django42=20compatib=E2=80=A6=20(#33406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: using `edx-proctoring-proctortrack` hash for django42 compatibility. * docs: link the follow up issue for github dependency --------- Co-authored-by: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Co-authored-by: UsamaSadiq --- requirements/edx-sandbox/py38.txt | 2 +- requirements/edx/base.txt | 24 ++++++++++++------------ requirements/edx/development.txt | 30 +++++++++++++++--------------- requirements/edx/doc.txt | 24 ++++++++++++------------ requirements/edx/github.in | 4 ++++ requirements/edx/kernel.in | 3 ++- requirements/edx/semgrep.txt | 6 +++--- requirements/edx/testing.txt | 26 +++++++++++++------------- 8 files changed, 62 insertions(+), 57 deletions(-) diff --git a/requirements/edx-sandbox/py38.txt b/requirements/edx-sandbox/py38.txt index d1cc50e869..0d8e18f1d0 100644 --- a/requirements/edx-sandbox/py38.txt +++ b/requirements/edx-sandbox/py38.txt @@ -75,7 +75,7 @@ python-dateutil==2.8.2 # via matplotlib random2==1.0.1 # via -r requirements/edx-sandbox/py38.in -regex==2023.8.8 +regex==2023.10.3 # via nltk scipy==1.7.3 # via diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 7d45b92ca3..80afc4fed8 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,6 +4,8 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in aiohttp==3.8.5 @@ -77,13 +79,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -boto3==1.28.58 +boto3==1.28.59 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.59 # via # -r requirements/edx/kernel.in # boto3 @@ -486,7 +488,7 @@ edx-enterprise==4.5.4 # -r requirements/edx/kernel.in edx-event-bus-kafka==5.5.0 # via -r requirements/edx/kernel.in -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/kernel.in edx-i18n-tools==1.2.0 # via ora2 @@ -516,8 +518,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via -r requirements/edx/kernel.in edx-rbac==1.8.0 # via edx-enterprise edx-rest-api-client==5.6.0 @@ -567,7 +567,7 @@ event-tracking==2.2.0 # -r requirements/edx/kernel.in # edx-proctoring # edx-search -fastavro==1.8.3 +fastavro==1.8.4 # via openedx-events filelock==3.12.4 # via snowflake-connector-python @@ -833,7 +833,7 @@ pillow==9.5.0 # edxval pkgutil-resolve-name==1.3.10 # via jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via snowflake-connector-python polib==1.2.0 # via edx-i18n-tools @@ -984,7 +984,7 @@ referencing==0.30.2 # via # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via nltk requests==2.31.0 # via @@ -1017,9 +1017,9 @@ rpds-py==0.10.3 # via # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.34 # via drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via ruamel-yaml rules==3.3 # via @@ -1081,7 +1081,7 @@ slumber==0.7.1 # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via edx-enterprise social-auth-app-django==5.0.0 # via @@ -1224,7 +1224,7 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/kernel.in # done-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4f904772ec..98a496aee0 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -4,6 +4,10 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt accessible-pygments==0.0.4 # via # -r requirements/edx/doc.txt @@ -145,14 +149,14 @@ boto==2.39.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.28.58 +boto3==1.28.59 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.59 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -759,7 +763,7 @@ edx-event-bus-kafka==5.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -803,10 +807,6 @@ edx-proctoring==4.16.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt edx-rbac==1.8.0 # via # -r requirements/edx/doc.txt @@ -901,7 +901,7 @@ fastapi==0.103.2 # via # -r requirements/edx/testing.txt # pact-python -fastavro==1.8.3 +fastavro==1.8.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1409,7 +1409,7 @@ pkgutil-resolve-name==1.3.10 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1723,7 +1723,7 @@ referencing==0.30.2 # -r requirements/edx/testing.txt # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1770,12 +1770,12 @@ rpds-py==0.10.3 # -r requirements/edx/testing.txt # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.34 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1882,7 +1882,7 @@ snowballstemmer==2.2.0 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2134,7 +2134,7 @@ vine==5.0.0 # amqp # celery # kombu -virtualenv==20.24.1 +virtualenv==20.24.5 # via # -r requirements/edx/testing.txt # tox @@ -2219,7 +2219,7 @@ xblock-poll==1.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index da21782626..46ad29c2c9 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -4,6 +4,8 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via -r requirements/edx/base.txt accessible-pygments==0.0.4 # via pydata-sphinx-theme acid-xblock==0.2.1 @@ -103,13 +105,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -boto3==1.28.58 +boto3==1.28.59 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.59 # via # -r requirements/edx/base.txt # boto3 @@ -562,7 +564,7 @@ edx-enterprise==4.5.4 # -r requirements/edx/base.txt edx-event-bus-kafka==5.5.0 # via -r requirements/edx/base.txt -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/base.txt edx-i18n-tools==1.2.0 # via @@ -593,8 +595,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via -r requirements/edx/base.txt edx-rbac==1.8.0 # via # -r requirements/edx/base.txt @@ -651,7 +651,7 @@ event-tracking==2.2.0 # -r requirements/edx/base.txt # edx-proctoring # edx-search -fastavro==1.8.3 +fastavro==1.8.4 # via # -r requirements/edx/base.txt # openedx-events @@ -986,7 +986,7 @@ pkgutil-resolve-name==1.3.10 # via # -r requirements/edx/base.txt # jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -1166,7 +1166,7 @@ referencing==0.30.2 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via # -r requirements/edx/base.txt # nltk @@ -1203,11 +1203,11 @@ rpds-py==0.10.3 # -r requirements/edx/base.txt # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.34 # via # -r requirements/edx/base.txt # drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via # -r requirements/edx/base.txt # ruamel-yaml @@ -1283,7 +1283,7 @@ smmap==5.0.1 # via gitdb snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1489,7 +1489,7 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/base.txt xblock-poll==1.13.0 # via -r requirements/edx/base.txt -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/base.txt # done-xblock diff --git a/requirements/edx/github.in b/requirements/edx/github.in index d36b6f9629..ea6d47eec8 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -86,3 +86,7 @@ ############################################################################## # ... add dependencies here + +# django42 support PR merged but new release is pending. +# https://github.com/openedx/edx-platform/issues/33431 +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index fb0307ba43..18f4f87faa 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -81,7 +81,8 @@ edx-name-affirmation edx-opaque-keys edx-organizations edx-proctoring>=2.0.1 -edx-proctoring-proctortrack==1.2.1 # Intentionally and permanently pinned to ensure code changes are reviewed +# using hash to support django42 +# edx-proctoring-proctortrack==1.2.1 # Intentionally and permanently pinned to ensure code changes are reviewed edx-rest-api-client edx-search edx-submissions diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index fb696264ac..9abf4935ab 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -74,11 +74,11 @@ rpds-py==0.10.3 # via # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.34 # via semgrep -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via ruamel-yaml -semgrep==1.42.0 +semgrep==1.43.0 # via -r requirements/edx/semgrep.in tomli==2.0.1 # via semgrep diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 781a353565..e9f7226a70 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -4,6 +4,8 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via -r requirements/edx/base.txt acid-xblock==0.2.1 # via -r requirements/edx/base.txt aiohttp==3.8.5 @@ -110,13 +112,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -boto3==1.28.58 +boto3==1.28.59 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.59 # via # -r requirements/edx/base.txt # boto3 @@ -589,7 +591,7 @@ edx-enterprise==4.5.4 # -r requirements/edx/base.txt edx-event-bus-kafka==5.5.0 # via -r requirements/edx/base.txt -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/base.txt edx-i18n-tools==1.2.0 # via @@ -623,8 +625,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via -r requirements/edx/base.txt edx-rbac==1.8.0 # via # -r requirements/edx/base.txt @@ -693,7 +693,7 @@ faker==19.6.2 # via factory-boy fastapi==0.103.2 # via pact-python -fastavro==1.8.3 +fastavro==1.8.4 # via # -r requirements/edx/base.txt # openedx-events @@ -1053,7 +1053,7 @@ pkgutil-resolve-name==1.3.10 # via # -r requirements/edx/base.txt # jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/edx/base.txt # pylint @@ -1298,7 +1298,7 @@ referencing==0.30.2 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via # -r requirements/edx/base.txt # nltk @@ -1337,11 +1337,11 @@ rpds-py==0.10.3 # -r requirements/edx/base.txt # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.34 # via # -r requirements/edx/base.txt # drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via # -r requirements/edx/base.txt # ruamel-yaml @@ -1426,7 +1426,7 @@ sniffio==1.3.0 # anyio # httpcore # httpx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1573,7 +1573,7 @@ vine==5.0.0 # amqp # celery # kombu -virtualenv==20.24.1 +virtualenv==20.24.5 # via tox voluptuous==0.13.1 # via @@ -1633,7 +1633,7 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/base.txt xblock-poll==1.13.0 # via -r requirements/edx/base.txt -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/base.txt # done-xblock From 9f16b0f8f63ab293932fdb4c68cd8ff563a8a2c2 Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Fri, 6 Oct 2023 09:10:13 -0400 Subject: [PATCH 18/86] feat: handle exam submission and reset (#33323) handles exam events from event bus that impact the 'instructor' app. These apis deal with learner completion and managing problem attempt state. --- lms/djangoapps/instructor/apps.py | 1 + lms/djangoapps/instructor/handlers.py | 82 +++++++ lms/djangoapps/instructor/tasks.py | 4 +- .../instructor/tests/test_handlers.py | 229 ++++++++++++++++++ .../instructor/tests/test_services.py | 155 ++---------- lms/djangoapps/instructor/tests/test_tasks.py | 189 +++++++++++++++ 6 files changed, 520 insertions(+), 140 deletions(-) create mode 100644 lms/djangoapps/instructor/handlers.py create mode 100644 lms/djangoapps/instructor/tests/test_handlers.py create mode 100644 lms/djangoapps/instructor/tests/test_tasks.py diff --git a/lms/djangoapps/instructor/apps.py b/lms/djangoapps/instructor/apps.py index 898416585f..d22c8b0aa1 100644 --- a/lms/djangoapps/instructor/apps.py +++ b/lms/djangoapps/instructor/apps.py @@ -36,6 +36,7 @@ class InstructorConfig(AppConfig): } def ready(self): + from . import handlers # pylint: disable=unused-import,import-outside-toplevel if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): from .services import InstructorService set_runtime_service('instructor', InstructorService()) diff --git a/lms/djangoapps/instructor/handlers.py b/lms/djangoapps/instructor/handlers.py new file mode 100644 index 0000000000..af740ee4fe --- /dev/null +++ b/lms/djangoapps/instructor/handlers.py @@ -0,0 +1,82 @@ +""" +Handlers for instructor +""" +import logging + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.dispatch import receiver +from openedx_events.learning.signals import EXAM_ATTEMPT_RESET, EXAM_ATTEMPT_SUBMITTED + +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor import enrollment +from lms.djangoapps.instructor.tasks import update_exam_completion_task + +User = get_user_model() + +log = logging.getLogger(__name__) + + +@receiver(EXAM_ATTEMPT_SUBMITTED) +def handle_exam_completion(sender, signal, **kwargs): + """ + exam completion event from the event bus + """ + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + usage_key = event_data.usage_key + + update_exam_completion_task.apply_async((user_data.pii.username, str(usage_key), 1.0)) + + +@receiver(EXAM_ATTEMPT_RESET) +def handle_exam_reset(sender, signal, **kwargs): + """ + exam reset event from the event bus + """ + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + requesting_user_data = event_data.requesting_user + usage_key = event_data.usage_key + course_key = event_data.course_key + content_id = str(usage_key) + + try: + student = User.objects.get(id=user_data.id) + except ObjectDoesNotExist: + log.error( + 'Error occurred while attempting to reset student attempt for user_id ' + f'{user_data.id} for content_id {content_id}. ' + 'User does not exist!' + ) + return + + try: + requesting_user = User.objects.get(id=requesting_user_data.id) + except ObjectDoesNotExist: + log.error( + 'Error occurred while attempting to reset student attempt. Requesting user_id ' + f'{requesting_user_data.id} does not exist!' + ) + return + + # reset problem state + try: + enrollment.reset_student_attempts( + course_key, + student, + usage_key, + requesting_user=requesting_user, + delete_module=True, + ) + except (StudentModule.DoesNotExist, enrollment.sub_api.SubmissionError): + log.error( + 'Error occurred while attempting to reset module state for user_id ' + f'{student.id} for content_id {content_id}.' + ) + + # In some cases, reset_student_attempts does not clear the entire exam's completion state. + # One example of this is an exam with multiple units (verticals) within it and the learner + # never viewing one of the units. All of the content in that unit will still be marked complete, + # but the reset code is unable to handle clearing the completion in that scenario. + update_exam_completion_task.apply_async((student.username, content_id, 0.0)) diff --git a/lms/djangoapps/instructor/tasks.py b/lms/djangoapps/instructor/tasks.py index ac3bda13a8..2ec19c35b8 100644 --- a/lms/djangoapps/instructor/tasks.py +++ b/lms/djangoapps/instructor/tasks.py @@ -5,14 +5,14 @@ import logging from celery import shared_task from celery_utils.logged_task import LoggedTask from django.core.exceptions import ObjectDoesNotExist +from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey from xblock.completable import XBlockCompletionMode -from edx_django_utils.monitoring import set_code_owner_attribute from common.djangoapps.student.models import get_user_by_username_or_email -from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor +from lms.djangoapps.courseware.model_data import FieldDataCache from openedx.core.lib.request_utils import get_request_or_stub from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order diff --git a/lms/djangoapps/instructor/tests/test_handlers.py b/lms/djangoapps/instructor/tests/test_handlers.py new file mode 100644 index 0000000000..ee29e99eea --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_handlers.py @@ -0,0 +1,229 @@ +""" +Unit tests for instructor signals +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_RESET, EXAM_ATTEMPT_SUBMITTED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.instructor import enrollment +from lms.djangoapps.instructor.handlers import handle_exam_completion, handle_exam_reset + + +class ExamCompletionEventBusTests(TestCase): + """ + Tests completion events from the event bus. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.subsection_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + exam_type='timed', + requesting_user=requesting_user_data, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + def test_submit_exam_completion_event(self, mock_task_apply): + """ + Assert update completion task is scheduled + """ + exam_event_data = self._get_exam_event_data(self.student_user, self.course_key, self.subsection_key) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_SUBMITTED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + handle_exam_completion(None, EXAM_ATTEMPT_SUBMITTED, **event_kwargs) + mock_task_apply.assert_called_once_with(('student_user', self.subsection_id, 1.0)) + + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + @mock.patch('lms.djangoapps.instructor.enrollment.reset_student_attempts', autospec=True) + def test_exam_reset_event(self, mock_reset, mock_task_apply): + """ + Assert problem state and completion are reset + """ + staff_user = UserFactory( + username='staff_user', + is_staff=True, + ) + + exam_event_data = self._get_exam_event_data( + self.student_user, + self.course_key, + self.subsection_key, + requesting_user=staff_user + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + + # reset signal + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + + # make sure problem attempts have been deleted + mock_reset.assert_called_once_with( + self.course_key, + self.student_user, + self.subsection_key, + requesting_user=staff_user, + delete_module=True, + ) + + # Assert we update completion with 0.0 + mock_task_apply.assert_called_once_with(('student_user', self.subsection_id, 0.0)) + + def test_exam_reset_bad_user(self): + staff_user = UserFactory( + username='staff_user', + is_staff=True, + ) + + # not a user + bad_user_data = ExamAttemptData( + student_user=UserData( + id=999, + is_active=True, + pii=UserPersonalData( + username='user_dne', + email='user_dne@example.com', + ), + ), + course_key=self.course_key, + usage_key=self.subsection_key, + exam_type='timed', + requesting_user=UserData( + id=staff_user.id, + is_active=True, + pii=None + ), + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + event_kwargs = { + 'exam_attempt': bad_user_data, + 'metadata': event_metadata + } + + # reset signal + with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log: + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + mock_log.assert_called_once_with( + 'Error occurred while attempting to reset student attempt for user_id ' + f'999 for content_id {bad_user_data.usage_key}. ' + 'User does not exist!' + ) + + def test_exam_reset_bad_requesting_user(self): + # requesting user is not a user + bad_user_data = ExamAttemptData( + student_user=UserData( + id=self.student_user.id, + is_active=True, + pii=UserPersonalData( + username='user_dne', + email='user_dne@example.com', + ), + ), + course_key=self.course_key, + usage_key=self.subsection_key, + exam_type='timed', + requesting_user=UserData( + id=999, + is_active=True, + pii=None + ), + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + event_kwargs = { + 'exam_attempt': bad_user_data, + 'metadata': event_metadata + } + + # reset signal + with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log: + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + mock_log.assert_called_once_with( + 'Error occurred while attempting to reset student attempt. Requesting user_id ' + '999 does not exist!' + ) + + @mock.patch( + 'lms.djangoapps.instructor.enrollment.reset_student_attempts', + side_effect=enrollment.sub_api.SubmissionError + ) + def test_module_reset_failure(self, mock_reset): + staff_user = UserFactory( + username='staff_user', + is_staff=True, + ) + + exam_event_data = self._get_exam_event_data( + self.student_user, + self.course_key, + self.subsection_key, + requesting_user=staff_user + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + + with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log: + # reset signal + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + mock_log.assert_called_once_with( + 'Error occurred while attempting to reset module state for user_id ' + f'{self.student_user.id} for content_id {self.subsection_id}.' + ) diff --git a/lms/djangoapps/instructor/tests/test_services.py b/lms/djangoapps/instructor/tests/test_services.py index 488c0d43a6..7080b58bba 100644 --- a/lms/djangoapps/instructor/tests/test_services.py +++ b/lms/djangoapps/instructor/tests/test_services.py @@ -5,9 +5,7 @@ import json from unittest import mock import pytest -from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.core.exceptions import ObjectDoesNotExist -from edx_toggles.toggles.testutils import override_waffle_switch from opaque_keys import InvalidKeyError from common.djangoapps.student.models import CourseEnrollment @@ -15,9 +13,8 @@ from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.instructor.access import allow_access from lms.djangoapps.instructor.services import InstructorService -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory class InstructorServiceTests(SharedModuleStoreTestCase): @@ -54,8 +51,8 @@ class InstructorServiceTests(SharedModuleStoreTestCase): ) @mock.patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send') - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_reset_student_attempts_delete(self, mock_submit, _mock_signal): + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + def test_reset_student_attempts_delete(self, mock_completion_task, _mock_signal): """ Test delete student state. """ @@ -64,22 +61,19 @@ class InstructorServiceTests(SharedModuleStoreTestCase): assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id, module_state_key=self.module_to_reset.module_state_key).count() == 1 - with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): - self.service.delete_student_attempt( - self.student.username, - str(self.course.id), - str(self.subsection.location), - requesting_user=self.student, - ) + self.service.delete_student_attempt( + self.student.username, + str(self.course.id), + str(self.subsection.location), + requesting_user=self.student, + ) # make sure the module has been deleted assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id, module_state_key=self.module_to_reset.module_state_key).count() == 0 - # Assert we send completion == 0.0 for both problems even though the second problem was never viewed - assert mock_submit.call_count == 2 - mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0) - mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0) + # Assert we send update completion with 0.0 + mock_completion_task.assert_called_once_with((self.student.username, str(self.subsection.location), 0.0)) def test_reset_bad_content_id(self): """ @@ -120,128 +114,13 @@ class InstructorServiceTests(SharedModuleStoreTestCase): ) assert result is None - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_complete_student_attempt_success(self, mock_submit): + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + def test_complete_student_attempt_success(self, mock_completion_task): """ - Assert complete_student_attempt correctly publishes completion for all - completable children of the given content_id + Assert update_exam_completion task is triggered """ - # Section, subsection, and unit are all aggregators and not completable so should - # not be submitted. - section = BlockFactory.create(parent=self.course, category='chapter') - subsection = BlockFactory.create(parent=section, category='sequential') - unit = BlockFactory.create(parent=subsection, category='vertical') - - # should both be submitted - video = BlockFactory.create(parent=unit, category='video') - problem = BlockFactory.create(parent=unit, category='problem') - - # Not a completable block - BlockFactory.create(parent=unit, category='discussion') - - with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): - self.service.complete_student_attempt(self.student.username, str(subsection.location)) - - # Only Completable leaf blocks should have completion published - assert mock_submit.call_count == 2 - mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0) - mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0) - - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_complete_student_attempt_split_test(self, mock_submit): - """ - Asserts complete_student_attempt correctly publishes completion when a split test is involved - - This test case exists because we ran into a bug about the user_service not existing - when a split_test existed inside of a subsection. Associated with this change was adding - in the user state into the module before attempting completion and this ensures that is - working properly. - """ - partition = UserPartition( - 0, - 'first_partition', - 'First Partition', - [ - Group(0, 'alpha'), - Group(1, 'beta') - ] - ) - course = CourseFactory.create(user_partitions=[partition]) - section = BlockFactory.create(parent=course, category='chapter') - subsection = BlockFactory.create(parent=section, category='sequential') - - c0_url = course.id.make_usage_key('vertical', 'split_test_cond0') - c1_url = course.id.make_usage_key('vertical', 'split_test_cond1') - split_test = BlockFactory.create( - parent=subsection, - category='split_test', - user_partition_id=0, - group_id_to_child={'0': c0_url, '1': c1_url}, - ) - - cond0vert = BlockFactory.create(parent=split_test, category='vertical', location=c0_url) - BlockFactory.create(parent=cond0vert, category='video') - BlockFactory.create(parent=cond0vert, category='problem') - - cond1vert = BlockFactory.create(parent=split_test, category='vertical', location=c1_url) - BlockFactory.create(parent=cond1vert, category='video') - BlockFactory.create(parent=cond1vert, category='html') - - with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): - self.service.complete_student_attempt(self.student.username, str(subsection.location)) - - # Only the group the user was assigned to should have completion published. - # Either cond0vert's children or cond1vert's children - assert mock_submit.call_count == 2 - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_bad_user(self, mock_logger): - """ - Assert complete_student_attempt with a bad user raises error and returns None - """ - username = 'bad_user' - block_id = str(self.problem.location) - self.service.complete_student_attempt(username, block_id) - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!' - ) - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_bad_content_id(self, mock_logger): - """ - Assert complete_student_attempt with a bad content_id raises error and returns None - """ - username = self.student.username - self.service.complete_student_attempt(username, 'foo/bar/baz') - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!' - ) - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_nonexisting_item(self, mock_logger): - """ - Assert complete_student_attempt with nonexisting item in the modulestore - raises error and returns None - """ - username = self.student.username - block = 'i4x://org.0/course_0/problem/fake_problem' - self.service.complete_student_attempt(username, block) - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!' - ) - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_failed_module(self, mock_logger): - """ - Assert complete_student_attempt with failed get_block raises error and returns None - """ - username = self.student.username - with mock.patch('lms.djangoapps.instructor.tasks.get_block_for_descriptor', return_value=None): - self.service.complete_student_attempt(username, str(self.course.location)) - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id=self.course.location) + - 'Block unable to be created from descriptor!' - ) + self.service.complete_student_attempt(self.student.username, str(self.subsection.location)) + mock_completion_task.assert_called_once_with((self.student.username, str(self.subsection.location), 1.0)) def test_is_user_staff(self): """ diff --git a/lms/djangoapps/instructor/tests/test_tasks.py b/lms/djangoapps/instructor/tests/test_tasks.py new file mode 100644 index 0000000000..4c6f9016f4 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_tasks.py @@ -0,0 +1,189 @@ +""" +Tests for tasks.py +""" +import json +from unittest import mock + +from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH +from edx_toggles.toggles.testutils import override_waffle_switch + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor.tasks import update_exam_completion_task +from xmodule.modulestore.tests.django_utils import \ + SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( # lint-amnesty, pylint: disable=wrong-import-order + BlockFactory, + CourseFactory +) +from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order + + +class UpdateCompletionTests(SharedModuleStoreTestCase): + """ + Test the update_exam_completion_task + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.email = 'escalation@test.com' + cls.course = CourseFactory.create(proctoring_escalation_email=cls.email) + cls.section = BlockFactory.create(parent=cls.course, category='chapter') + cls.subsection = BlockFactory.create(parent=cls.section, category='sequential') + cls.unit = BlockFactory.create(parent=cls.subsection, category='vertical') + cls.problem = BlockFactory.create(parent=cls.unit, category='problem') + cls.unit_2 = BlockFactory.create(parent=cls.subsection, category='vertical') + cls.problem_2 = BlockFactory.create(parent=cls.unit_2, category='problem') + cls.complete_error_prefix = ('Error occurred while attempting to complete student attempt for ' + 'user {user} for content_id {content_id}. ') + + def setUp(self): + super().setUp() + + self.student = UserFactory() + CourseEnrollment.enroll(self.student, self.course.id) + + self.module_to_reset = StudentModule.objects.create( + student=self.student, + course_id=self.course.id, + module_state_key=self.problem.location, + state=json.dumps({'attempts': 2}), + ) + + @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') + def test_update_completion_success(self, mock_submit): + """ + Assert correctly publishes completion for all + completable children of the given content_id + """ + # Section, subsection, and unit are all aggregators and not completable so should + # not be submitted. + section = BlockFactory.create(parent=self.course, category='chapter') + subsection = BlockFactory.create(parent=section, category='sequential') + unit = BlockFactory.create(parent=subsection, category='vertical') + + # should both be submitted + video = BlockFactory.create(parent=unit, category='video') + problem = BlockFactory.create(parent=unit, category='problem') + + # Not a completable block + BlockFactory.create(parent=unit, category='discussion') + + with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): + update_exam_completion_task(self.student.username, str(subsection.location), 1.0) + + # Only Completable leaf blocks should have completion published + assert mock_submit.call_count == 2 + mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0) + mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0) + + @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') + def test_update_completion_delete(self, mock_submit): + """ + Test update completion with a value of 0.0 + """ + with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): + update_exam_completion_task(self.student.username, str(self.subsection.location), 0.0) + + # Assert we send completion == 0.0 for both problems + assert mock_submit.call_count == 2 + mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0) + mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0) + + @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') + def test_update_completion_split_test(self, mock_submit): + """ + Asserts correctly publishes completion when a split test is involved + + This test case exists because we ran into a bug about the user_service not existing + when a split_test existed inside of a subsection. Associated with this change was adding + in the user state into the module before attempting completion and this ensures that is + working properly. + """ + partition = UserPartition( + 0, + 'first_partition', + 'First Partition', + [ + Group(0, 'alpha'), + Group(1, 'beta') + ] + ) + course = CourseFactory.create(user_partitions=[partition]) + section = BlockFactory.create(parent=course, category='chapter') + subsection = BlockFactory.create(parent=section, category='sequential') + + c0_url = course.id.make_usage_key('vertical', 'split_test_cond0') + c1_url = course.id.make_usage_key('vertical', 'split_test_cond1') + split_test = BlockFactory.create( + parent=subsection, + category='split_test', + user_partition_id=0, + group_id_to_child={'0': c0_url, '1': c1_url}, + ) + + cond0vert = BlockFactory.create(parent=split_test, category='vertical', location=c0_url) + BlockFactory.create(parent=cond0vert, category='video') + BlockFactory.create(parent=cond0vert, category='problem') + + cond1vert = BlockFactory.create(parent=split_test, category='vertical', location=c1_url) + BlockFactory.create(parent=cond1vert, category='video') + BlockFactory.create(parent=cond1vert, category='html') + + with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): + update_exam_completion_task(self.student.username, str(subsection.location), 1.0) + + # Only the group the user was assigned to should have completion published. + # Either cond0vert's children or cond1vert's children + assert mock_submit.call_count == 2 + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_bad_user(self, mock_logger): + """ + Assert a bad user raises error and returns None + """ + username = 'bad_user' + block_id = str(self.problem.location) + update_exam_completion_task(username, block_id, 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!' + ) + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_bad_content_id(self, mock_logger): + """ + Assert a bad content_id raises error and returns None + """ + username = self.student.username + update_exam_completion_task(username, 'foo/bar/baz', 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!' + ) + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_nonexisting_item(self, mock_logger): + """ + Assert nonexisting item in the modulestore + raises error and returns None + """ + username = self.student.username + block = 'i4x://org.0/course_0/problem/fake_problem' + update_exam_completion_task(username, block, 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!' + ) + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_failed_module(self, mock_logger): + """ + Assert failed get_block raises error and returns None + """ + username = self.student.username + with mock.patch('lms.djangoapps.instructor.tasks.get_block_for_descriptor', return_value=None): + update_exam_completion_task(username, str(self.course.location), 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id=self.course.location) + + 'Block unable to be created from descriptor!' + ) From 4d3ef54e60c6a27969a835143470f26692f714f1 Mon Sep 17 00:00:00 2001 From: connorhaugh <49422820+connorhaugh@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:06:20 -0400 Subject: [PATCH 19/86] fix: library ref mgmt cmd task params (#33427) * fix: library ref mgmt cmd task params * fix: lint fix * fix: lint fix --- .../replace_v1_lib_refs_with_v2_in_courses.py | 17 +++++++++--- cms/djangoapps/contentstore/tasks.py | 26 +++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py b/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py index 39d64912bf..a2159fbd4d 100644 --- a/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py +++ b/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py @@ -35,14 +35,15 @@ class Command(BaseCommand): """A method to replace 'source_library_id' in all relevant blocks.""" courses = CourseOverview.get_all_courses() + course_id_strings = [str(course.id) for course in courses] # Use Celery to distribute the workload tasks = group( replace_all_library_source_blocks_ids_for_course.s( - course, + course_id_string, v1_to_v2_lib_map ) - for course in courses + for course_id_string in course_id_strings ) results = tasks.apply_async() @@ -58,7 +59,8 @@ class Command(BaseCommand): def validate(self, v1_to_v2_lib_map): """ Validate that replace_all_library_source_blocks_ids was successful""" courses = CourseOverview.get_all_courses() - tasks = group(validate_all_library_source_blocks_ids_for_course.s(course, v1_to_v2_lib_map) for course in courses) # lint-amnesty, pylint: disable=line-too-long + course_id_strings = [str(course.id) for course in courses] + tasks = group(validate_all_library_source_blocks_ids_for_course.s(course_id, v1_to_v2_lib_map) for course_id in course_id_strings) # lint-amnesty, pylint: disable=line-too-long results = tasks.apply_async() validation = set() @@ -80,9 +82,16 @@ class Command(BaseCommand): def undo(self, v1_to_v2_lib_map): """ undo the changes made by replace_all_library_source_blocks_ids""" courses = CourseOverview.get_all_courses() + course_id_strings = [str(course.id) for course in courses] # Use Celery to distribute the workload - tasks = group(undo_all_library_source_blocks_ids_for_course.s(course, v1_to_v2_lib_map) for course in courses) + tasks = group( + undo_all_library_source_blocks_ids_for_course.s( + course_id, + v1_to_v2_lib_map + ) + for course_id in course_id_strings + ) results = tasks.apply_async() for result in results.get(): diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index b7a249bfa5..434aba419a 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -1006,23 +1006,24 @@ def delete_v1_library(v1_library_key_string): @shared_task(time_limit=30) @set_code_owner_attribute -def validate_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): +def validate_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v2 value from the map """ + course_id = CourseKey.from_string(course_key_string) store = modulestore() - with store.bulk_operations(course.id): + with store.bulk_operations(course_id): visited = [] for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published]: blocks = store.get_items( - course.id.for_branch(branch), + course_id.for_branch(branch), settings={'source_library_id': {'$exists': True}} ) for xblock in blocks: if xblock.source_library_id not in v1_to_v2_lib_map.values(): # lint-amnesty, pylint: disable=broad-except raise Exception( - f'{xblock.source_library_id} in {course.id} is not found in mapping. Validation failed' + f'{xblock.source_library_id} in {course_id} is not found in mapping. Validation failed' ) visited.append(xblock.source_library_id) # return sucess @@ -1031,18 +1032,20 @@ def validate_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): @shared_task(time_limit=30) @set_code_owner_attribute -def replace_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return +def replace_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v2 value from the map. This will trigger a publish on the course for every published library source block. """ store = modulestore() - with store.bulk_operations(course.id): + course_id = CourseKey.from_string(course_key_string) + + with store.bulk_operations(course_id): #for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published]: draft_blocks, published_blocks = [ store.get_items( - course.id.for_branch(branch), + course_id.for_branch(branch), settings={'source_library_id': {'$exists': True}} ) for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published] @@ -1058,7 +1061,7 @@ def replace_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): LOGGER.error( 'Key %s not found in mapping. Skipping block for course %s', str({draft_library_source_block.source_library_id}), - str(course.id) + str(course_id) ) continue @@ -1088,18 +1091,19 @@ def replace_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): @shared_task(time_limit=30) @set_code_owner_attribute -def undo_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return +def undo_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v1 value from the inverted map. This is exists to undo changes made previously. """ + course_id = CourseKey.from_string(course_key_string) v2_to_v1_lib_map = {v: k for k, v in v1_to_v2_lib_map.items()} store = modulestore() draft_blocks, published_blocks = [ store.get_items( - course.id.for_branch(branch), + course_id.for_branch(branch), settings={'source_library_id': {'$exists': True}} ) for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published] @@ -1115,7 +1119,7 @@ def undo_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): # LOGGER.error( 'Key %s not found in mapping. Skipping block for course %s', str({draft_library_source_block.source_library_id}), - str(course.id) + str(course_id) ) continue From c3766619ed592e35b335d349ccf952f167e0a991 Mon Sep 17 00:00:00 2001 From: Jenkins Date: Sun, 8 Oct 2023 20:39:30 +0000 Subject: [PATCH 20/86] chore(i18n): update translations --- conf/locale/ar/LC_MESSAGES/django.po | 10 +++++++++- conf/locale/ca/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/de_DE/LC_MESSAGES/django.po | 10 +++++++++- conf/locale/el/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/en/LC_MESSAGES/django.po | 4 ++-- conf/locale/en/LC_MESSAGES/djangojs.po | 4 ++-- conf/locale/eo/LC_MESSAGES/django.mo | Bin 1199599 -> 1199599 bytes conf/locale/eo/LC_MESSAGES/django.po | 4 ++-- conf/locale/eo/LC_MESSAGES/djangojs.mo | Bin 455219 -> 455219 bytes conf/locale/eo/LC_MESSAGES/djangojs.po | 4 ++-- conf/locale/es_419/LC_MESSAGES/django.mo | Bin 683032 -> 683150 bytes conf/locale/es_419/LC_MESSAGES/django.po | 10 +++++++++- conf/locale/eu_ES/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/fa_IR/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/fr/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/id/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/it_IT/LC_MESSAGES/django.po | 10 +++++++++- conf/locale/ja_JP/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/ka/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/lt_LT/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/lv/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/mn/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/pl/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/pt_PT/LC_MESSAGES/django.po | 10 +++++++++- conf/locale/rtl/LC_MESSAGES/django.mo | Bin 781719 -> 781719 bytes conf/locale/rtl/LC_MESSAGES/django.po | 4 ++-- conf/locale/rtl/LC_MESSAGES/djangojs.mo | Bin 291255 -> 291255 bytes conf/locale/rtl/LC_MESSAGES/djangojs.po | 4 ++-- conf/locale/sk/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/sw_KE/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/th/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/tr_TR/LC_MESSAGES/django.po | 10 +++++++++- conf/locale/uk/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/vi/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/zh_CN/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/zh_HANS/LC_MESSAGES/django.po | 8 ++++++++ conf/locale/zh_TW/LC_MESSAGES/django.po | 8 ++++++++ 37 files changed, 226 insertions(+), 18 deletions(-) diff --git a/conf/locale/ar/LC_MESSAGES/django.po b/conf/locale/ar/LC_MESSAGES/django.po index 150f0b8043..e7edd04500 100644 --- a/conf/locale/ar/LC_MESSAGES/django.po +++ b/conf/locale/ar/LC_MESSAGES/django.po @@ -259,7 +259,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: NELC Open edX Translation , 2020\n" "Language-Team: Arabic (https://app.transifex.com/open-edx/teams/6205/ar/)\n" @@ -7601,6 +7601,14 @@ msgstr "أردج {country} على اللائحة السوداء للمساق {co msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/ca/LC_MESSAGES/django.po b/conf/locale/ca/LC_MESSAGES/django.po index 418d0a02ab..adf7ef616a 100644 --- a/conf/locale/ca/LC_MESSAGES/django.po +++ b/conf/locale/ca/LC_MESSAGES/django.po @@ -6803,6 +6803,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/de_DE/LC_MESSAGES/django.po b/conf/locale/de_DE/LC_MESSAGES/django.po index ce94b6980b..9739f3ec42 100644 --- a/conf/locale/de_DE/LC_MESSAGES/django.po +++ b/conf/locale/de_DE/LC_MESSAGES/django.po @@ -177,7 +177,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Stefania Trabucchi , 2019\n" "Language-Team: German (Germany) (https://app.transifex.com/open-edx/teams/6205/de_DE/)\n" @@ -7563,6 +7563,14 @@ msgstr "Blacklist {country} für {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/el/LC_MESSAGES/django.po b/conf/locale/el/LC_MESSAGES/django.po index f6ce1c8a49..9c2da911a2 100644 --- a/conf/locale/el/LC_MESSAGES/django.po +++ b/conf/locale/el/LC_MESSAGES/django.po @@ -6950,6 +6950,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/en/LC_MESSAGES/django.po b/conf/locale/en/LC_MESSAGES/django.po index 52f45b05ba..fc72790f43 100644 --- a/conf/locale/en/LC_MESSAGES/django.po +++ b/conf/locale/en/LC_MESSAGES/django.po @@ -38,8 +38,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.857987\n" +"POT-Creation-Date: 2023-10-08 20:36+0000\n" +"PO-Revision-Date: 2023-10-08 20:36:26.861016\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: en\n" diff --git a/conf/locale/en/LC_MESSAGES/djangojs.po b/conf/locale/en/LC_MESSAGES/djangojs.po index d0b2f6871c..0dec53e989 100644 --- a/conf/locale/en/LC_MESSAGES/djangojs.po +++ b/conf/locale/en/LC_MESSAGES/djangojs.po @@ -32,8 +32,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.819628\n" +"POT-Creation-Date: 2023-10-08 20:36+0000\n" +"PO-Revision-Date: 2023-10-08 20:36:26.755103\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: en\n" diff --git a/conf/locale/eo/LC_MESSAGES/django.mo b/conf/locale/eo/LC_MESSAGES/django.mo index bacfc67180ff23db0ccfec0a99e5d3506a6219a6..6a1ae31477f43aafca91292c6de1cf766f4f564f 100644 GIT binary patch delta 91 zcmaDq+w=Ww&xRJp7N!>F7M3lnhdKo<6pRe4jLoc!%=9eG3=Is;+Anvq0x=s9vjZ^) e5OV@C7Z7s;F%J;)0x=&D^8>NK_RF1uGDiSj`X%20 delta 91 zcmaDq+w=Ww&xRJp7N!>F7M3lnhdKof6^sn5jLobJP4p~G%`Gj=+b?&r0x=s9vjZ^) e5OV@C7Z7s;F%J;)0x=&D^8>NK_RF1uGDiSmFD2^$ diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po index 1892fb2de7..626bd259fd 100644 --- a/conf/locale/eo/LC_MESSAGES/django.po +++ b/conf/locale/eo/LC_MESSAGES/django.po @@ -38,8 +38,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.857987\n" +"POT-Creation-Date: 2023-10-08 20:36+0000\n" +"PO-Revision-Date: 2023-10-08 20:36:26.861016\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: eo\n" diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.mo b/conf/locale/eo/LC_MESSAGES/djangojs.mo index a0ae05bf43c99527b737b4dcb7314273493cd1a8..07f0b257fb8d866d31d4f6fbe403649888cf54d0 100644 GIT binary patch delta 53 zcmdn|M0)cR>4p}@Eldw}1T7Sd46KaJtc=X`%uP)V4UF4A=`aB?GZ3=?G3)kEI&6+d E0Rn6jBLDyZ delta 53 zcmdn|M0)cR>4p}@Eldw}1Pv9846KaJtPD-`EDSBpj4awe=`aB?GZ3=?G3)kEI&6+d E0RlJ^CIA2c diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.po b/conf/locale/eo/LC_MESSAGES/djangojs.po index 31d5461819..fef2609d9f 100644 --- a/conf/locale/eo/LC_MESSAGES/djangojs.po +++ b/conf/locale/eo/LC_MESSAGES/djangojs.po @@ -32,8 +32,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.819628\n" +"POT-Creation-Date: 2023-10-08 20:36+0000\n" +"PO-Revision-Date: 2023-10-08 20:36:26.755103\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: eo\n" diff --git a/conf/locale/es_419/LC_MESSAGES/django.mo b/conf/locale/es_419/LC_MESSAGES/django.mo index e4fb596cebe3e7aa8641367c21eca48f10b86ada..e5487617473cb773e68e30f474c6f9b791a8422d 100644 GIT binary patch delta 93240 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

CYz6Ujh2T?aV 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 Q%}+aSzRQmJ>84%zKcU>zY5)KL diff --git a/conf/locale/es_419/LC_MESSAGES/django.po b/conf/locale/es_419/LC_MESSAGES/django.po index d5b34818ea..ff394f49a7 100644 --- a/conf/locale/es_419/LC_MESSAGES/django.po +++ b/conf/locale/es_419/LC_MESSAGES/django.po @@ -296,7 +296,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Albeiro Gonzalez , 2019\n" "Language-Team: Spanish (Latin America) (https://app.transifex.com/open-edx/teams/6205/es_419/)\n" @@ -7970,6 +7970,14 @@ msgstr "Incluir en lista negra a {country} para el curso {course}" msgid "Learner Pathways" msgstr "Rutas de aprendizaje" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "Aplicación de notificación" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "Tipo de notificación" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/eu_ES/LC_MESSAGES/django.po b/conf/locale/eu_ES/LC_MESSAGES/django.po index 3942a14866..7b3ee32521 100644 --- a/conf/locale/eu_ES/LC_MESSAGES/django.po +++ b/conf/locale/eu_ES/LC_MESSAGES/django.po @@ -6917,6 +6917,14 @@ msgstr "{country} zerrenda beltza {course}-rako" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/fa_IR/LC_MESSAGES/django.po b/conf/locale/fa_IR/LC_MESSAGES/django.po index 2458f76795..1e4087dc0c 100644 --- a/conf/locale/fa_IR/LC_MESSAGES/django.po +++ b/conf/locale/fa_IR/LC_MESSAGES/django.po @@ -7548,6 +7548,14 @@ msgstr "فهرست سیاه {country} برای {course}" msgid "Learner Pathways" msgstr "مسیرهای یادگیرنده" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/fr/LC_MESSAGES/django.po b/conf/locale/fr/LC_MESSAGES/django.po index 5b339180ad..b530cd2f9c 100644 --- a/conf/locale/fr/LC_MESSAGES/django.po +++ b/conf/locale/fr/LC_MESSAGES/django.po @@ -7967,6 +7967,14 @@ msgstr "Mettre sur le pays {country} sur liste noire pour {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/id/LC_MESSAGES/django.po b/conf/locale/id/LC_MESSAGES/django.po index 24719010aa..f4630eafce 100644 --- a/conf/locale/id/LC_MESSAGES/django.po +++ b/conf/locale/id/LC_MESSAGES/django.po @@ -7140,6 +7140,14 @@ msgstr "Daftar hitamkan {country} untuk {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/it_IT/LC_MESSAGES/django.po b/conf/locale/it_IT/LC_MESSAGES/django.po index ae49f47dda..b9f7728f03 100644 --- a/conf/locale/it_IT/LC_MESSAGES/django.po +++ b/conf/locale/it_IT/LC_MESSAGES/django.po @@ -127,7 +127,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Ilaria Botti , 2021\n" "Language-Team: Italian (Italy) (https://app.transifex.com/open-edx/teams/6205/it_IT/)\n" @@ -7814,6 +7814,14 @@ msgstr "Lista di proscrizione {country} per {course}" msgid "Learner Pathways" msgstr "Percorsi di apprendimento" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/ja_JP/LC_MESSAGES/django.po b/conf/locale/ja_JP/LC_MESSAGES/django.po index 9f67617d19..dae8509831 100644 --- a/conf/locale/ja_JP/LC_MESSAGES/django.po +++ b/conf/locale/ja_JP/LC_MESSAGES/django.po @@ -6852,6 +6852,14 @@ msgstr "{course} 講座のブラックリスト {country} " msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/ka/LC_MESSAGES/django.po b/conf/locale/ka/LC_MESSAGES/django.po index 64c62625d1..942530cd55 100644 --- a/conf/locale/ka/LC_MESSAGES/django.po +++ b/conf/locale/ka/LC_MESSAGES/django.po @@ -7032,6 +7032,14 @@ msgstr "დაემატოს \"{country}\" {course} კურსის შ msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/lt_LT/LC_MESSAGES/django.po b/conf/locale/lt_LT/LC_MESSAGES/django.po index af46587ba2..7867ae401c 100644 --- a/conf/locale/lt_LT/LC_MESSAGES/django.po +++ b/conf/locale/lt_LT/LC_MESSAGES/django.po @@ -6767,6 +6767,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/lv/LC_MESSAGES/django.po b/conf/locale/lv/LC_MESSAGES/django.po index 20725bde01..ded1241d94 100644 --- a/conf/locale/lv/LC_MESSAGES/django.po +++ b/conf/locale/lv/LC_MESSAGES/django.po @@ -7168,6 +7168,14 @@ msgstr "Ievietot valsti {country} melnajā sarakstā kursam {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/mn/LC_MESSAGES/django.po b/conf/locale/mn/LC_MESSAGES/django.po index 562c794239..f98e92c2f3 100644 --- a/conf/locale/mn/LC_MESSAGES/django.po +++ b/conf/locale/mn/LC_MESSAGES/django.po @@ -6824,6 +6824,14 @@ msgstr "{country} улсыг {course} хичээлийн хар жагсаал msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/pl/LC_MESSAGES/django.po b/conf/locale/pl/LC_MESSAGES/django.po index da1f4536ac..f073094802 100644 --- a/conf/locale/pl/LC_MESSAGES/django.po +++ b/conf/locale/pl/LC_MESSAGES/django.po @@ -7296,6 +7296,14 @@ msgstr "Dodaj kraj \"{country}\" do czarnej listy kursu {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/pt_PT/LC_MESSAGES/django.po b/conf/locale/pt_PT/LC_MESSAGES/django.po index e6fe1acdb8..a038db3903 100644 --- a/conf/locale/pt_PT/LC_MESSAGES/django.po +++ b/conf/locale/pt_PT/LC_MESSAGES/django.po @@ -141,7 +141,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Cátia Lopes , 2019\n" "Language-Team: Portuguese (Portugal) (https://app.transifex.com/open-edx/teams/6205/pt_PT/)\n" @@ -7773,6 +7773,14 @@ msgstr "Incluir na lista negra {country} para o curso {course}" msgid "Learner Pathways" msgstr "Trajetos do Estudante" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/rtl/LC_MESSAGES/django.mo b/conf/locale/rtl/LC_MESSAGES/django.mo index 8ad5d4143459101dbb31590238cda9ab41b94bce..ab64bced25a0a30c50f8fc7c4e02edfc147de27f 100644 GIT binary patch delta 67 zcmbP!TYvg({e~9C7N!>F7M3lnhdKo<6pRe4jLoc!%=9eG3=Is;+Anvq0x=s9vjZ^) P5OV@C*Y?Yu+((iDJeL|7 delta 67 zcmbP!TYvg({e~9C7N!>F7M3lnhdKof6^sn5jLobJP4p~G%`Gj=+b?&r0x=s9vjZ^) P5OV@C*Y?Yu+((iDJ**lf diff --git a/conf/locale/rtl/LC_MESSAGES/django.po b/conf/locale/rtl/LC_MESSAGES/django.po index 85b4e8a6f3..bbfa9ac233 100644 --- a/conf/locale/rtl/LC_MESSAGES/django.po +++ b/conf/locale/rtl/LC_MESSAGES/django.po @@ -38,8 +38,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.857987\n" +"POT-Creation-Date: 2023-10-08 20:36+0000\n" +"PO-Revision-Date: 2023-10-08 20:36:26.861016\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: rtl\n" diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.mo b/conf/locale/rtl/LC_MESSAGES/djangojs.mo index 14aca7096c8fc1f55329872830a33a0b0fac7911..37deb82eb33eeb7a0411e36964870079d23b7ea1 100644 GIT binary patch delta 45 zcmdn~SaADe!G;#bEldw}1T7Sd46KaJtc=X`%uP)V4UF4A=`aB?^Y%|VEJs}cbNmm( delta 45 zcmdn~SaADe!G;#bEldw}1Pv9846KaJtPD-`EDSBpj4awe=`aB?^Y%|VEJs}cbFdG_ diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.po b/conf/locale/rtl/LC_MESSAGES/djangojs.po index 280f56e60c..fbbc53b09d 100644 --- a/conf/locale/rtl/LC_MESSAGES/djangojs.po +++ b/conf/locale/rtl/LC_MESSAGES/djangojs.po @@ -32,8 +32,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.819628\n" +"POT-Creation-Date: 2023-10-08 20:36+0000\n" +"PO-Revision-Date: 2023-10-08 20:36:26.755103\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: rtl\n" diff --git a/conf/locale/sk/LC_MESSAGES/django.po b/conf/locale/sk/LC_MESSAGES/django.po index a782279aa1..b89b1f04ed 100644 --- a/conf/locale/sk/LC_MESSAGES/django.po +++ b/conf/locale/sk/LC_MESSAGES/django.po @@ -6846,6 +6846,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/sw_KE/LC_MESSAGES/django.po b/conf/locale/sw_KE/LC_MESSAGES/django.po index 1500e6b165..2dab56fb0a 100644 --- a/conf/locale/sw_KE/LC_MESSAGES/django.po +++ b/conf/locale/sw_KE/LC_MESSAGES/django.po @@ -6890,6 +6890,14 @@ msgstr "Orodha ya {country} zisizokubaliwa kwenye {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/th/LC_MESSAGES/django.po b/conf/locale/th/LC_MESSAGES/django.po index 91bee06809..5375e05551 100644 --- a/conf/locale/th/LC_MESSAGES/django.po +++ b/conf/locale/th/LC_MESSAGES/django.po @@ -6705,6 +6705,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/tr_TR/LC_MESSAGES/django.po b/conf/locale/tr_TR/LC_MESSAGES/django.po index 99388c329a..db6be88624 100644 --- a/conf/locale/tr_TR/LC_MESSAGES/django.po +++ b/conf/locale/tr_TR/LC_MESSAGES/django.po @@ -132,7 +132,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Ali Işıngör , 2021\n" "Language-Team: Turkish (Turkey) (https://app.transifex.com/open-edx/teams/6205/tr_TR/)\n" @@ -7639,6 +7639,14 @@ msgstr "{course} dersi için {country} kara listede" msgid "Learner Pathways" msgstr "Öğrenci Patikaları" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/uk/LC_MESSAGES/django.po b/conf/locale/uk/LC_MESSAGES/django.po index 9df1d2c5ea..55d45117c6 100644 --- a/conf/locale/uk/LC_MESSAGES/django.po +++ b/conf/locale/uk/LC_MESSAGES/django.po @@ -7339,6 +7339,14 @@ msgstr "Занести до чорного списку країну {country} msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/vi/LC_MESSAGES/django.po b/conf/locale/vi/LC_MESSAGES/django.po index 6b2785be79..49e88138f2 100644 --- a/conf/locale/vi/LC_MESSAGES/django.po +++ b/conf/locale/vi/LC_MESSAGES/django.po @@ -6783,6 +6783,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/zh_CN/LC_MESSAGES/django.po b/conf/locale/zh_CN/LC_MESSAGES/django.po index 5727a7015a..7530c6fd04 100644 --- a/conf/locale/zh_CN/LC_MESSAGES/django.po +++ b/conf/locale/zh_CN/LC_MESSAGES/django.po @@ -7234,6 +7234,14 @@ msgstr "课程{course}的黑名单国家: {country}" msgid "Learner Pathways" msgstr "学习者路径" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/zh_HANS/LC_MESSAGES/django.po b/conf/locale/zh_HANS/LC_MESSAGES/django.po index 5727a7015a..7530c6fd04 100644 --- a/conf/locale/zh_HANS/LC_MESSAGES/django.po +++ b/conf/locale/zh_HANS/LC_MESSAGES/django.po @@ -7234,6 +7234,14 @@ msgstr "课程{course}的黑名单国家: {country}" msgid "Learner Pathways" msgstr "学习者路径" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" diff --git a/conf/locale/zh_TW/LC_MESSAGES/django.po b/conf/locale/zh_TW/LC_MESSAGES/django.po index 43c41e5e9c..8c9c7f706e 100644 --- a/conf/locale/zh_TW/LC_MESSAGES/django.po +++ b/conf/locale/zh_TW/LC_MESSAGES/django.po @@ -6871,6 +6871,14 @@ msgstr "課程{course}的黑名單國家: {country} " msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" From b353019c3f217a64e9baec2394630a98be361d10 Mon Sep 17 00:00:00 2001 From: Awais Qureshi Date: Mon, 9 Oct 2023 14:25:35 +0500 Subject: [PATCH 21/86] chore: Adding condition to pick values in case of django42. (#33440) * fix: add default for CSRF_TRUSTED_ORIGINS_WITH_SCHEME. --- cms/envs/common.py | 1 + cms/envs/production.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/cms/envs/common.py b/cms/envs/common.py index bcb7665210..10c70bc982 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -831,6 +831,7 @@ CSRF_COOKIE_SECURE = False CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = '' CROSS_DOMAIN_CSRF_COOKIE_NAME = '' CSRF_TRUSTED_ORIGINS = [] +CSRF_TRUSTED_ORIGINS_WITH_SCHEME = [] #################### CAPA External Code Evaluation ############################# XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds diff --git a/cms/envs/production.py b/cms/envs/production.py index d04dfcd8ac..213243fa82 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -14,6 +14,7 @@ import warnings import yaml from corsheaders.defaults import default_headers as corsheaders_default_headers +import django from django.core.exceptions import ImproperlyConfigured from django.urls import reverse_lazy from edx_django_utils.plugins import add_plugins @@ -236,6 +237,11 @@ SHARED_COOKIE_DOMAIN = ENV_TOKENS.get('SHARED_COOKIE_DOMAIN', SESSION_COOKIE_DOM # by end users. CSRF_COOKIE_SECURE = ENV_TOKENS.get('CSRF_COOKIE_SECURE', False) +# values are already updated above with default CSRF_TRUSTED_ORIGINS values but in +# case of new django version these values will override. +if django.VERSION[0] >= 4: # for greater than django 3.2 use schemes. + CSRF_TRUSTED_ORIGINS = ENV_TOKENS.get('CSRF_TRUSTED_ORIGINS_WITH_SCHEME', []) + #Email overrides MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) MKTG_URL_OVERRIDES.update(ENV_TOKENS.get('MKTG_URL_OVERRIDES', MKTG_URL_OVERRIDES)) From c6f4ea72bdbaf0fc61fa9b32b10eb5ba1f88839c Mon Sep 17 00:00:00 2001 From: jajjibhai008 Date: Mon, 9 Oct 2023 14:09:37 +0000 Subject: [PATCH 22/86] feat: Upgrade Python dependency edx-enterprise feat: Added enable_demo_data_for_analytics_and_lpr field to EnterpriseCustomer 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 bb8123527a..e80db0b543 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ django-storages==1.14 # 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.5.7 +edx-enterprise==4.6.0 # django-oauth-toolkit version >=2.0.0 has breaking changes. More details # mentioned on this issue https://github.com/openedx/edx-platform/issues/32884 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 422b76ce06..7abc59820b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -482,7 +482,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.7 +edx-enterprise==4.6.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 ad0a562e10..818bcefe75 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -754,7 +754,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.7 +edx-enterprise==4.6.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 aeb9b1f2be..74b6bd4004 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -558,7 +558,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.7 +edx-enterprise==4.6.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 3dab18dd11..fc8cc6e85c 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -585,7 +585,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.7 +edx-enterprise==4.6.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From b6179696e5c30dce771f8e539152d70ee046e37d Mon Sep 17 00:00:00 2001 From: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Date: Tue, 10 Oct 2023 03:24:00 -0400 Subject: [PATCH 23/86] chore: Updating Python Requirements (#33452) --- requirements/edx-sandbox/py38.txt | 4 +-- requirements/edx/base.txt | 35 ++++++++++++++------------ requirements/edx/development.txt | 41 +++++++++++++++++-------------- requirements/edx/doc.txt | 35 ++++++++++++++------------ requirements/edx/semgrep.txt | 4 +-- requirements/edx/testing.txt | 39 +++++++++++++++-------------- 6 files changed, 85 insertions(+), 73 deletions(-) diff --git a/requirements/edx-sandbox/py38.txt b/requirements/edx-sandbox/py38.txt index 0d8e18f1d0..c4674d259c 100644 --- a/requirements/edx-sandbox/py38.txt +++ b/requirements/edx-sandbox/py38.txt @@ -20,9 +20,9 @@ cryptography==38.0.4 # via # -c requirements/edx-sandbox/../constraints.txt # -r requirements/edx-sandbox/py38.in -cycler==0.12.0 +cycler==0.12.1 # via matplotlib -fonttools==4.43.0 +fonttools==4.43.1 # via matplotlib importlib-resources==6.1.0 # via matplotlib diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 422b76ce06..3336783571 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -8,7 +8,7 @@ # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.8.5 +aiohttp==3.8.6 # via # geoip2 # openai @@ -66,7 +66,7 @@ beautifulsoup4==4.12.2 # via pynliner billiard==4.1.0 # via celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -79,13 +79,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -boto3==1.28.59 +boto3==1.28.62 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.31.59 +botocore==1.31.62 # via # -r requirements/edx/kernel.in # boto3 @@ -180,7 +180,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.2.14 # via jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in @@ -426,7 +426,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/bundled.in # edx-enterprise @@ -490,7 +490,7 @@ edx-event-bus-kafka==5.5.0 # via -r requirements/edx/kernel.in edx-event-bus-redis==0.3.2 # via -r requirements/edx/kernel.in -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via ora2 edx-milestones==0.5.0 # via -r requirements/edx/kernel.in @@ -552,7 +552,7 @@ edx-when==2.4.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via -r requirements/edx/kernel.in elasticsearch==7.13.4 # via @@ -681,6 +681,7 @@ lti-consumer-xblock==9.6.1 lxml==4.9.3 # via # -r requirements/edx/kernel.in + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -696,6 +697,7 @@ mako==1.2.4 # -r requirements/edx/kernel.in # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -774,7 +776,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -937,7 +939,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/kernel.in # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/kernel.in pytz==2022.7.1 # via @@ -1013,11 +1015,11 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.10.3 +rpds-py==0.10.4 # via # jsonschema # referencing -ruamel-yaml==0.17.34 +ruamel-yaml==0.17.35 # via drf-yasg ruamel-yaml-clib==0.2.8 # via ruamel-yaml @@ -1040,11 +1042,12 @@ semantic-version==2.10.0 # via edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/kernel.in -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/kernel.in # sailthru-client # super-csv + # xblock # xblock-utils six==1.16.0 # via @@ -1094,7 +1097,7 @@ social-auth-core==4.3.0 # -r requirements/edx/kernel.in # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki @@ -1128,7 +1131,7 @@ testfixtures==7.2.0 # via edx-enterprise text-unidecode==1.3 # via python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via bleach tomlkit==0.12.1 # via snowflake-connector-python @@ -1203,7 +1206,7 @@ wrapt==1.15.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/kernel.in # acid-xblock diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ad0a562e10..779d2826d2 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -16,7 +16,7 @@ acid-xblock==0.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -50,7 +50,7 @@ aniso8601==9.0.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-tincan-py35 -annotated-types==0.5.0 +annotated-types==0.6.0 # via # -r requirements/edx/testing.txt # pydantic @@ -132,7 +132,7 @@ billiard==4.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -149,14 +149,14 @@ boto==2.39.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.28.59 +boto3==1.28.62 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.59 +botocore==1.31.62 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -339,7 +339,7 @@ distlib==0.3.7 # via # -r requirements/edx/testing.txt # virtualenv -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/doc.txt @@ -685,7 +685,7 @@ edx-auth-backends==4.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -767,7 +767,7 @@ edx-event-bus-redis==0.3.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -857,7 +857,7 @@ edx-when==2.4.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -893,7 +893,7 @@ execnet==2.0.2 # pytest-xdist factory-boy==3.3.0 # via -r requirements/edx/testing.txt -faker==19.6.2 +faker==19.8.0 # via # -r requirements/edx/testing.txt # factory-boy @@ -1146,6 +1146,7 @@ lxml==4.9.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -1165,6 +1166,7 @@ mako==1.2.4 # -r requirements/edx/testing.txt # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -1305,7 +1307,7 @@ openedx-django-wiki==2.0.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1660,7 +1662,7 @@ python3-openid==3.2.0 ; python_version >= "3" # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1764,13 +1766,13 @@ rfc3986[idna2008]==1.5.0 # via # -r requirements/edx/testing.txt # httpx -rpds-py==0.10.3 +rpds-py==0.10.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # jsonschema # referencing -ruamel-yaml==0.17.34 +ruamel-yaml==0.17.35 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1817,12 +1819,13 @@ shapely==2.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # sailthru-client # super-csv + # xblock # xblock-utils singledispatch==4.1.0 # via -r requirements/edx/testing.txt @@ -1900,7 +1903,7 @@ social-auth-core==4.3.0 # -r requirements/edx/testing.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2011,7 +2014,7 @@ text-unidecode==1.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2143,7 +2146,7 @@ voluptuous==0.13.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -vulture==2.9.1 +vulture==2.10 # via -r requirements/edx/development.in walrus==0.9.3 # via @@ -2191,7 +2194,7 @@ wrapt==1.15.0 # -r requirements/edx/testing.txt # astroid # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index aeb9b1f2be..fda28ab2d7 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -10,7 +10,7 @@ accessible-pygments==0.0.4 # via pydata-sphinx-theme acid-xblock==0.2.1 # via -r requirements/edx/base.txt -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/edx/base.txt # geoip2 @@ -92,7 +92,7 @@ billiard==4.1.0 # via # -r requirements/edx/base.txt # celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -105,13 +105,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -boto3==1.28.59 +boto3==1.28.62 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.59 +botocore==1.31.62 # via # -r requirements/edx/base.txt # boto3 @@ -227,7 +227,7 @@ deprecated==1.2.14 # via # -r requirements/edx/base.txt # jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt @@ -502,7 +502,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/base.txt # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/base.txt # edx-enterprise @@ -566,7 +566,7 @@ edx-event-bus-kafka==5.5.0 # via -r requirements/edx/base.txt edx-event-bus-redis==0.3.2 # via -r requirements/edx/base.txt -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via # -r requirements/edx/base.txt # ora2 @@ -633,7 +633,7 @@ edx-when==2.4.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -805,6 +805,7 @@ lti-consumer-xblock==9.6.1 lxml==4.9.3 # via # -r requirements/edx/base.txt + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -820,6 +821,7 @@ mako==1.2.4 # -r requirements/edx/base.txt # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -914,7 +916,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.0.3 # via -r requirements/edx/base.txt -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/base.txt # edx-event-bus-kafka @@ -1117,7 +1119,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/base.txt # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/base.txt pytz==2022.7.1 # via @@ -1198,12 +1200,12 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/base.txt # social-auth-core -rpds-py==0.10.3 +rpds-py==0.10.4 # via # -r requirements/edx/base.txt # jsonschema # referencing -ruamel-yaml==0.17.34 +ruamel-yaml==0.17.35 # via # -r requirements/edx/base.txt # drf-yasg @@ -1237,11 +1239,12 @@ semantic-version==2.10.0 # edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/base.txt -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/base.txt # sailthru-client # super-csv + # xblock # xblock-utils six==1.16.0 # via @@ -1298,7 +1301,7 @@ social-auth-core==4.3.0 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1377,7 +1380,7 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via # -r requirements/edx/base.txt # bleach @@ -1468,7 +1471,7 @@ wrapt==1.15.0 # via # -r requirements/edx/base.txt # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/base.txt # acid-xblock diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 9abf4935ab..31dcbbf984 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -70,11 +70,11 @@ requests==2.31.0 # via semgrep rich==13.6.0 # via semgrep -rpds-py==0.10.3 +rpds-py==0.10.4 # via # jsonschema # referencing -ruamel-yaml==0.17.34 +ruamel-yaml==0.17.35 # via semgrep ruamel-yaml-clib==0.2.8 # via ruamel-yaml diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 3dab18dd11..e59f34f5ef 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -8,7 +8,7 @@ # via -r requirements/edx/base.txt acid-xblock==0.2.1 # via -r requirements/edx/base.txt -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/edx/base.txt # geoip2 @@ -31,7 +31,7 @@ aniso8601==9.0.1 # via # -r requirements/edx/base.txt # edx-tincan-py35 -annotated-types==0.5.0 +annotated-types==0.6.0 # via pydantic anyio==3.7.1 # via @@ -97,7 +97,7 @@ billiard==4.1.0 # via # -r requirements/edx/base.txt # celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -112,13 +112,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -boto3==1.28.59 +boto3==1.28.62 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.59 +botocore==1.31.62 # via # -r requirements/edx/base.txt # boto3 @@ -260,7 +260,7 @@ dill==0.3.7 # via pylint distlib==0.3.7 # via virtualenv -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt @@ -529,7 +529,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/base.txt # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/base.txt # edx-enterprise @@ -593,7 +593,7 @@ edx-event-bus-kafka==5.5.0 # via -r requirements/edx/base.txt edx-event-bus-redis==0.3.2 # via -r requirements/edx/base.txt -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via # -r requirements/edx/base.txt # -r requirements/edx/testing.in @@ -663,7 +663,7 @@ edx-when==2.4.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -689,7 +689,7 @@ execnet==2.0.2 # via pytest-xdist factory-boy==3.3.0 # via -r requirements/edx/testing.in -faker==19.6.2 +faker==19.8.0 # via factory-boy fastapi==0.103.2 # via pact-python @@ -870,6 +870,7 @@ lti-consumer-xblock==9.6.1 lxml==4.9.3 # via # -r requirements/edx/base.txt + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -886,6 +887,7 @@ mako==1.2.4 # -r requirements/edx/base.txt # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -981,7 +983,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.0.3 # via -r requirements/edx/base.txt -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/base.txt # edx-event-bus-kafka @@ -1250,7 +1252,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/base.txt # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/base.txt pytz==2022.7.1 # via @@ -1332,12 +1334,12 @@ requests-oauthlib==1.3.1 # social-auth-core rfc3986[idna2008]==1.5.0 # via httpx -rpds-py==0.10.3 +rpds-py==0.10.4 # via # -r requirements/edx/base.txt # jsonschema # referencing -ruamel-yaml==0.17.34 +ruamel-yaml==0.17.35 # via # -r requirements/edx/base.txt # drf-yasg @@ -1375,11 +1377,12 @@ semantic-version==2.10.0 # edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/base.txt -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/base.txt # sailthru-client # super-csv + # xblock # xblock-utils singledispatch==4.1.0 # via -r requirements/edx/testing.in @@ -1441,7 +1444,7 @@ social-auth-core==4.3.0 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1487,7 +1490,7 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via # -r requirements/edx/base.txt # bleach @@ -1612,7 +1615,7 @@ wrapt==1.15.0 # -r requirements/edx/base.txt # astroid # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/base.txt # acid-xblock From 1db6867edfdf06856d2576504dede908bee6a893 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Tue, 10 Oct 2023 18:29:12 +0500 Subject: [PATCH 24/86] fix: replace py2neo with forked package (#33453) * fix: replace py2neo with overhangio py2neo fork --- requirements/edx/base.txt | 2 +- requirements/edx/bundled.in | 6 +++++- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 656523e466..2438510c27 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -845,7 +845,7 @@ psutil==5.9.5 # via # -r requirements/edx/paver.txt # edx-django-utils -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in diff --git a/requirements/edx/bundled.in b/requirements/edx/bundled.in index e115f667df..b8ada003ca 100644 --- a/requirements/edx/bundled.in +++ b/requirements/edx/bundled.in @@ -20,7 +20,11 @@ # 4. If the package is not needed in production, add it to another file such # as development.in or testing.in instead. -py2neo # Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph). +# Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph). +# Using the fork because official package has been removed from PyPI/GitHub +# Follow up issue to remove this fork: https://github.com/openedx/edx-platform/issues/33456 +https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz + pygments # Used to support colors in paver command output ## Third party integrations diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 48b940775f..95c68b314c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1445,7 +1445,7 @@ py==1.11.0 # via # -r requirements/edx/testing.txt # tox -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 06e2ac85e4..daf088184c 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1004,7 +1004,7 @@ psutil==5.9.5 # via # -r requirements/edx/base.txt # edx-django-utils -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index ef495fc689..a728884e70 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1084,7 +1084,7 @@ psutil==5.9.5 # pytest-xdist py==1.11.0 # via tox -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 565b34e4e0904d1a55e56db403893a207c62e4c3 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 10 Oct 2023 21:03:23 +0530 Subject: [PATCH 25/86] feat: allow oauth configuration per site and backend (#32656) Allows admins to configure same oauth backend for multiple sites. This change includes site_id in KEY_FIELDS for oauth configuration provider allowing a backend configuration for each site. --- common/djangoapps/third_party_auth/models.py | 30 +++++++-- .../djangoapps/third_party_auth/pipeline.py | 4 +- .../third_party_auth/tests/test_provider.py | 62 ++++++++++++++++--- common/djangoapps/third_party_auth/views.py | 2 +- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index af5159764c..1ea265e868 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -366,13 +366,12 @@ class OAuth2ProviderConfig(ProviderConfig): .. no_pii: """ - # We are keying the provider config by backend_name here as suggested in the python social - # auth documentation. In order to reuse a backend for a second provider, a subclass can be - # created with seperate name. + # We are keying the provider config by backend_name and site_id to support configuration per site. + # In order to reuse a backend for a second provider, a subclass can be created with seperate name. # example: # class SecondOpenIDProvider(OpenIDAuth): # name = "second-openId-provider" - KEY_FIELDS = ('backend_name',) + KEY_FIELDS = ('site_id', 'backend_name') prefix = 'oa2' backend_name = models.CharField( max_length=50, blank=False, db_index=True, @@ -401,6 +400,29 @@ class OAuth2ProviderConfig(ProviderConfig): verbose_name = "Provider Configuration (OAuth)" verbose_name_plural = verbose_name + @classmethod + def current(cls, *args): + """ + Get the current config model for the provider according to the given backend and the current + site. + """ + site_id = Site.objects.get_current(get_current_request()).id + return super(OAuth2ProviderConfig, cls).current(site_id, *args) + + @property + def provider_id(self): + """ + Unique string key identifying this provider. Must be URL and css class friendly. + Ignoring site_id as the config is filtered using current method which fetches the configuration for the current + site_id. + """ + assert self.prefix is not None + return "-".join((self.prefix, ) + tuple( + str(getattr(self, field)) + for field in self.KEY_FIELDS + if field != 'site_id' + )) + def clean(self): """ Standardize and validate fields """ super().clean() diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 9135ea556b..fde2bb9cbc 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -854,7 +854,7 @@ def user_details_force_sync(auth_entry, strategy, details, user=None, *args, **k This step is controlled by the `sync_learner_profile_data` flag on the provider's configuration. """ current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs}) - if user and current_provider.sync_learner_profile_data: + if user and current_provider and current_provider.sync_learner_profile_data: # Keep track of which incoming values get applied. changed = {} @@ -931,7 +931,7 @@ def set_id_verification_status(auth_entry, strategy, details, user=None, *args, Use the user's authentication with the provider, if configured, as evidence of their identity being verified. """ current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs}) - if user and current_provider.enable_sso_id_verification: + if user and current_provider and current_provider.enable_sso_id_verification: # Get previous valid, non expired verification attempts for this SSO Provider and user verifications = SSOVerification.objects.filter( user=user, diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py index 3fa8f80f4d..28e95c16b8 100644 --- a/common/djangoapps/third_party_auth/tests/test_provider.py +++ b/common/djangoapps/third_party_auth/tests/test_provider.py @@ -11,7 +11,9 @@ from django.test.utils import CaptureQueriesContext from common.djangoapps.third_party_auth import provider from common.djangoapps.third_party_auth.tests import testutil from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth -from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration +from openedx.core.djangoapps.site_configuration.tests.test_util import ( + with_site_configuration, with_site_configuration_context +) SITE_DOMAIN_A = 'professionalx.example.com' SITE_DOMAIN_B = 'somethingelse.example.com' @@ -114,13 +116,13 @@ class RegistryTest(testutil.TestCase): assert no_log_in_provider.provider_id not in provider_ids assert normal_provider.provider_id in provider_ids - def test_tpa_hint_provider_displayed_for_login(self): + def test_tpa_hint_exp_hidden_provider_displayed_for_login(self): """ - Tests to ensure that an enabled-but-not-visible provider is presented + Test to ensure that an explicitly enabled-but-not-visible provider is presented for use in the UI when the "tpa_hint" parameter is specified + A hidden provider should be accessible with tpa_hint (this is the main case) """ - # A hidden provider should be accessible with tpa_hint (this is the main case) hidden_provider = self.configure_google_provider(visible=False, enabled=True) provider_ids = [ idp.provider_id @@ -128,8 +130,14 @@ class RegistryTest(testutil.TestCase): ] assert hidden_provider.provider_id in provider_ids - # New providers are hidden (ie, not flagged as 'visible') by default - # The tpa_hint parameter should work for these providers as well + def test_tpa_hint_hidden_provider_displayed_for_login(self): + """ + Tests to ensure that an implicitly enabled-but-not-visible provider is presented + for use in the UI when the "tpa_hint" parameter is specified. + New providers are hidden (ie, not flagged as 'visible') by default + The tpa_hint parameter should work for these providers as well. + """ + implicitly_hidden_provider = self.configure_linkedin_provider(enabled=True) provider_ids = [ idp.provider_id @@ -137,7 +145,10 @@ class RegistryTest(testutil.TestCase): ] assert implicitly_hidden_provider.provider_id in provider_ids - # Disabled providers should not be matched in tpa_hint scenarios + def test_tpa_hint_disabled_hidden_provider_displayed_for_login(self): + """ + Disabled providers should not be matched in tpa_hint scenarios + """ disabled_provider = self.configure_twitter_provider(visible=True, enabled=False) provider_ids = [ idp.provider_id @@ -145,7 +156,10 @@ class RegistryTest(testutil.TestCase): ] assert disabled_provider.provider_id not in provider_ids - # Providers not utilized for learner authentication should not match tpa_hint + def test_tpa_hint_no_log_hidden_provider_displayed_for_login(self): + """ + Providers not utilized for learner authentication should not match tpa_hint + """ no_log_in_provider = self.configure_lti_provider() provider_ids = [ idp.provider_id @@ -153,6 +167,30 @@ class RegistryTest(testutil.TestCase): ] assert no_log_in_provider.provider_id not in provider_ids + def test_get_current_site_oauth_provider(self): + """ + Verify that correct provider for current site is returned even if same backend is used for multiple sites. + """ + site_a = Site.objects.get_or_create(domain=SITE_DOMAIN_A, name=SITE_DOMAIN_A)[0] + site_b = Site.objects.get_or_create(domain=SITE_DOMAIN_B, name=SITE_DOMAIN_B)[0] + site_a_provider = self.configure_google_provider(visible=True, enabled=True, site=site_a) + site_b_provider = self.configure_google_provider(visible=True, enabled=True, site=site_b) + with with_site_configuration_context(domain=SITE_DOMAIN_A): + assert site_a_provider.enabled_for_current_site is True + + # Registry.displayed_for_login gets providers enabled for current site + provider_ids = provider.Registry.displayed_for_login() + # Google oauth provider for current site should be displayed + assert site_a_provider in provider_ids + assert site_b_provider not in provider_ids + + # Similarly, the other site should only see its own providers + with with_site_configuration_context(domain=SITE_DOMAIN_B): + assert site_b_provider.enabled_for_current_site is True + provider_ids = provider.Registry.displayed_for_login() + assert site_b_provider in provider_ids + assert site_a_provider not in provider_ids + def test_provider_enabled_for_current_site(self): """ Verify that enabled_for_current_site returns True when the provider matches the current site. @@ -201,7 +239,7 @@ class RegistryTest(testutil.TestCase): def test_get_returns_none_if_provider_id_is_none(self): assert provider.Registry.get(None) is None - def test_get_returns_none_if_provider_not_enabled(self): + def test_get_returns_none_if_provider_not_enabled_change(self): linkedin_provider_id = "oa2-linkedin-oauth2" # At this point there should be no configuration entries at all so no providers should be enabled assert provider.Registry.enabled() == [] @@ -209,6 +247,12 @@ class RegistryTest(testutil.TestCase): # Now explicitly disabled this provider: self.configure_linkedin_provider(enabled=False) assert provider.Registry.get(linkedin_provider_id) is None + + def test_get_returns_provider_if_provider_enabled(self): + """ + Test to ensure that Registry gets enabled providers. + """ + linkedin_provider_id = "oa2-linkedin-oauth2" self.configure_linkedin_provider(enabled=True) assert provider.Registry.get(linkedin_provider_id).provider_id == linkedin_provider_id diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index c6a406c530..d24ba8cfd7 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -47,7 +47,7 @@ def inactive_user_view(request): if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) - if third_party_provider.skip_email_verification and not activated: + if third_party_provider and third_party_provider.skip_email_verification and not activated: user.is_active = True user.save() activated = True From d6e21a1c2982002f609bb30dca226cfcae678bd6 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 10 Oct 2023 10:44:10 -0700 Subject: [PATCH 26/86] feat: remove Taxonomy.required, squash taxonomy migrations (#33438) * feat: remove Taxonomy.required, make allow_multiple True by default * chore: squash migrations * fix: more robust migration Co-authored-by: Jillian * chore: version bump, use squashed migration --------- Co-authored-by: Jillian --- .../core/djangoapps/content_tagging/api.py | 2 - .../migrations/0001_squashed.py | 54 +++++++++++++++++++ .../migrations/0003_system_defined_fixture.py | 6 --- .../migrations/0004_system_defined_org.py | 9 ++-- .../migrations/0007_system_defined_org_2.py | 28 ++++++++++ .../rest_api/v1/tests/test_views.py | 13 ++--- .../content_tagging/tests/test_tasks.py | 45 ++++++++++------ requirements/constraints.txt | 2 +- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 12 files changed, 126 insertions(+), 41 deletions(-) create mode 100644 openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py create mode 100644 openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index 4ff21dff3f..4867160c1b 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -18,7 +18,6 @@ def create_taxonomy( name: str, description: str = None, enabled=True, - required=False, allow_multiple=False, allow_free_text=False, ) -> Taxonomy: @@ -29,7 +28,6 @@ def create_taxonomy( name=name, description=description, enabled=enabled, - required=required, allow_multiple=allow_multiple, allow_free_text=allow_free_text, ) diff --git a/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py new file mode 100644 index 0000000000..fa00df307c --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.21 on 2023-10-09 23:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [ + ('content_tagging', '0001_initial'), + ('content_tagging', '0002_system_defined_taxonomies'), + ('content_tagging', '0003_system_defined_fixture'), + ('content_tagging', '0004_system_defined_org'), + ('content_tagging', '0005_auto_20230830_1517'), + ('content_tagging', '0006_simplify_models'), + ] + + initial = True + + dependencies = [ + ("oel_tagging", "0001_squashed"), + ('organizations', '0003_historicalorganizationcourse'), + ] + + operations = [ + migrations.CreateModel( + name='ContentObjectTag', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.objecttag',), + ), + migrations.CreateModel( + name='TaxonomyOrg', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rel_type', models.CharField(choices=[('OWN', 'owner')], default='OWN', max_length=3)), + ('org', models.ForeignKey(default=None, help_text='Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.', null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.organization')), + ('taxonomy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_tagging.taxonomy')), + ], + ), + migrations.AddIndex( + model_name='taxonomyorg', + index=models.Index(fields=['taxonomy', 'rel_type'], name='content_tag_taxonom_b04dd1_idx'), + ), + migrations.AddIndex( + model_name='taxonomyorg', + index=models.Index(fields=['taxonomy', 'rel_type', 'org'], name='content_tag_taxonom_70d60b_idx'), + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py index 7846b907c4..ff855482e1 100644 --- a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py +++ b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py @@ -38,12 +38,6 @@ def load_system_defined_taxonomies(apps, schema_editor): org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy org_taxonomy.save() - # Adding taxonomy class to the language taxonomy - language_taxonomy = Taxonomy.objects.get(id=-1) - ContentLanguageTaxonomy = apps.get_model("content_tagging", "ContentLanguageTaxonomy") - language_taxonomy.taxonomy_class = ContentLanguageTaxonomy - language_taxonomy.save() - def revert_system_defined_taxonomies(apps, schema_editor): """ diff --git a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py index 852d67ae4a..a60ef381cd 100644 --- a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py +++ b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py @@ -6,8 +6,9 @@ def load_system_defined_org_taxonomies(apps, _schema_editor): Associates the system defined taxonomy Language (id=-1) to all orgs and removes the ContentOrganizationTaxonomy (id=-3) from the database """ - TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") - TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) + # Disabled for now as the way that this taxonomy is created has changed. + # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + # TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) Taxonomy = apps.get_model("oel_tagging", "Taxonomy") Taxonomy.objects.get(id=-3).delete() @@ -20,8 +21,8 @@ def revert_system_defined_org_taxonomies(apps, _schema_editor): Deletes association of system defined taxonomy Language (id=-1) to all orgs and creates the ContentOrganizationTaxonomy (id=-3) in the database """ - TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") - TaxonomyOrg.objects.get(id=-1).delete() + # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + # TaxonomyOrg.objects.get(id=-1).delete() Taxonomy = apps.get_model("oel_tagging", "Taxonomy") org_taxonomy = Taxonomy( diff --git a/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py new file mode 100644 index 0000000000..0a48016ca2 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py @@ -0,0 +1,28 @@ +from django.db import migrations + + +def mark_language_taxonomy_as_all_orgs(apps, _schema_editor): + """ + Associates the system defined taxonomy Language (id=-1) to all orgs. + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.update_or_create(taxonomy_id=-1, defaults={"org": None}) + + +def revert_mark_language_taxonomy_as_all_orgs(apps, _schema_editor): + """ + Deletes association of system defined taxonomy Language (id=-1) to all orgs. + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.get(taxonomy_id=-1, org=None).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('content_tagging', '0001_squashed'), + ("oel_tagging", "0012_language_taxonomy"), + ] + + operations = [ + migrations.RunPython(mark_language_taxonomy_as_all_orgs, revert_mark_language_taxonomy_as_all_orgs), + ] diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 2eafb933b4..8e57c1546f 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -33,8 +33,7 @@ def check_taxonomy( name, description=None, enabled=True, - required=False, - allow_multiple=False, + allow_multiple=True, allow_free_text=False, system_defined=False, visible_to_authors=True, @@ -47,7 +46,6 @@ def check_taxonomy( assert data["name"] == name assert data["description"] == description assert data["enabled"] == enabled - assert data["required"] == required assert data["allow_multiple"] == allow_multiple assert data["allow_free_text"] == allow_free_text assert data["system_defined"] == system_defined @@ -350,7 +348,6 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): "name": "taxonomy_data", "description": "This is a description", "enabled": True, - "required": True, "allow_multiple": True, } @@ -444,7 +441,6 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): "name": "new name", "description": taxonomy.description, "enabled": taxonomy.enabled, - "required": taxonomy.required, }, ) @@ -540,7 +536,6 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): "name": "new name", "description": taxonomy.description, "enabled": taxonomy.enabled, - "required": taxonomy.required, }, ) @@ -668,13 +663,13 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): ) self.multiple_taxonomy = Taxonomy.objects.create(name="Multiple Taxonomy", allow_multiple=True) - self.required_taxonomy = Taxonomy.objects.create(name="Required Taxonomy", required=True) + self.single_value_taxonomy = Taxonomy.objects.create(name="Required Taxonomy", allow_multiple=False) for i in range(20): # Valid ObjectTags Tag.objects.create(taxonomy=self.tA1, value=f"Tag {i}") Tag.objects.create(taxonomy=self.tA2, value=f"Tag {i}") Tag.objects.create(taxonomy=self.multiple_taxonomy, value=f"Tag {i}") - Tag.objects.create(taxonomy=self.required_taxonomy, value=f"Tag {i}") + Tag.objects.create(taxonomy=self.single_value_taxonomy, value=f"Tag {i}") self.open_taxonomy = Taxonomy.objects.create(name="Enabled Free-Text Taxonomy", allow_free_text=True) @@ -685,7 +680,7 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): rel_type=TaxonomyOrg.RelType.OWNER, ) TaxonomyOrg.objects.create( - taxonomy=self.required_taxonomy, + taxonomy=self.single_value_taxonomy, org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER, ) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py index f3c964b836..39e1742b9e 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py @@ -5,10 +5,9 @@ from __future__ import annotations from unittest.mock import patch -from django.core.management import call_command from django.test import override_settings from edx_toggles.toggles.testutils import override_waffle_flag -from openedx_tagging.core.tagging.models import LanguageTaxonomy, Tag, Taxonomy +from openedx_tagging.core.tagging.models import Tag, Taxonomy from organizations.models import Organization from common.djangoapps.student.tests.factories import UserFactory @@ -16,16 +15,43 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from .. import api -from ..models import TaxonomyOrg +from ..models.base import TaxonomyOrg from ..toggles import CONTENT_TAGGING_AUTO from ..types import ContentKey LANGUAGE_TAXONOMY_ID = -1 +class LanguageTaxonomyTestMixin: + """ + Mixin for test cases that expect the Language System Taxonomy to exist. + """ + + def setUp(self): + """ + When pytest runs, it creates the database by inspecting models, not by + running migrations. So data created by our migrations is not present. + In particular, the Language Taxonomy is not present. So this mixin will + create the taxonomy, simulating the effect of the following migrations: + 1. openedx_tagging.core.tagging.migrations.0012_language_taxonomy + 2. content_tagging.migrations.0007_system_defined_org_2 + """ + super().setUp() + Taxonomy.objects.get_or_create(id=-1, defaults={ + "name": "Languages", + "description": "Languages that are enabled on this system.", + "enabled": True, + "allow_multiple": False, + "allow_free_text": False, + "visible_to_authors": True, + "_taxonomy_class": "openedx_tagging.core.tagging.models.system_defined.LanguageTaxonomy", + }) + TaxonomyOrg.objects.get_or_create(taxonomy_id=-1, defaults={"org": None}) + + @skip_unless_cms # Auto-tagging is only available in the CMS @override_waffle_flag(CONTENT_TAGGING_AUTO, active=True) -class TestAutoTagging(ModuleStoreTestCase): +class TestAutoTagging(LanguageTaxonomyTestMixin, ModuleStoreTestCase): """ Test if the Course and XBlock tags are automatically created """ @@ -51,17 +77,6 @@ class TestAutoTagging(ModuleStoreTestCase): return True - @classmethod - def setUpClass(cls): - # Run fixtures to create the system defined tags - call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml") - - # Enable Language taxonomy for all orgs - language_taxonomy = LanguageTaxonomy.objects.get(id=LANGUAGE_TAXONOMY_ID) - TaxonomyOrg.objects.create(taxonomy=language_taxonomy, org=None) - - super().setUpClass() - def setUp(self): super().setUp() # Create user diff --git a/requirements/constraints.txt b/requirements/constraints.txt index e80db0b543..c9dfcc927e 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -121,7 +121,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.2.0 +openedx-learning==0.2.3 # lti-consumer-xblock 9.6.2 contains a breaking change that makes # existing custom parameter configurations unusable. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 2438510c27..60dd1232f6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -785,7 +785,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.2.0 +openedx-learning==0.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 95c68b314c..595ebb3e87 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1318,7 +1318,7 @@ openedx-filters==1.6.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock -openedx-learning==0.2.0 +openedx-learning==0.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index daf088184c..3b6aa57299 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -925,7 +925,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock -openedx-learning==0.2.0 +openedx-learning==0.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index a728884e70..125de02350 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -992,7 +992,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock -openedx-learning==0.2.0 +openedx-learning==0.2.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt From 1e2ea85372cf777122cb1f13c7c6a63c982b8790 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 29 Sep 2023 13:24:31 -0400 Subject: [PATCH 27/86] test: Update more tests that had short passwords. --- .../tests/test_course_create_rerun.py | 2 +- .../contentstore/tests/test_course_listing.py | 4 +- .../tests/test_course_settings.py | 4 +- .../contentstore/tests/test_i18n.py | 2 +- .../tests/test_users_default_role.py | 2 +- cms/djangoapps/contentstore/tests/tests.py | 2 +- .../views/tests/test_certificates.py | 10 ++-- .../views/tests/test_organizations.py | 5 +- cms/djangoapps/maintenance/tests.py | 16 +++--- .../course_modes/tests/test_admin.py | 2 +- common/djangoapps/student/tests/factories.py | 4 +- .../student/tests/test_admin_views.py | 17 ++++--- .../student/tests/test_bulk_email_settings.py | 2 +- .../djangoapps/student/tests/test_filters.py | 2 +- .../student/tests/test_retirement.py | 2 +- .../student/tests/test_userstanding.py | 2 +- common/djangoapps/student/tests/test_views.py | 7 ++- common/djangoapps/student/tests/tests.py | 29 ++++++----- .../third_party_auth/tests/specs/base.py | 2 +- .../third_party_auth/tests/test_admin.py | 7 ++- lms/djangoapps/badges/api/tests.py | 2 +- .../bulk_email/tests/test_course_optout.py | 10 ++-- lms/djangoapps/bulk_email/tests/test_email.py | 2 +- .../bulk_email/tests/test_err_handling.py | 2 +- .../bulk_email/tests/test_signals.py | 4 +- lms/djangoapps/ccx/api/v0/tests/test_views.py | 8 +-- lms/djangoapps/ccx/tests/test_views.py | 31 ++++++------ lms/djangoapps/ccx/tests/utils.py | 6 +-- .../commerce/api/v1/tests/test_views.py | 2 +- lms/djangoapps/commerce/tests/test_views.py | 5 +- .../course_api/blocks/tests/test_views.py | 14 +++--- .../transformers/tests/helpers.py | 4 +- lms/djangoapps/course_wiki/tests/tests.py | 2 +- lms/djangoapps/courseware/tests/helpers.py | 4 +- lms/djangoapps/courseware/tests/test_about.py | 2 +- .../courseware/tests/test_access.py | 6 +-- .../courseware/tests/test_block_render.py | 4 +- .../courseware/tests/test_course_survey.py | 2 +- .../tests/test_discussion_xblock.py | 2 +- .../courseware/tests/test_entrance_exam.py | 6 +-- .../courseware/tests/test_masquerade.py | 10 ++-- .../courseware/tests/test_split_module.py | 4 +- .../tests/test_submitting_problems.py | 2 +- lms/djangoapps/courseware/tests/test_tabs.py | 6 +-- .../tests/test_view_authentication.py | 4 +- lms/djangoapps/courseware/tests/test_views.py | 28 +++++------ lms/djangoapps/courseware/testutils.py | 2 +- .../django_comment_client/base/tests.py | 4 +- .../discussion/rest_api/tests/test_views.py | 16 +++--- lms/djangoapps/discussion/tests/test_views.py | 22 ++++---- .../grades/rest_api/v1/tests/mixins.py | 2 +- .../grades/rest_api/v1/tests/test_views.py | 2 +- .../grades/tests/integration/test_access.py | 4 +- .../grades/tests/integration/test_events.py | 4 +- .../grades/tests/integration/test_problems.py | 10 ++-- lms/djangoapps/instructor/tests/test_api.py | 50 +++++++++---------- .../tests/test_api_email_localization.py | 2 +- .../instructor/tests/test_certificates.py | 40 +++++++-------- lms/djangoapps/instructor/tests/test_email.py | 4 +- .../instructor/tests/test_filters.py | 2 +- .../instructor/tests/test_proctoring.py | 2 +- .../instructor/tests/test_spoc_gradebook.py | 2 +- .../tests/views/test_instructor_dashboard.py | 14 +++--- .../rest_api/v1/tests/test_views.py | 22 ++++---- .../instructor_task/tests/test_base.py | 2 +- .../api/v0/tests/test_views.py | 6 +-- .../learner_dashboard/tests/test_programs.py | 11 ++-- .../learner_dashboard/tests/test_views.py | 7 ++- .../tests/test_views.py | 12 +++-- lms/djangoapps/mobile_api/testutils.py | 2 +- lms/djangoapps/staticbook/tests.py | 2 +- lms/djangoapps/survey/tests/test_models.py | 2 +- lms/djangoapps/survey/tests/test_utils.py | 2 +- lms/djangoapps/survey/tests/test_views.py | 2 +- .../verify_student/tests/test_views.py | 16 +++--- .../contentserver/test/test_contentserver.py | 14 +++--- .../course_apps/rest_api/tests/test_views.py | 2 +- .../notifications/tests/test_views.py | 37 +++++++------- .../tests/test_dot_overrides.py | 19 ++++--- .../oauth_dispatch/tests/test_views.py | 11 ++-- .../safe_sessions/tests/test_middleware.py | 12 +++-- .../tests/test_theme_style_overrides.py | 5 +- .../djangoapps/theming/tests/test_views.py | 2 +- .../user_api/accounts/tests/test_api.py | 2 +- .../djangoapps/user_api/tests/test_views.py | 1 + .../verification_api/tests/test_views.py | 2 +- .../user_authn/api/tests/test_views.py | 4 +- .../core/djangoapps/user_authn/tests/utils.py | 2 +- .../user_authn/views/tests/test_password.py | 8 +-- .../tests/test_course_expiration.py | 10 ++-- .../tests/views/test_course_sock.py | 2 +- .../tests/views/test_masquerade.py | 2 +- .../test_crowdsource_hinter.py | 4 +- .../xblock_integration/test_recommender.py | 6 +-- .../xblock_integration/xblock_testcase.py | 6 +-- xmodule/modulestore/tests/django_utils.py | 2 +- 96 files changed, 365 insertions(+), 351 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 264d6de8ff..c8d8c6e8d7 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -51,7 +51,7 @@ class TestCourseListing(ModuleStoreTestCase): self.factory = RequestFactory() self.global_admin = AdminFactory() self.client = AjaxEnabledTestClient() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.course_create_rerun_url = reverse('course_handler') self.course_start = datetime.datetime.utcnow() self.course_end = self.course_start + datetime.timedelta(days=30) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 461a1281f5..66c16dc6dd 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -56,12 +56,12 @@ class TestCourseListing(ModuleStoreTestCase): super().setUp() # create and log in a staff user. # create and log in a non-staff user - self.user = UserFactory() + self.user = UserFactory(password=self.TEST_PASSWORD) self.factory = RequestFactory() self.request = self.factory.get('/course') self.request.user = self.user self.client = AjaxEnabledTestClient() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def _create_course_with_access_groups(self, course_location, user=None): """ diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 605e0d2133..f19f35efe2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1866,10 +1866,10 @@ id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete= """ Return the course details page as either global or non-global staff """ - user = UserFactory(is_staff=global_staff) + user = UserFactory(is_staff=global_staff, password=self.TEST_PASSWORD) CourseInstructorRole(self.course.id).add_users(user) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get_html(self.course_details_url) diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index dc77403071..be2003f981 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -184,7 +184,7 @@ class InternationalizationTest(ModuleStoreTestCase): self.uname = 'testuser' self.email = 'test+courses@edx.org' - self.password = 'foo' + self.password = 'password' # Create the use so we can log them in. self.user = UserFactory.create(username=self.uname, email=self.email, password=self.password) diff --git a/cms/djangoapps/contentstore/tests/test_users_default_role.py b/cms/djangoapps/contentstore/tests/test_users_default_role.py index 590efc0bff..1abf0a0ec6 100644 --- a/cms/djangoapps/contentstore/tests/test_users_default_role.py +++ b/cms/djangoapps/contentstore/tests/test_users_default_role.py @@ -27,7 +27,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase): # create and log in a staff user. self.user = UserFactory(is_staff=True) self.client = AjaxEnabledTestClient() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # create a course via the view handler to create course self.course_key = self.store.make_course_key('Org_1', 'Course_1', 'Run_1') diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index f212bc39f1..bd2c3dbfd3 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -95,7 +95,7 @@ class AuthTestCase(ContentStoreTestCase): super().setUp() self.email = 'a@b.com' - self.pw = 'xyz' + self.pw = 'password1234' self.username = 'testuser' self.client = AjaxEnabledTestClient() # clear the cache so ratelimiting won't affect these tests diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index 76dfb3be95..7af7a448de 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -249,7 +249,7 @@ class CertificatesListHandlerTestCase( Tests user without write permissions on course should not able to create certificate """ user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.ajax_post( self._url(), data=CERTIFICATE_JSON @@ -635,7 +635,7 @@ class CertificatesDetailHandlerTestCase( """ self._add_course_certificates(count=2, signatory_count=1, asset_path_format=signatory_path) user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.delete( self._url(cid=1), content_type="application/json", @@ -653,7 +653,7 @@ class CertificatesDetailHandlerTestCase( user = UserFactory() for role in [CourseInstructorRole, CourseStaffRole]: role(self.course.id).add_users(user) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.delete( self._url(cid=1), content_type="application/json", @@ -681,7 +681,7 @@ class CertificatesDetailHandlerTestCase( user = UserFactory() for role in [CourseInstructorRole, CourseStaffRole]: role(self.course.id).add_users(user) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.put( self._url(cid=1), data=json.dumps(cert_data), @@ -799,7 +799,7 @@ class CertificatesDetailHandlerTestCase( test_url = reverse_course_url('certificate_activation_handler', self.course.id) self._add_course_certificates(count=1, signatory_count=2, asset_path_format=signatory_path) user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.post( test_url, data=json.dumps({"is_active": activate}), diff --git a/cms/djangoapps/contentstore/views/tests/test_organizations.py b/cms/djangoapps/contentstore/views/tests/test_organizations.py index 6f3b676711..cf3a376f34 100644 --- a/cms/djangoapps/contentstore/views/tests/test_organizations.py +++ b/cms/djangoapps/contentstore/views/tests/test_organizations.py @@ -14,8 +14,9 @@ class TestOrganizationListing(TestCase): """Verify Organization listing behavior.""" def setUp(self): super().setUp() - self.staff = UserFactory(is_staff=True) - self.client.login(username=self.staff.username, password='test') + self.password = "password1234" + self.staff = UserFactory(is_staff=True, password=self.password) + self.client.login(username=self.staff.username, password=self.password) self.org_names_listing_url = reverse('organizations') self.org_short_names = ["alphaX", "betaX", "orgX"] for index, short_name in enumerate(self.org_short_names): diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py index dd060c4c01..a487f8e37f 100644 --- a/cms/djangoapps/maintenance/tests.py +++ b/cms/djangoapps/maintenance/tests.py @@ -30,7 +30,7 @@ class TestMaintenanceIndex(ModuleStoreTestCase): def setUp(self): super().setUp() self.user = AdminFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.assertTrue(login_success) self.view_url = reverse('maintenance:maintenance_index') @@ -56,7 +56,7 @@ class MaintenanceViewTestCase(ModuleStoreTestCase): def setUp(self): super().setUp() self.user = AdminFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.assertTrue(login_success) def verify_error_message(self, data, error_message): @@ -110,8 +110,8 @@ class MaintenanceViewAccessTests(MaintenanceViewTestCase): """ Test that all maintenance app views are not accessible to non-global-staff user. """ - user = UserFactory(username='test', email='test@example.com', password='test') - login_success = self.client.login(username=user.username, password='test') + user = UserFactory(username='test', email='test@example.com', password=self.TEST_PASSWORD) + login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD) self.assertTrue(login_success) response = self.client.get(url) @@ -245,13 +245,13 @@ class TestAnnouncementsViews(MaintenanceViewTestCase): self.admin = AdminFactory.create( email='staff@edx.org', username='admin', - password='pass' + password=self.TEST_PASSWORD ) - self.client.login(username=self.admin.username, password='pass') + self.client.login(username=self.admin.username, password=self.TEST_PASSWORD) self.non_staff_user = UserFactory.create( email='test@edx.org', username='test', - password='pass' + password=self.TEST_PASSWORD ) def test_index(self): @@ -301,7 +301,7 @@ class TestAnnouncementsViews(MaintenanceViewTestCase): self.assertEqual(response.status_code, 403) def test_authorization(self): - self.client.login(username=self.non_staff_user, password='pass') + self.client.login(username=self.non_staff_user, password=self.TEST_PASSWORD) announcement = Announcement.objects.create(content="Test Delete") announcement.save() diff --git a/common/djangoapps/course_modes/tests/test_admin.py b/common/djangoapps/course_modes/tests/test_admin.py index 52640dc52f..25b6558d24 100644 --- a/common/djangoapps/course_modes/tests/test_admin.py +++ b/common/djangoapps/course_modes/tests/test_admin.py @@ -54,7 +54,7 @@ class AdminCourseModePageTest(ModuleStoreTestCase): '_expiration_datetime_1': expiration.time(), } - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) # Create a new course mode from django admin page response = self.client.post(reverse('admin:course_modes_coursemode_add'), data=data) diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 7080824125..506d1818e4 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -35,7 +35,7 @@ from common.djangoapps.student.roles import OrgStaffRole from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -TEST_PASSWORD = 'test' +TEST_PASSWORD = 'password' class GroupFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring @@ -81,7 +81,7 @@ class UserFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing- model = User django_get_or_create = ('email', 'username') - _DEFAULT_PASSWORD = 'test' + _DEFAULT_PASSWORD = 'password' username = factory.Sequence('robot{}'.format) email = factory.Sequence('robot+test+{}@edx.org'.format) diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 5c6350c2f0..7ae693606d 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -53,7 +53,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'email': self.user.email } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -78,7 +78,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'course_id': str(self.course.id) } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -96,7 +96,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -115,7 +115,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -136,7 +136,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'email': email } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Adding new role with invalid data response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -163,7 +163,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'email': self.user.email } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -230,7 +230,7 @@ class CourseEnrollmentAdminTest(SharedModuleStoreTestCase): user=self.user, course_id=self.course.id, # pylint: disable=no-member ) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) @ddt.data(*ADMIN_URLS) @ddt.unpack @@ -326,11 +326,12 @@ class LoginFailuresAdminTest(TestCase): super().setUpClass() cls.user = UserFactory.create(username='§', is_staff=True, is_superuser=True) cls.user.save() + cls.TEST_PASSWORD = 'password' def setUp(self): """Setup.""" super().setUp() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.user2 = UserFactory.create(username='Zażółć gęślą jaźń') self.user_lockout_until = datetime.datetime.now(UTC) LoginFailures.objects.create(user=self.user, failure_count=10, lockout_until=self.user_lockout_until) diff --git a/common/djangoapps/student/tests/test_bulk_email_settings.py b/common/djangoapps/student/tests/test_bulk_email_settings.py index d9bfd15621..308ccf66e0 100644 --- a/common/djangoapps/student/tests/test_bulk_email_settings.py +++ b/common/djangoapps/student/tests/test_bulk_email_settings.py @@ -33,7 +33,7 @@ class TestStudentDashboardEmailView(SharedModuleStoreTestCase): # Create student account student = UserFactory.create() CourseEnrollmentFactory.create(user=student, course_id=self.course.id) - self.client.login(username=student.username, password="test") + self.client.login(username=student.username, password=self.TEST_PASSWORD) self.url = reverse('dashboard') # URL for email settings modal diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index 6e429fd39a..376595a850 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -316,7 +316,7 @@ class StudentDashboardFiltersTest(ModuleStoreTestCase): def setUp(self): # pylint: disable=arguments-differ super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password="test") + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.dashboard_url = reverse("dashboard") self.first_course = CourseFactory.create( org="test1", course="course1", display_name="run1", diff --git a/common/djangoapps/student/tests/test_retirement.py b/common/djangoapps/student/tests/test_retirement.py index a7e5fac865..96c0034a0e 100644 --- a/common/djangoapps/student/tests/test_retirement.py +++ b/common/djangoapps/student/tests/test_retirement.py @@ -260,7 +260,7 @@ class TestRegisterRetiredUsername(TestCase): 'username': 'username', 'email': 'foo_bar' + '@bar.com', 'name': 'foo bar', - 'password': '123', + 'password': '12345678', 'terms_of_service': 'true', 'honor_code': 'true', } diff --git a/common/djangoapps/student/tests/test_userstanding.py b/common/djangoapps/student/tests/test_userstanding.py index 832127e474..e7b9bab233 100644 --- a/common/djangoapps/student/tests/test_userstanding.py +++ b/common/djangoapps/student/tests/test_userstanding.py @@ -43,7 +43,7 @@ class UserStandingTest(TestCase): (self.non_staff, self.non_staff_client), (self.admin, self.admin_client), ]: - client.login(username=user.username, password='test') + client.login(username=user.username, password='password') UserStandingFactory.create( user=self.bad_user, diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index b9518efa04..1230f8320a 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -49,7 +49,6 @@ from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: d from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -PASSWORD = 'test' TOMORROW = now() + timedelta(days=1) ONE_WEEK_AGO = now() - timedelta(weeks=1) THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) @@ -81,7 +80,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): self.user = UserFactory() self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) self.cert_status = 'processing' - self.client.login(username=self.user.username, password=PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def mock_cert(self, _user, _course_overview): """ Return a preset certificate status. """ @@ -212,7 +211,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, """ super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.path = reverse('dashboard') def set_course_sharing_urls(self, set_marketing, set_social_sharing): @@ -1018,7 +1017,7 @@ class TestCourseDashboardNoticesRedirects(SharedModuleStoreTestCase): def setUp(self): super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.path = reverse('dashboard') def test_check_for_unacknowledged_notices(self): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index b41ad2f856..a90b2504d1 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -278,7 +278,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): def setUp(self): super().setUp() self.course = CourseFactory.create() - self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test') + self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password=self.TEST_PASSWORD) self.client = Client() cache.clear() @@ -307,7 +307,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): """ Test that the certificate verification status for courses is visible on the dashboard. """ - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) self._check_verification_status_on('verified', 'You're enrolled as a verified student') self._check_verification_status_on('honor', 'You're enrolled as an honor code student') self._check_verification_status_off('audit', '') @@ -345,7 +345,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): Test that the certificate verification status for courses is not visible on the dashboard if the verified certificates setting is off. """ - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) self._check_verification_status_off('verified', 'You\'re enrolled as a verified student') self._check_verification_status_off('honor', 'You\'re enrolled as an honor code student') self._check_verification_status_off('audit', '') @@ -371,7 +371,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): @skip_unless_lms def test_linked_in_add_to_profile_btn_not_appearing_without_config(self): # Without linked-in config don't show Add Certificate to LinkedIn button - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) CourseModeFactory.create( course_id=self.course.id, @@ -409,7 +409,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): def test_linked_in_add_to_profile_btn_with_certificate(self): # If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button # should be visible. and it has URL value with valid parameters. - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) linkedin_config = LinkedInAddToProfileConfiguration.objects.create(company_identifier='1337', enabled=True) CourseModeFactory.create( @@ -480,7 +480,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): # Create a course and log in the user. # Creating a new course will trigger a publish event and the course will be cached test_course = CourseFactory.create(emit_signals=True) - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) with check_mongo_calls(0): CourseEnrollment.enroll(self.user, test_course.id) @@ -495,7 +495,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): @skip_unless_lms def test_dashboard_header_nav_has_find_courses(self): - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) response = self.client.get(reverse("dashboard")) # "Explore courses" is shown in the side panel @@ -542,7 +542,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase): super().setUp() self.org = 'fakeX' self.course = CourseFactory.create(org=self.org) - self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password='test') + self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password=self.TEST_PASSWORD) CourseModeFactory.create(mode_slug='no-id-professional', course_id=self.course.id) CourseEnrollment.enroll(self.user, self.course.location.course_key, mode='no-id-professional') cache.clear() @@ -564,7 +564,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase): 'course_org_filter': self.org }) self.set_up_site(site_domain, site_configuration_values) - self.client.login(username='jack', password='test') + self.client.login(username='jack', password=self.TEST_PASSWORD) response = self.client.get(reverse('dashboard')) self.assertContains(response, 'class="course professional"') @@ -585,7 +585,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase): 'course_org_filter': self.org }) self.set_up_site(site_domain, site_configuration_values) - self.client.login(username='jack', password='test') + self.client.login(username='jack', password=self.TEST_PASSWORD) response = self.client.get(reverse('dashboard')) self.assertNotContains(response, 'class="course professional"') @@ -899,8 +899,8 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase): def setUp(self): super().setUp() self.course = CourseFactory.create() - self.user = UserFactory.create(password='secret') - self.client.login(username=self.user.username, password='secret') + self.user = UserFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.url = reverse('change_enrollment') def _enroll_through_view(self, course): @@ -1056,14 +1056,13 @@ class AnonymousLookupTable(ModuleStoreTestCase): class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Tests verifying that related programs appear on the course dashboard.""" maxDiff = None - password = 'test' related_programs_preface = 'Related Programs' @classmethod def setUpClass(cls): super().setUpClass() - cls.user = UserFactory() + cls.user = UserFactory(password=cls.TEST_PASSWORD) cls.course = CourseFactory() cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member @@ -1073,7 +1072,7 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): self.url = reverse('dashboard') self.create_programs_config() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) course_run = CourseRunFactory(key=str(self.course.id)) # pylint: disable=no-member course = CatalogCourseFactory(course_runs=[course_run]) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 116deacf5e..4974627fd0 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -481,7 +481,7 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): # The AJAX on the page will log them in: ajax_login_response = self.client.post( reverse('user_api_login_session', kwargs={'api_version': 'v1'}), - {'email': self.user.email, 'password': 'test'} + {'email': self.user.email, 'password': 'password'} ) assert ajax_login_response.status_code == 200 # Then the AJAX will finish the third party auth: diff --git a/common/djangoapps/third_party_auth/tests/test_admin.py b/common/djangoapps/third_party_auth/tests/test_admin.py index c5481a3a09..a1b2e2235d 100644 --- a/common/djangoapps/third_party_auth/tests/test_admin.py +++ b/common/djangoapps/third_party_auth/tests/test_admin.py @@ -14,6 +14,9 @@ from common.djangoapps.third_party_auth.tests import testutil from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth +TEST_PASSWORD = 'password' + + # This is necessary because cms does not implement third party auth @skip_unless_thirdpartyauth() class Oauth2ProviderConfigAdminTest(testutil.TestCase): @@ -36,9 +39,9 @@ class Oauth2ProviderConfigAdminTest(testutil.TestCase): prepopulated correctly, and that we can clear and update the image. """ # Login as a super user - user = UserFactory.create(is_staff=True, is_superuser=True) + user = UserFactory.create(is_staff=True, is_superuser=True, password=TEST_PASSWORD) user.save() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=TEST_PASSWORD) # Get baseline provider count providers = OAuth2ProviderConfig.objects.all() diff --git a/lms/djangoapps/badges/api/tests.py b/lms/djangoapps/badges/api/tests.py index e0031c29de..cadb30d11a 100644 --- a/lms/djangoapps/badges/api/tests.py +++ b/lms/djangoapps/badges/api/tests.py @@ -29,7 +29,7 @@ class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase): self.course = CourseFactory.create() self.user = UserFactory.create() # Password defined by factory. - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def url(self): """ diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index a618545bcd..fe0f70fbdc 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -40,7 +40,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.send_mail_url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) self.success_content = { @@ -70,7 +70,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.client.logout() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.navigate_to_email_view() test_email = { @@ -92,7 +92,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): """ self.client.logout() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) unsubscribe_link = get_unsubscribed_link(self.student.username, str(self.course.id)) response = self.client.post(unsubscribe_link, {'unsubscribe': True}) @@ -122,7 +122,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): assert CourseEnrollment.is_enrolled(self.student, self.course.id) - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.navigate_to_email_view() test_email = { @@ -155,7 +155,7 @@ class TestACEOptoutCourseEmails(ModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self._set_email_optout(False) self.policy = CourseEmailOptout() diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index b67864381b..ba95bd26dd 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -87,7 +87,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase): """ Log in self.client as user. """ - self.client.login(username=user.username, password="test") + self.client.login(username=user.username, password=self.TEST_PASSWORD) def goto_instructor_dash_email_view(self): """ diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index 3f3ffe9b69..06e9a1dce4 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -53,7 +53,7 @@ class TestEmailErrors(ModuleStoreTestCase): course_title = "ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ" self.course = CourseFactory.create(display_name=course_title) self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") diff --git a/lms/djangoapps/bulk_email/tests/test_signals.py b/lms/djangoapps/bulk_email/tests/test_signals.py index ea369fa805..1a3715284b 100644 --- a/lms/djangoapps/bulk_email/tests/test_signals.py +++ b/lms/djangoapps/bulk_email/tests/test_signals.py @@ -33,7 +33,7 @@ class TestOptoutCourseEmailsBySignal(ModuleStoreTestCase): # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.send_mail_url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) self.success_content = { @@ -78,7 +78,7 @@ class TestOptoutCourseEmailsBySignal(ModuleStoreTestCase): force_optout_all(sender=self.__class__, user=self.student) # Try to send a bulk course email - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.send_test_email() # Assert that self.student.email not in mail.to, outbox should only contain "myself" target diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py index d3daf72244..195fafb11c 100644 --- a/lms/djangoapps/ccx/api/v0/tests/test_views.py +++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py @@ -33,7 +33,7 @@ from lms.djangoapps.instructor.access import allow_access, list_with_level from lms.djangoapps.instructor.enrollment import enroll_email, get_email_params from openedx.core.lib.courses import get_course_by_id -USER_PASSWORD = 'test' +USER_PASSWORD = 'password' class CcxRestApiTest(CcxTestCase, APITestCase): @@ -760,7 +760,7 @@ class CcxDetailTest(CcxRestApiTest): """ # create a staff user staff_user = UserFactory.create( - username='test_staff_user', email='test_staff_user@openedx.org', password='test', + username='test_staff_user', email='test_staff_user@openedx.org', password=USER_PASSWORD, ) # add staff role to the staff user CourseStaffRole(self.master_course_key).add_users(staff_user) @@ -779,7 +779,7 @@ class CcxDetailTest(CcxRestApiTest): """ # create an instructor user instructor_user = UserFactory.create( - username='test_instructor_user', email='test_instructor_user@openedx.org', password='test', + username='test_instructor_user', email='test_instructor_user@openedx.org', password=USER_PASSWORD, ) # add instructor role to the instructor user CourseInstructorRole(self.master_course_key).add_users(instructor_user) @@ -798,7 +798,7 @@ class CcxDetailTest(CcxRestApiTest): """ # create an coach user coach_user = UserFactory.create( - username='test_coach_user', email='test_coach_user@openedx.org', password='test', + username='test_coach_user', email='test_coach_user@openedx.org', password=USER_PASSWORD, ) # add coach role to the coach user CourseCcxCoachRole(self.master_course_key).add_users(coach_user) diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index b18ed5c2aa..6dd569c0fb 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -129,13 +129,14 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): ccx = self.make_ccx() ccx_key = CCXLocator.from_course_locator(self.course.id, ccx.id) self.url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_key}) + self.TEST_PASSWORD = 'password' def test_staff_access_coach_dashboard(self): """ User is staff, should access coach dashboard. """ staff = self.make_staff() - self.client.login(username=staff.username, password="test") + self.client.login(username=staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == 200 @@ -145,7 +146,7 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): User is instructor, should access coach dashboard. """ instructor = self.make_instructor() - self.client.login(username=instructor.username, password="test") + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) # Now access URL response = self.client.get(self.url) @@ -155,8 +156,8 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): """ Assert user with no access must not see dashboard. """ - user = UserFactory.create(password="test") - self.client.login(username=user.username, password="test") + user = UserFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == 403 @@ -211,12 +212,12 @@ class TestCCXProgressChanges(CcxTestCase, LoginEnrollmentTestCase): """ assert signal and schedule update. """ - student = UserFactory.create(is_staff=False, password="test") + student = UserFactory.create(is_staff=False, password=self.TEST_PASSWORD) CourseEnrollment.enroll(student, ccx_course_key) assert CourseEnrollment.objects.filter(course_id=ccx_course_key, user=student).exists() # login as student - self.client.login(username=student.username, password="test") + self.client.login(username=student.username, password=self.TEST_PASSWORD) progress_page_response = self.client.get( reverse('progress', kwargs={'course_id': ccx_course_key}) ) @@ -236,7 +237,7 @@ class TestCCXProgressChanges(CcxTestCase, LoginEnrollmentTestCase): self.make_coach() ccx = self.make_ccx() ccx_course_key = CCXLocator.from_course_locator(self.course.id, str(ccx.id)) - self.client.login(username=self.coach.username, password="test") + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_course_key}) response = self.client.get(url) @@ -295,7 +296,7 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): """ super().setUp() # Login with the instructor account - self.client.login(username=self.coach.username, password="test") + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) # adding staff to master course. staff = UserFactory() @@ -315,8 +316,8 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): ccx = self.make_ccx() # create session of non-coach user - user = UserFactory.create(password="test") - self.client.login(username=user.username, password="test") + user = UserFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=user.username, password=self.TEST_PASSWORD) url = reverse( 'ccx_coach_dashboard', kwargs={'course_id': CCXLocator.from_course_locator(self.course.id, ccx.id)}) @@ -841,7 +842,7 @@ class TestCoachDashboardSchedule(CcxTestCase, LoginEnrollmentTestCase, ModuleSto self.mstore = modulestore() # Login with the instructor account - self.client.login(username=self.coach.username, password="test") + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) # adding staff to master course. staff = UserFactory() @@ -981,7 +982,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro # Create instructor account self.coach = coach = AdminFactory.create() - self.client.login(username=coach.username, password="test") + self.client.login(username=coach.username, password=self.TEST_PASSWORD) # Create CCX role = CourseCcxCoachRole(self._course.id) @@ -1009,7 +1010,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro self.course = get_course_by_id(self.ccx_key, depth=None) CourseOverview.load_from_module_store(self.course.id) setup_students_and_grades(self) - self.client.login(username=coach.username, password="test") + self.client.login(username=coach.username, password=self.TEST_PASSWORD) self.addCleanup(RequestCache.clear_all_namespaces) from xmodule.modulestore.django import SignalHandler @@ -1068,7 +1069,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro get_course.return_value = self.course self.addCleanup(patch_context.stop) - self.client.login(username=self.student.username, password="test") # lint-amnesty, pylint: disable=no-member + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) # lint-amnesty, pylint: disable=no-member url = reverse( 'progress', kwargs={'course_id': self.ccx_key} @@ -1194,7 +1195,7 @@ class TestStudentViewsWithCCX(ModuleStoreTestCase): # Create a Split Mongo course and enroll a student user in it. self.student_password = "foobar" - self.student = UserFactory.create(username="test", password=self.student_password, is_staff=False) + self.student = UserFactory.create(username=self.TEST_PASSWORD, password=self.student_password, is_staff=False) self.split_course = SampleCourseFactory.create(default_store=ModuleStoreEnum.Type.split) CourseEnrollment.enroll(self.student, self.split_course.id) diff --git a/lms/djangoapps/ccx/tests/utils.py b/lms/djangoapps/ccx/tests/utils.py index 4d36353b9a..4f8c34e14c 100644 --- a/lms/djangoapps/ccx/tests/utils.py +++ b/lms/djangoapps/ccx/tests/utils.py @@ -68,7 +68,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase): """ super().setUp() # Create instructor account - self.coach = UserFactory.create(password="test") + self.coach = UserFactory.create() # create an instance of modulestore self.mstore = modulestore() @@ -76,7 +76,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase): """ create staff user. """ - staff = UserFactory.create(password="test") + staff = UserFactory.create() role = CourseStaffRole(self.course.id) role.add_users(staff) @@ -86,7 +86,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase): """ create instructor user. """ - instructor = UserFactory.create(password="test") + instructor = UserFactory.create() role = CourseInstructorRole(self.course.id) role.add_users(instructor) diff --git a/lms/djangoapps/commerce/api/v1/tests/test_views.py b/lms/djangoapps/commerce/api/v1/tests/test_views.py index 47de5d8ff5..68f3d92d52 100644 --- a/lms/djangoapps/commerce/api/v1/tests/test_views.py +++ b/lms/djangoapps/commerce/api/v1/tests/test_views.py @@ -23,7 +23,7 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p from ....tests.mocks import mock_order_endpoint from ....tests.test_views import UserMixin -PASSWORD = 'test' +PASSWORD = 'password' JSON_CONTENT_TYPE = 'application/json' diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 9af2ee7d97..8932c826c7 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -3,6 +3,9 @@ from common.djangoapps.student.tests.factories import UserFactory +TEST_PASSWORD = "password" + + class UserMixin: """ Mixin for tests involving users. """ @@ -12,4 +15,4 @@ class UserMixin: def _login(self): """ Log into LMS. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=TEST_PASSWORD) diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index 72e5e430f6..e2426708d6 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -60,7 +60,7 @@ class TestBlocksView(SharedModuleStoreTestCase): self.admin_user = AdminFactory.create() self.data_researcher = UserFactory.create() CourseDataResearcherRole(self.course_key).add_users(self.data_researcher) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) CourseEnrollmentFactory.create(user=self.user, course_id=self.course_key) # default values for url and query_params @@ -248,7 +248,7 @@ class TestBlocksView(SharedModuleStoreTestCase): self.client.logout() self.verify_response(403, cacheable=False) # Verify response for a staff user. - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) self.verify_response(cacheable=False) def test_non_existent_course(self): @@ -269,7 +269,7 @@ class TestBlocksView(SharedModuleStoreTestCase): self.verify_response(400) def test_no_user_staff_all_blocks(self): - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) self.query_params.pop('username') self.query_params['all_blocks'] = True self.verify_response() @@ -319,7 +319,7 @@ class TestBlocksView(SharedModuleStoreTestCase): - other_course_settings - course_visibility """ - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) response = self.verify_response(params={ 'all_blocks': True, 'requested_fields': ['other_course_settings', 'course_visibility'], @@ -351,7 +351,7 @@ class TestBlocksView(SharedModuleStoreTestCase): - other_course_settings - course_visibility """ - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) response = self.verify_response(params={ 'all_blocks': True, 'requested_fields': ['course_visibility'], @@ -370,7 +370,7 @@ class TestBlocksView(SharedModuleStoreTestCase): """ Test if data researcher has access to the api endpoint """ - self.client.login(username=self.data_researcher.username, password='test') + self.client.login(username=self.data_researcher.username, password=self.TEST_PASSWORD) self.verify_response(params={ 'all_blocks': True, @@ -556,7 +556,7 @@ class TestBlockMetadataView(SharedModuleStoreTestCase): # pylint: disable=test- def setUp(self): super().setUp() self.admin_user = AdminFactory.create() - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) self.usage_key = list(self.non_orphaned_block_usage_keys)[0] self.url = reverse( 'blocks_metadata', diff --git a/lms/djangoapps/course_blocks/transformers/tests/helpers.py b/lms/djangoapps/course_blocks/transformers/tests/helpers.py index 27e7a686ab..29c15e8289 100644 --- a/lms/djangoapps/course_blocks/transformers/tests/helpers.py +++ b/lms/djangoapps/course_blocks/transformers/tests/helpers.py @@ -50,7 +50,7 @@ class CourseStructureTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) """ super().setUp() # Set up users. - self.password = 'test' + self.password = 'password' self.user = UserFactory.create(password=self.password) self.staff = UserFactory.create(password=self.password, is_staff=True) @@ -253,7 +253,7 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) parent_block.children.append(self.xblock_keys[i]) update_block(parent_block) - self.password = 'test' + self.password = 'password' self.student = UserFactory.create(is_staff=False, username='test_student', password=self.password) self.staff = UserFactory.create(is_staff=True, username='test_staff', password=self.password) CourseEnrollmentFactory.create( diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index 8cdd93115d..1afdea5d74 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -24,7 +24,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas # Create two accounts self.student = 'view@test.com' self.instructor = 'view2@test.com' - self.password = 'foo' + self.password = 'password' for username, email in [('u1', self.student), ('u2', self.instructor)]: self.create_account(username, email, self.password) self.activate_user(email) diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 3a2ddbc456..30398dad3c 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -128,7 +128,7 @@ class BaseTestXmodule(ModuleStoreTestCase): self.clients = {user.username: Client() for user in self.users} self.login_statuses = [ self.clients[user.username].login( - username=user.username, password='test') + username=user.username, password=self.TEST_PASSWORD) for user in self.users ] @@ -173,7 +173,7 @@ class LoginEnrollmentTestCase(TestCase): Create a user account, activate, and log in. """ self.email = 'foo@test.com' # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.password = 'bar' # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.password = 'password' # lint-amnesty, pylint: disable=attribute-defined-outside-init self.username = 'test' # lint-amnesty, pylint: disable=attribute-defined-outside-init self.user = self.create_account( self.username, diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index f1f915fd02..f6c71119a7 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -284,7 +284,7 @@ class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleSt # pylint: disable=attribute-defined-outside-init # create a new account since the first account is already enrolled in the course self.email = 'foo_second@test.com' - self.password = 'bar' + self.password = 'password' self.username = 'test_second' self.create_account(self.username, self.email, self.password) self.activate_user(self.email) diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 8a4cbabad6..ba93ec6762 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -82,8 +82,8 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase) super().setUp() # Create ccx coach account - self.coach = AdminFactory.create(password="test") - self.client.login(username=self.coach.username, password="test") + self.coach = AdminFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) # assign role to coach role = CourseCcxCoachRole(self.course.id) @@ -152,7 +152,7 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase) assert resp.status_code == 200 # Assert access of a student - self.client.login(username=student.username, password='test') + self.client.login(username=student.username, password=self.TEST_PASSWORD) resp = self.client.get(reverse('student_progress', args=[str(ccx_locator), self.coach.id])) assert resp.status_code == 404 diff --git a/lms/djangoapps/courseware/tests/test_block_render.py b/lms/djangoapps/courseware/tests/test_block_render.py index 80827afa63..be1ebd5c05 100644 --- a/lms/djangoapps/courseware/tests/test_block_render.py +++ b/lms/djangoapps/courseware/tests/test_block_render.py @@ -362,7 +362,7 @@ class BlockRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase): def test_session_authentication(self): """ Test that the xblock endpoint supports session authentication.""" - self.client.login(username=self.mock_user.username, password="test") + self.client.login(username=self.mock_user.username, password=self.TEST_PASSWORD) dispatch_url = self._get_dispatch_url() response = self.client.post(dispatch_url) assert 200 == response.status_code @@ -387,7 +387,7 @@ class BlockRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test that sending POST request without or invalid position argument don't raise server error """ - self.client.login(username=self.mock_user.username, password="test") + self.client.login(username=self.mock_user.username, password=self.TEST_PASSWORD) dispatch_url = self._get_dispatch_url() response = self.client.post(dispatch_url) assert 200 == response.status_code diff --git a/lms/djangoapps/courseware/tests/test_course_survey.py b/lms/djangoapps/courseware/tests/test_course_survey.py index d729add1b6..e5aa97cfaa 100644 --- a/lms/djangoapps/courseware/tests/test_course_survey.py +++ b/lms/djangoapps/courseware/tests/test_course_survey.py @@ -22,7 +22,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe """ All tests for the views.py file """ - STUDENT_INFO = [('view@test.com', 'foo')] + STUDENT_INFO = [('view@test.com', 'password1234')] @classmethod def setUpClass(cls): diff --git a/lms/djangoapps/courseware/tests/test_discussion_xblock.py b/lms/djangoapps/courseware/tests/test_discussion_xblock.py index bbe0e68bdc..599f125a7e 100644 --- a/lms/djangoapps/courseware/tests/test_discussion_xblock.py +++ b/lms/djangoapps/courseware/tests/test_discussion_xblock.py @@ -366,7 +366,7 @@ class TestXBlockInCourse(SharedModuleStoreTestCase): """ Tests that course block api returns student_view_data for discussion xblock """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse('blocks_in_block_tree', kwargs={'usage_key_string': str(self.course_usage_key)}) query_params = { 'depth': 'all', diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 240a9494fc..338a7bedfc 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -136,7 +136,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest self.request = get_mock_request(UserFactory()) self.course = self.update_course(self.course, self.request.user.id) - self.client.login(username=self.request.user.username, password="test") + self.client.login(username=self.request.user.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.request.user, self.course.id) self.expected_locked_toc = ( @@ -257,7 +257,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest # hit skip entrance exam api in instructor app instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.request.user.email, @@ -277,7 +277,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest self.client.logout() staff_user = StaffFactory(course_key=self.course.id) staff_user.is_staff = True - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) # assert staff has access to all toc self.request.user = staff_user diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 43041b1437..9d211b05ea 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -88,7 +88,7 @@ class MasqueradeTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase, Mas super().setUp() self.test_user = self.create_user() - self.login(self.test_user.email, 'test') + self.login(self.test_user.email, self.TEST_PASSWORD) self.enroll(self.course, True) def get_courseware_page(self): @@ -298,12 +298,12 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi def login_staff(self): """ Login as a staff user """ self.logout() - self.login(self.test_user.email, 'test') + self.login(self.test_user.email, self.TEST_PASSWORD) def login_student(self): """ Login as a student """ self.logout() - self.login(self.student_user.email, 'test') + self.login(self.student_user.email, self.TEST_PASSWORD) def submit_answer(self, response1, response2): """ @@ -351,7 +351,7 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi student = UserFactory.create(username=username) CourseEnrollment.enroll(student, self.course.id) self.logout() - self.login(student.email, 'test') + self.login(student.email, self.TEST_PASSWORD) # Answer correctly as the student, and check progress. self.submit_answer('Correct', 'Correct') assert self.get_progress_detail() == '2/2' @@ -378,7 +378,7 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi # Verify the student state did not change. self.logout() - self.login(student.email, 'test') + self.login(student.email, self.TEST_PASSWORD) assert self.get_progress_detail() == '2/2' def test_masquerading_with_language_preference(self): diff --git a/lms/djangoapps/courseware/tests/test_split_module.py b/lms/djangoapps/courseware/tests/test_split_module.py index 7e6b88718e..5026f05ca2 100644 --- a/lms/djangoapps/courseware/tests/test_split_module.py +++ b/lms/djangoapps/courseware/tests/test_split_module.py @@ -59,7 +59,7 @@ class SplitTestBase(ModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.included_usage_keys = None self.excluded_usage_keys = None @@ -309,7 +309,7 @@ class SplitTestPosition(SharedModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) def test_changing_position_works(self): # Make a mock FieldDataCache for this course, so we can get the course block diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 8ffcea0298..9536067f52 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -154,7 +154,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, Probl # create a test student self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG) self.student = 'view@test.com' - self.password = 'foo' + self.password = 'password' self.create_account('u1', self.student, self.password) self.activate_user(self.student) self.enroll(self.course) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index db59fa373b..6ad7ef73de 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -404,7 +404,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi # login as instructor hit skip entrance exam api in instructor app instructor = InstructorFactory(course_key=self.course.id) self.client.logout() - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -426,7 +426,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi # Login as member of staff self.client.logout() staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) course_tab_list = get_course_tab_list(staff_user, self.course) assert len(course_tab_list) == 4 @@ -685,7 +685,7 @@ class CourseTabListTestCase(TabListTestCase): # Login as member of staff self.client.logout() staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) course_tab_list_staff = get_course_tab_list(staff_user, self.course) name_list_staff = [x.name for x in course_tab_list_staff] assert 'Static Tab Free' in name_list_staff diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 42f2275e0f..aa53561ee5 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -29,7 +29,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro Check that view authentication works properly. """ - ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] + ACCOUNT_INFO = [('view@test.com', 'password1234'), ('view2@test.com', 'password1234')] ENABLED_SIGNALS = ['course_published'] @staticmethod @@ -111,7 +111,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro self.assert_request_status_code(302, url) def login(self, user): # lint-amnesty, pylint: disable=arguments-differ - return super().login(user.email, 'test') + return super().login(user.email, self.TEST_PASSWORD) def setUp(self): super().setUp() diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c7d7ce7b17..67d403a215 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -712,7 +712,7 @@ class ViewsTestCase(BaseViewsTestCase): # log into a staff account admin = AdminFactory() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) url = reverse('submission_history', kwargs={ 'course_id': str(self.course_key), @@ -727,7 +727,7 @@ class ViewsTestCase(BaseViewsTestCase): # log into a staff account admin = AdminFactory() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) # try it with an existing user and a malicious location url = reverse('submission_history', kwargs={ @@ -751,7 +751,7 @@ class ViewsTestCase(BaseViewsTestCase): # log into a staff account admin = AdminFactory.create() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) usage_key = self.course_key.make_usage_key('problem', 'test-history') state_client = DjangoXBlockUserStateClient(admin) @@ -814,7 +814,7 @@ class ViewsTestCase(BaseViewsTestCase): course_key = course.id client = Client() admin = AdminFactory.create() - assert client.login(username=admin.username, password='test') + assert client.login(username=admin.username, password=TEST_PASSWORD) state_client = DjangoXBlockUserStateClient(admin) usage_key = course_key.make_usage_key('problem', 'test-history') state_client.set( @@ -1253,7 +1253,7 @@ class ProgressPageBaseTests(ModuleStoreTestCase): def setUp(self): super().setUp() self.user = UserFactory.create() - assert self.client.login(username=self.user.username, password='test') + assert self.client.login(username=self.user.username, password=TEST_PASSWORD) self.setup_course() @@ -1352,7 +1352,7 @@ class ProgressPageTests(ProgressPageBaseTests): # Create a new course, a user which will not be enrolled in course, admin user for staff access course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) admin = AdminFactory.create() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) # Create and enable Credit course CreditCourse.objects.create(course_key=course.id, enabled=True) @@ -1646,7 +1646,7 @@ class ProgressPageTests(ProgressPageBaseTests): """ CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) user = UserFactory.create() - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) add_course_mode(self.course, mode_slug=CourseMode.AUDIT) add_course_mode(self.course) CourseEnrollmentFactory(user=user, course_id=self.course.id, mode=course_mode) @@ -1679,7 +1679,7 @@ class ProgressPageTests(ProgressPageBaseTests): """ CourseDurationLimitConfig.objects.create(enabled=False) user = UserFactory.create() - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) CourseModeFactory.create( course_id=self.course.id, mode_slug=course_mode @@ -1698,7 +1698,7 @@ class ProgressPageTests(ProgressPageBaseTests): in an ineligible mode. """ user = UserFactory.create() - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) CourseEnrollmentFactory(user=user, course_id=self.course.id, mode=course_mode) with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_create: @@ -2081,7 +2081,7 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests): self.setup_course(show_correctness=show_correctness, due_date=due_date, graded=graded) self.add_problem() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=TEST_PASSWORD) resp = self._get_progress_page() # Ensure that expected text is present @@ -2133,7 +2133,7 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests): self.add_problem() # Login as a course staff user to view the student progress page. - self.client.login(username=self.staff_user.username, password='test') + self.client.login(username=self.staff_user.username, password=TEST_PASSWORD) resp = self._get_student_progress_page() @@ -3198,7 +3198,7 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCa def setUp(self): super().setUp() self.user = UserFactory.create() - assert self.client.login(username=self.user.username, password='test') + assert self.client.login(username=self.user.username, password=TEST_PASSWORD) self.course = CourseFactory.create() CourseOverview.load_from_module_store(self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id) @@ -3298,7 +3298,7 @@ class PreviewTests(BaseViewsTestCase): # Previews will not redirect to the mfe course_staff = UserFactory.create(is_staff=False) CourseStaffRole(self.course_key).add_users(course_staff) - self.client.login(username=course_staff.username, password='test') + self.client.login(username=course_staff.username, password=TEST_PASSWORD) assert self.client.get(preview_url).status_code == 200 @@ -3435,7 +3435,7 @@ class TestCourseWideResources(ModuleStoreTestCase): CourseEnrollmentFactory(user=user, course_id=course.id) if is_instructor: allow_access(course, user, 'instructor') - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) kwargs = None if param == 'course_id': diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index 3c2b946e91..36774447fe 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -81,7 +81,7 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): """ Logs in the test user. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password='password') def course_options(self): """ diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index d450998a60..ea712e0852 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -241,7 +241,7 @@ class ViewsTestCaseMixin: with patch('common.djangoapps.student.models.user.cc.User.save'): uname = 'student' email = 'student@edx.org' - self.password = 'test' + self.password = 'password' # Create the user and make them active so we can log them in. self.student = UserFactory.create(username=uname, email=email, password=self.password) @@ -464,7 +464,7 @@ class ViewsTestCase( with patch('common.djangoapps.student.models.user.cc.User.save'): uname = 'student' email = 'student@edx.org' - self.password = 'test' + self.password = 'password' # Create the user and make them active so we can log them in. self.student = UserFactory.create(username=uname, email=email, password=self.password) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 0e303cc240..3fe9d64450 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -2758,7 +2758,7 @@ class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStor discussion_topics={"Test Topic": {"id": "test_topic"}} ) self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) - self.password = 'edx' + self.password = 'password' self.user = UserFactory(username='staff', password=self.password, is_staff=True) def _get_oauth_headers(self, user): @@ -3056,7 +3056,7 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe run="z", start=datetime.now(UTC), ) - self.password = 'edx' + self.password = 'password' self.user = UserFactory(username='staff', password=self.password, is_staff=True) course_key = CourseKey.from_string('course-v1:x+y+z') seed_permissions_roles(course_key) @@ -3278,7 +3278,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Tests that for a regular user stats are returned without flag counts """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) data = response.json() assert data["results"] == self.stats_without_flags @@ -3288,7 +3288,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Tests that for a moderator user stats are returned with flag counts """ - self.client.login(username=self.moderator.username, password='test') + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) data = response.json() assert data["results"] == self.stats @@ -3308,7 +3308,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Test valid sorting options and defaults """ - self.client.login(username=username, password='test') + self.client.login(username=username, password=self.TEST_PASSWORD) params = {} if ordering_requested: params = {"order_by": ordering_requested} @@ -3326,7 +3326,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Test for invalid sorting options for regular users. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, {"order_by": order_by}) assert "order_by" in response.json()["field_errors"] @@ -3341,7 +3341,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM Test for endpoint with username param. """ params = {'username': username_search_string} - self.client.login(username=self.moderator.username, password='test') + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) self.client.get(self.url, params) assert urlparse( httpretty.last_request().path # lint-amnesty, pylint: disable=no-member @@ -3356,7 +3356,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM Test for endpoint with username param with no matches. """ params = {'username': 'unknown'} - self.client.login(username=self.moderator.username, password='test') + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, params) data = response.json() self.assertFalse(data['results']) diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index ada3db7792..e0d3b869da 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -463,7 +463,7 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amne CourseTeamFactory.create(discussion_topic_id=discussion_topic_id) user_not_in_team = UserFactory.create() CourseEnrollmentFactory.create(user=user_not_in_team, course_id=self.course.id) - self.client.login(username=user_not_in_team.username, password='test') + self.client.login(username=user_not_in_team.username, password=self.TEST_PASSWORD) mock_request.side_effect = make_mock_request_impl( course=self.course, @@ -624,7 +624,7 @@ class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: d def test_html(self, mock_request): _mock_text, mock_thread_id = self._create_mock_cohorted_thread(mock_request) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) response = self.client.get( reverse('single_thread', kwargs={ 'course_id': str(self.course.id), @@ -758,7 +758,7 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l if is_ajax: headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse('single_thread', args=[str(self.course.id), commentable_id, "dummy_thread_id"]), @@ -824,7 +824,7 @@ class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTes text="dummy content", thread_list=self.thread_list ) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse("forum_form_discussion", args=[str(self.course.id)]), HTTP_X_REQUESTED_WITH="XMLHttpRequest" @@ -889,7 +889,7 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content verify that the user does not have access to that thread. """ def call_single_thread(): - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse('single_thread', args=[str(self.course.id), discussion_id, thread_id]) ) @@ -1113,7 +1113,7 @@ class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdT if is_ajax: headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse("forum_form_discussion", args=[str(self.course.id)]), data=request_data, @@ -1165,7 +1165,7 @@ class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupI if is_ajax: headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.client.login(username=requesting_user.username, password='test') + self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD) return self.client.get( reverse('user_profile', args=[str(self.course.id), profiled_user.id]), data=request_data, @@ -1414,7 +1414,7 @@ class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase) mock_request.side_effect = make_mock_request_impl( course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID ) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) response = self.client.get( reverse('user_profile', kwargs={ @@ -2300,7 +2300,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): """ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse("forum_form_discussion", args=[self.course.id]) response = self.client.get(url) assert response.status_code == 302 @@ -2315,7 +2315,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): """ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse("user_profile", args=[self.course.id, self.user.id]) response = self.client.get(url) assert response.status_code == 302 @@ -2330,7 +2330,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): """ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"]) response = self.client.get(url) assert response.status_code == 302 diff --git a/lms/djangoapps/grades/rest_api/v1/tests/mixins.py b/lms/djangoapps/grades/rest_api/v1/tests/mixins.py index e5e7f269e3..9131ebd290 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/mixins.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/mixins.py @@ -97,7 +97,7 @@ class GradeViewTestMixin(SharedModuleStoreTestCase): def setUp(self): super().setUp() - self.password = 'test' + self.password = 'password' self.global_staff = GlobalStaffFactory.create() self.student = UserFactory(password=self.password, username='student', email='student@example.com') self.other_student = UserFactory( diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py index 1fbfb6070d..0dd6ea2573 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py @@ -548,7 +548,7 @@ class CourseSubmissionHistoryWithDataTest(TestSubmittingProblems): def setUp(self): super().setUp() self.namespaced_url = 'grades_api:v1:submission_history' - self.password = 'test' + self.password = 'password' self.basic_setup() self.global_staff = GlobalStaffFactory.create() diff --git a/lms/djangoapps/grades/tests/integration/test_access.py b/lms/djangoapps/grades/tests/integration/test_access.py index 380962b19e..6545094a3c 100644 --- a/lms/djangoapps/grades/tests/integration/test_access.py +++ b/lms/djangoapps/grades/tests/integration/test_access.py @@ -73,9 +73,9 @@ class GradesAccessIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreT self.addCleanup(set_current_request, None) self.request = get_mock_request(UserFactory()) self.student = self.request.user - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.student, self.course.id) - self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password='test') + self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password=self.TEST_PASSWORD) self.refresh_course() def test_subsection_access_changed(self): diff --git a/lms/djangoapps/grades/tests/integration/test_events.py b/lms/djangoapps/grades/tests/integration/test_events.py index 868b55eeae..881ad049b3 100644 --- a/lms/djangoapps/grades/tests/integration/test_events.py +++ b/lms/djangoapps/grades/tests/integration/test_events.py @@ -70,9 +70,9 @@ class GradesEventIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreTe self.addCleanup(set_current_request, None) self.request = get_mock_request(UserFactory()) self.student = self.request.user - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.student, self.course.id) - self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password='test') + self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password=self.TEST_PASSWORD) self.refresh_course() @patch('lms.djangoapps.grades.events.tracker') diff --git a/lms/djangoapps/grades/tests/integration/test_problems.py b/lms/djangoapps/grades/tests/integration/test_problems.py index 36c54bec86..b4a2d06c9f 100644 --- a/lms/djangoapps/grades/tests/integration/test_problems.py +++ b/lms/djangoapps/grades/tests/integration/test_problems.py @@ -43,9 +43,8 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase): def setUp(self): super().setUp() - password = 'test' - self.student = UserFactory.create(is_staff=False, username='test_student', password=password) - self.client.login(username=self.student.username, password=password) + self.student = UserFactory.create(is_staff=False, username='test_student', password=self.TEST_PASSWORD) + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.addCleanup(set_current_request, None) self.request = get_mock_request(self.student) self.course_structure = get_course_blocks(self.student, self.course.location) @@ -58,8 +57,7 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase): For details on the contents and structure of the file, see `common/test/data/scoreable/README`. """ - password = 'test' - user = UserFactory.create(is_staff=False, username='test_student', password=password) + user = UserFactory.create(is_staff=False, username='test_student', password=cls.TEST_PASSWORD) course_items = import_course_from_xml( cls.store, @@ -144,7 +142,7 @@ class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase): ''' self.addCleanup(set_current_request, None) self.request = get_mock_request(UserFactory()) - self.client.login(username=self.request.user.username, password="test") + self.client.login(username=self.request.user.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.request.user, self.course.id) def _get_altered_metadata(self, alterations): diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 468bca31f7..bb534d9b02 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -325,7 +325,7 @@ class TestEndpointHttpMethods(SharedModuleStoreTestCase, LoginEnrollmentTestCase """ super().setUp() global_user = GlobalStaffFactory() - self.client.login(username=global_user.username, password='test') + self.client.login(username=global_user.username, password=self.TEST_PASSWORD) @ddt.data(*INSTRUCTOR_POST_ENDPOINTS) def test_endpoints_reject_get(self, data): @@ -478,7 +478,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest """ Ensure that an enrolled student can't access staff or instructor endpoints. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) for endpoint, args in self.staff_level_endpoints: self._access_endpoint( @@ -518,7 +518,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest CourseEnrollment.enroll(staff_member, self.course.id) CourseFinanceAdminRole(self.course.id).add_users(staff_member) CourseDataResearcherRole(self.course.id).add_users(staff_member) - self.client.login(username=staff_member.username, password='test') + self.client.login(username=staff_member.username, password=self.TEST_PASSWORD) # Try to promote to forums admin - not working # update_forum_role(self.course.id, staff_member, FORUM_ROLE_ADMINISTRATOR, 'allow') @@ -558,7 +558,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest CourseFinanceAdminRole(self.course.id).add_users(inst) CourseDataResearcherRole(self.course.id).add_users(inst) - self.client.login(username=inst.username, password='test') + self.client.login(username=inst.username, password=self.TEST_PASSWORD) for endpoint, args in self.staff_level_endpoints: expected_status = 200 @@ -632,7 +632,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas self.audit_course_instructor = InstructorFactory(course_key=self.audit_course.id) self.white_label_course_instructor = InstructorFactory(course_key=self.white_label_course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.not_enrolled_student = UserFactory( username='NotEnrolledStudent', @@ -945,7 +945,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas Test that enrollment mode for audit courses (paid courses) is 'audit'. """ # Login Audit Course instructor - self.client.login(username=self.audit_course_instructor.username, password='test') + self.client.login(username=self.audit_course_instructor.username, password=self.TEST_PASSWORD) csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) @@ -976,7 +976,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas self.white_label_course_mode.save() # Login Audit Course instructor - self.client.login(username=self.white_label_course_instructor.username, password='test') + self.client.login(username=self.white_label_course_instructor.username, password=self.TEST_PASSWORD) csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) @@ -1024,7 +1024,7 @@ class TestInstructorAPIEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTest self.request = RequestFactory().request() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.enrolled_student = UserFactory(username='EnrolledStudent', first_name='Enrolled', last_name='Student') CourseEnrollment.enroll( @@ -1873,7 +1873,7 @@ class TestInstructorAPIBulkBetaEnrollment(SharedModuleStoreTestCase, LoginEnroll super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.beta_tester = BetaTesterFactory(course_key=self.course.id) CourseEnrollment.enroll( @@ -2218,7 +2218,7 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.other_instructor = InstructorFactory(course_key=self.course.id) self.other_staff = StaffFactory(course_key=self.course.id) @@ -2458,7 +2458,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment self.course_mode.save() self.instructor = InstructorFactory(course_key=self.course.id) CourseDataResearcherRole(self.course.id).add_users(self.instructor) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.students = [UserFactory() for _ in range(6)] for student in self.students: @@ -2600,7 +2600,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment })) course_instructor = InstructorFactory(course_key=self.course.id) CourseDataResearcherRole(self.course.id).add_users(course_instructor) - self.client.login(username=course_instructor.username, password='test') + self.client.login(username=course_instructor.username, password=self.TEST_PASSWORD) url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)}) @@ -2972,7 +2972,7 @@ class TestInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginEnrollmentTes def setUp(self): super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory() CourseEnrollment.enroll(self.student, self.course.id) @@ -3158,7 +3158,7 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE self.instructor = InstructorFactory(course_key=self.course.id) # Add instructor to invalid ee course CourseInstructorRole(self.course_with_invalid_ee.id).add_users(self.instructor) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory() CourseEnrollment.enroll(self.student, self.course.id) @@ -3261,7 +3261,7 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE """ Test entrance exam delete state failure with staff access. """ self.client.logout() staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) url = reverse('reset_student_attempts_for_entrance_exam', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -3414,7 +3414,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm } self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def tearDown(self): super().tearDown() @@ -3434,7 +3434,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm def test_send_email_but_not_staff(self): self.client.logout() student = UserFactory() - self.client.login(username=student.username, password='test') + self.client.login(username=student.username, password=self.TEST_PASSWORD) url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, self.full_test_message) assert response.status_code == 403 @@ -3631,7 +3631,7 @@ class TestInstructorAPITaskLists(SharedModuleStoreTestCase, LoginEnrollmentTestC def setUp(self): super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory() CourseEnrollment.enroll(self.student, self.course.id) @@ -3768,7 +3768,7 @@ class TestInstructorEmailContentList(SharedModuleStoreTestCase, LoginEnrollmentT super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.tasks = {} self.emails = {} self.emails_info = {} @@ -4030,7 +4030,7 @@ class TestDueDateExtensions(SharedModuleStoreTestCase, LoginEnrollmentTestCase): CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) extract_dates(None, self.course.id) def test_change_due_date(self): @@ -4206,7 +4206,7 @@ class TestDueDateExtensionsDeletedDate(ModuleStoreTestCase, LoginEnrollmentTestC CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) extract_dates(None, self.course.id) @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) @@ -4251,7 +4251,7 @@ class TestCourseIssuedCertificatesData(SharedModuleStoreTestCase): def setUp(self): super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def generate_certificate(self, course_id, mode, status): """ @@ -4379,7 +4379,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase): """ Verify that we get the error we expect for a given file input. """ - self.client.login(username=self.staff_user.username, password='test') + self.client.login(username=self.staff_user.username, password=self.TEST_PASSWORD) response = self.call_add_users_to_cohorts(file_content, suffix=file_suffix) assert response.status_code == 400 result = json.loads(response.content.decode('utf-8')) @@ -4392,7 +4392,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase): background task. """ mock_store_upload.return_value = (None, 'fake_file_name.csv') - self.client.login(username=self.staff_user.username, password='test') + self.client.login(username=self.staff_user.username, password=self.TEST_PASSWORD) response = self.call_add_users_to_cohorts(file_content) assert response.status_code == 204 assert mock_store_upload.called @@ -4438,7 +4438,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase): """ Verify that we can't access the view when we aren't a staff user. """ - self.client.login(username=self.non_staff_user.username, password='test') + self.client.login(username=self.non_staff_user.username, password=self.TEST_PASSWORD) response = self.call_add_users_to_cohorts('') assert response.status_code == 403 diff --git a/lms/djangoapps/instructor/tests/test_api_email_localization.py b/lms/djangoapps/instructor/tests/test_api_email_localization.py index e0cef6a7af..a23b94ece0 100644 --- a/lms/djangoapps/instructor/tests/test_api_email_localization.py +++ b/lms/djangoapps/instructor/tests/test_api_email_localization.py @@ -35,7 +35,7 @@ class TestInstructorAPIEnrollmentEmailLocalization(SharedModuleStoreTestCase): # Esperanto. self.instructor = InstructorFactory(course_key=self.course.id) set_user_preference(self.instructor, LANGUAGE_KEY, 'zh-cn') - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory.create() set_user_preference(self.student, LANGUAGE_KEY, 'eo') diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 5350d1d07f..11fd82bfb3 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -64,11 +64,11 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): def test_visible_only_to_global_staff(self): # Instructors don't see the certificates section - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self._assert_certificates_visible(False) # Global staff can see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) self._assert_certificates_visible(True) def test_visible_only_when_feature_flag_enabled(self): @@ -77,17 +77,17 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): cache.clear() # Now even global staff can't see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) self._assert_certificates_visible(False) @ddt.data("started", "error", "success") def test_show_certificate_status(self, status): - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) with self._certificate_status("honor", status): self._assert_certificate_status("honor", status) def test_show_enabled_button(self): - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) # Initially, no example certs are generated, so # the enable button should be disabled @@ -104,7 +104,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self._assert_enable_certs_button(False) def test_can_disable_even_after_failure(self): - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) with self._certificate_status("honor", "error"): # When certs are disabled for a course, then don't allow them @@ -127,7 +127,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self.course.cert_html_view_enabled = True self.course.save() self.store.update_item(self.course, self.global_staff.id) - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertContains(response, 'Enable Student-Generated Certificates') self.assertContains(response, 'enable-certificates-submit') @@ -143,7 +143,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self.course.cert_html_view_enabled = True self.course.save() self.store.update_item(self.course, self.global_staff.id) - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertContains(response, 'Enable Student-Generated Certificates') self.assertContains(response, 'enable-certificates-submit') @@ -233,18 +233,18 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): url = reverse(url_name, kwargs={'course_id': self.course.id}) # Instructors do not have access - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) response = self.client.post(url) assert response.status_code == 403 # Global staff have access - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) response = self.client.post(url) assert response.status_code == 302 @ddt.data(True, False) def test_enable_certificate_generation(self, is_enabled): - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse( 'enable_certificate_generation', kwargs={'course_id': str(self.course.id)} @@ -274,7 +274,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): user who made the request is not member of global staff. """ user = UserFactory.create() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) url = reverse( 'start_certificate_generation', kwargs={'course_id': str(self.course.id)} @@ -283,7 +283,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): response = self.client.post(url) assert response.status_code == 403 - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) response = self.client.post(url) assert response.status_code == 403 @@ -292,7 +292,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): Test certificates generation api endpoint returns success status when called with valid course key """ - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse( 'start_certificate_generation', kwargs={'course_id': str(self.course.id)} @@ -319,7 +319,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): ) # Login the client and access the url with 'certificate_statuses' - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.downloadable]}) @@ -352,7 +352,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): ) # Login the client and access the url without 'certificate_statuses' - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url) @@ -419,7 +419,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() CertificateGenerationConfiguration.objects.create(enabled=True) - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_certificate_exception_added_successfully(self): """ @@ -712,7 +712,7 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() CertificateGenerationConfiguration.objects.create(enabled=True) - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_generate_certificate_exceptions_all_students(self): """ @@ -830,7 +830,7 @@ class TestCertificatesInstructorApiBulkAllowlist(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.enrolled_user_2, self.course.id) # Global staff can see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_create_allowlist_exception_record(self): """ @@ -1026,7 +1026,7 @@ class CertificateInvalidationViewTests(SharedModuleStoreTestCase): ) # Global staff can see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_invalidate_certificate(self): """ diff --git a/lms/djangoapps/instructor/tests/test_email.py b/lms/djangoapps/instructor/tests/test_email.py index a84c728c30..322c734fa6 100644 --- a/lms/djangoapps/instructor/tests/test_email.py +++ b/lms/djangoapps/instructor/tests/test_email.py @@ -40,7 +40,7 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase): # Create instructor account instructor = AdminFactory.create() - self.client.login(username=instructor.username, password="test") + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) def tearDown(self): super().tearDown() @@ -148,7 +148,7 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase): # Create instructor account instructor = AdminFactory.create() - self.client.login(username=instructor.username, password="test") + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) # URL for instructor dash self.url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course_key)}) diff --git a/lms/djangoapps/instructor/tests/test_filters.py b/lms/djangoapps/instructor/tests/test_filters.py index 19948da144..a676ab92a5 100644 --- a/lms/djangoapps/instructor/tests/test_filters.py +++ b/lms/djangoapps/instructor/tests/test_filters.py @@ -99,7 +99,7 @@ class InstructorDashboardFiltersTest(ModuleStoreTestCase): """ super().setUp() self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.course = CourseFactory.create( org="test1", course="course1", display_name="run1", ) diff --git a/lms/djangoapps/instructor/tests/test_proctoring.py b/lms/djangoapps/instructor/tests/test_proctoring.py index 491a2ee1f7..4b196f307b 100644 --- a/lms/djangoapps/instructor/tests/test_proctoring.py +++ b/lms/djangoapps/instructor/tests/test_proctoring.py @@ -36,7 +36,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase): # Create instructor account self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def setup_course_url(self, course): """ diff --git a/lms/djangoapps/instructor/tests/test_spoc_gradebook.py b/lms/djangoapps/instructor/tests/test_spoc_gradebook.py index bf574e8f50..221ed3603e 100644 --- a/lms/djangoapps/instructor/tests/test_spoc_gradebook.py +++ b/lms/djangoapps/instructor/tests/test_spoc_gradebook.py @@ -56,7 +56,7 @@ class TestGradebook(SharedModuleStoreTestCase): super().setUp() instructor = AdminFactory.create() - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) self.users = [UserFactory.create() for _ in range(USER_COUNT)] for user in self.users: CourseEnrollmentFactory.create(user=user, course_id=self.course.id) diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index bca16977a9..db455f0258 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -85,7 +85,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT self.course_mode.save() # Create instructor account self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) # URL for instructor dash self.url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course.id)}) @@ -169,7 +169,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT org=self.course.id.org ) set_course_cohorted(self.course.id, True) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url).content.decode('utf-8') self.assertEqual(discussion_section in response, is_discussion_tab_available) @@ -192,7 +192,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT with override_waffle_flag(OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG, is_legacy_discussion_setting_enabled): user = UserFactory.create(is_staff=True) set_course_cohorted(self.course.id, True) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url).content.decode('utf-8') self.assertEqual(discussion_section in response, is_discussion_tab_available) @@ -224,7 +224,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT role=access_role, org=self.course.id.org ) - self.client.login(username=user.username, password="test") + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) if can_access: self.assertContains(response, download_section) @@ -244,7 +244,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT role='data_researcher', org=self.course.id.org ) - self.client.login(username=user.username, password="test") + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) matches = re.findall( rb'