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 01/17] 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 af3327951540..15741197aa8e 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -8,6 +8,7 @@ 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.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 000000000000..42ad9aa1bfe2 --- /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 02/17] 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 15741197aa8e..58c5fb90aace 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -8,7 +8,7 @@ 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 42ad9aa1bfe2..72424987b5ab 100644 --- a/lms/djangoapps/grades/tests/test_handlers.py +++ b/lms/djangoapps/grades/tests/test_handlers.py @@ -10,10 +10,13 @@ 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 @@ def test_exam_attempt_verified_event_handler(self, override_enabled, mock_undo_o ) 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 03/17] 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 c76e34a95bb7..c93bc85a42fc 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -49,6 +49,8 @@ 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 000000000000..8d45468497cc --- /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 29ee5a05d394..508bb3ad6df5 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 000000000000..241d9500bd67 --- /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 04/17] 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 e2e9014d326f..7d45b92ca3f1 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 2c95eabd1882..4f904772ecd1 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 b636f128f415..da2178262605 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 c8a9dc113106..781a3535656a 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 05/17] 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 6c78327d07e8..3a6f8d95d0ad 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5380,6 +5380,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring ############## 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 ec3018c0079e..d7d5f58574bc 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 d0f8daf0e553..66bd3a109b80 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -130,12 +130,12 @@ def __str__(self): 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 @@ def get_updated_user_course_preferences(user, course_id): 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 e59ff77eb16b..1401a2d9ab17 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -13,6 +13,7 @@ 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 @@ 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 51cdc741d6ef..3164f9a9bae2 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py @@ -5,8 +5,10 @@ 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 @@ def test_send_with_app_disabled_notifications(self, app_name, notification_type) # 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 4ee3a614a7b3..39bf8755da8e 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 06/17] 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 bae9a5be22e1..bb8123527a57 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 7d45b92ca3f1..7deca97f357f 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 4f904772ecd1..c875bec8794e 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 da2178262605..3cb2357b8df3 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 781a3535656a..60defce3b2dc 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 07/17] =?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 d1cc50e86978..0d8e18f1d0ed 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 7d45b92ca3f1..80afc4fed8d4 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 4f904772ecd1..98a496aee0e9 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 da2178262605..46ad29c2c915 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 d36b6f96291b..ea6d47eec8a0 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 fb0307ba43f8..18f4f87faa66 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 fb696264ace4..9abf4935ab87 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 781a3535656a..e9f7226a70df 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 08/17] 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 898416585f38..d22c8b0aa1ad 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 000000000000..af740ee4fed9 --- /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 ac3bda13a84d..2ec19c35b880 100644 --- a/lms/djangoapps/instructor/tasks.py +++ b/lms/djangoapps/instructor/tasks.py @@ -5,14 +5,14 @@ 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 000000000000..ee29e99eea98 --- /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 488c0d43a600..7080b58bba81 100644 --- a/lms/djangoapps/instructor/tests/test_services.py +++ b/lms/djangoapps/instructor/tests/test_services.py @@ -5,9 +5,7 @@ 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 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 @@ def setUp(self): ) @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 @@ def test_reset_student_attempts_delete(self, mock_submit, _mock_signal): 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 @@ def test_reset_non_existing_attempt(self): ) assert result is None - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_complete_student_attempt_success(self, mock_submit): - """ - Assert complete_student_attempt 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): - 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): + @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 with a bad content_id raises error and returns None + Assert update_exam_completion task is triggered """ - 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 000000000000..4c6f9016f4fa --- /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 09/17] 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 39d64912bf70..a2159fbd4ddb 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 @@ def replace_all_library_source_blocks_ids(self, v1_to_v2_lib_map): """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 @@ def replace_all_library_source_blocks_ids(self, v1_to_v2_lib_map): 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 @@ def validate(self, v1_to_v2_lib_map): 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 b7a249bfa5b4..434aba419af6 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 10/17] 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 150f0b8043eb..e7edd04500fc 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 418d0a02ab77..adf7ef616a7f 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 ce94b6980bba..9739f3ec4207 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 f6ce1c8a498f..9c2da911a2f5 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 52f45b05ba49..fc72790f43ce 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 d0b2f6871cfe..0dec53e989b4 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 1892fb2de783..626bd259fd97 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 31d5461819b1..fef2609d9f4c 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 d5b34818eaf6..ff394f49a7f4 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 3942a1486622..7b3ee3252146 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 2458f767956a..1e4087dc0c80 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 5b339180ad6d..b530cd2f9ce9 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 24719010aa0a..f4630eafce0a 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 ae49f47dda59..b9f7728f03ca 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 9f67617d194d..dae850983138 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 64c62625d12b..942530cd55a3 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 af46587ba214..7867ae401c44 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 20725bde01fa..ded1241d9486 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 562c79423902..f98e92c2f33a 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 da1f4536acdb..f0730948028f 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 e6fe1acdb82a..a038db390378 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 85b4e8a6f301..bbfa9ac233d2 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 280f56e60c52..fbbc53b09dab 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 a782279aa192..b89b1f04ed25 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 1500e6b1655f..2dab56fb0aa9 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 91bee068099a..5375e0555163 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 99388c329a9f..db6be8862445 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 9df1d2c5eaef..55d45117c64d 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 6b2785be79f0..49e88138f288 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 5727a7015aaa..7530c6fd048a 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 5727a7015aaa..7530c6fd048a 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 43c41e5e9c99..8c9c7f706e39 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 11/17] 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 bcb7665210cf..10c70bc98215 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -831,6 +831,7 @@ 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 d04dfcd8acc0..213243fa8237 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -14,6 +14,7 @@ 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 @@ def get_env_setting(setting): # 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 12/17] 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 bb8123527a57..e80db0b54320 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 422b76ce0614..7abc59820b69 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 ad0a562e1002..818bcefe7517 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 aeb9b1f2be41..74b6bd4004c7 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 3dab18dd1141..fc8cc6e85c03 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 13/17] 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 0d8e18f1d0ed..c4674d259cd0 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 422b76ce0614..3336783571c0 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 ad0a562e1002..779d2826d27d 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 aeb9b1f2be41..fda28ab2d7ec 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 9abf4935ab87..31dcbbf984e0 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 3dab18dd1141..e59f34f5efcd 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 14/17] 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 656523e46680..2438510c2764 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 e115f667df2c..b8ada003ca56 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 48b940775f80..95c68b314c75 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 06e2ac85e4cd..daf088184cf9 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 ef495fc68911..a728884e70d5 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 15/17] 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 af5159764c92..1ea265e868f2 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 Meta: 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 9135ea556bea..fde2bb9cbc0c 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 3fa8f80f4d1a..28e95c16b8f9 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 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 @@ def test_providers_displayed_for_login(self): 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 @@ def test_tpa_hint_provider_displayed_for_login(self): ] 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 @@ def test_tpa_hint_provider_displayed_for_login(self): ] 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 @@ def test_tpa_hint_provider_displayed_for_login(self): ] 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 @@ def test_tpa_hint_provider_displayed_for_login(self): ] 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 @@ def test_oauth2_enabled_only_for_supplied_backend(self): 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 @@ def test_get_returns_none_if_provider_not_enabled(self): # 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 c6a406c5301c..d24ba8cfd7db 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 16/17] 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 4ff21dff3f3b..4867160c1b83 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 000000000000..fa00df307c64 --- /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 7846b907c4e6..ff855482e1fc 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 852d67ae4ab6..a60ef381cd54 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 000000000000..0a48016ca2b4 --- /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 2eafb933b483..8e57c1546ff0 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 @@ def test_create_taxonomy(self, user_attr, expected_status): "name": "taxonomy_data", "description": "This is a description", "enabled": True, - "required": True, "allow_multiple": True, } @@ -444,7 +441,6 @@ def test_update_taxonomy(self, user_attr, taxonomy_attr, expected_status): "name": "new name", "description": taxonomy.description, "enabled": taxonomy.enabled, - "required": taxonomy.required, }, ) @@ -540,7 +536,6 @@ def test_patch_taxonomy(self, user_attr, taxonomy_attr, expected_status): "name": "new name", "description": taxonomy.description, "enabled": taxonomy.enabled, - "required": taxonomy.required, }, ) @@ -668,13 +663,13 @@ def setUp(self): ) 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 @@ def setUp(self): 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 f3c964b836c7..39e1742b9ee4 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 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 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 @@ def _check_tag(self, object_key: ContentKey, taxonomy_id: int, value: str | None 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 e80db0b54320..c9dfcc927eb1 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 2438510c2764..60dd1232f626 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 95c68b314c75..595ebb3e8765 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 daf088184cf9..3b6aa57299a2 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 a728884e70d5..125de023503a 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 6825702d087dddda79bd241c4a3034c8d8401e2c Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Mon, 9 Oct 2023 14:46:47 +1030 Subject: [PATCH 17/17] feat: adds endpoint for downloading the taxonomy templates --- .../rest_api/v1/tests/test_views.py | 31 +++++++++++++++++++ .../content_tagging/rest_api/v1/urls.py | 10 +++++- 2 files changed, 40 insertions(+), 1 deletion(-) 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 8e57c1546ff0..8cb4b94fd227 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 @@ -25,6 +25,7 @@ TAXONOMY_ORG_LIST_URL = "/api/content_tagging/v1/taxonomies/" TAXONOMY_ORG_DETAIL_URL = "/api/content_tagging/v1/taxonomies/{pk}/" OBJECT_TAG_UPDATE_URL = "/api/content_tagging/v1/object_tags/{object_id}/?taxonomy={taxonomy_id}" +TAXONOMY_TEMPLATE_URL = "/api/content_tagging/v1/taxonomies/import/{filename}" def check_taxonomy( @@ -844,3 +845,33 @@ def test_tag_unauthorized(self, objectid_attr): response = self.client.put(url, {"tags": ["Tag 1"]}, format="json") assert response.status_code == status.HTTP_403_FORBIDDEN + + +@skip_unless_cms +@ddt.ddt +class TestDownloadTemplateView(APITestCase): + """ + Tests the taxonomy template downloads. + """ + @ddt.data( + ("template.csv", "text/csv"), + ("template.json", "application/json"), + ) + @ddt.unpack + def test_download(self, filename, content_type): + url = TAXONOMY_TEMPLATE_URL.format(filename=filename) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.headers['Content-Type'] == content_type + assert response.headers['Content-Disposition'] == f'attachment; filename="{filename}"' + assert int(response.headers['Content-Length']) > 0 + + def test_download_not_found(self): + url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt") + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_download_method_not_allowed(self): + url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt") + response = self.client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py index 5c0bceb38ee4..d6fc047e5288 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py @@ -6,7 +6,10 @@ from django.urls.conf import path, include -from openedx_tagging.core.tagging.rest_api.v1 import views as oel_tagging_views +from openedx_tagging.core.tagging.rest_api.v1 import ( + views as oel_tagging_views, + views_import as oel_tagging_views_import, +) from . import views @@ -15,5 +18,10 @@ router.register("object_tags", oel_tagging_views.ObjectTagView, basename="object_tag") urlpatterns = [ + path( + "taxonomies/import/template.", + oel_tagging_views_import.TemplateView.as_view(), + name="taxonomy-import-template", + ), path('', include(router.urls)) ]