diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f9abcc671fb..a05b78e8837a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,6 +17,7 @@ lms/djangoapps/instructor_task/ lms/djangoapps/mobile_api/ openedx/core/djangoapps/credentials @openedx/2U-aperture openedx/core/djangoapps/credit @openedx/2U-aperture +openedx/core/djangoapps/enrollments/ @openedx/2U-aperture openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch openedx/core/djangoapps/user_api/ @openedx/2U-aperture @@ -37,8 +38,9 @@ lms/djangoapps/certificates/ @openedx/2U- # Discovery common/djangoapps/course_modes/ common/djangoapps/enrollment/ +lms/djangoapps/branding/ @openedx/2U-aperture lms/djangoapps/commerce/ -lms/djangoapps/experiments/ +lms/djangoapps/experiments/ @openedx/2U-aperture lms/djangoapps/learner_dashboard/ @openedx/2U-aperture lms/djangoapps/learner_home/ @openedx/2U-aperture openedx/features/content_type_gating/ diff --git a/README.rst b/README.rst index e74176faf91e..64186fca3ace 100644 --- a/README.rst +++ b/README.rst @@ -124,6 +124,35 @@ sites):: ./manage.py lms collectstatic ./manage.py cms collectstatic +Set up CMS SSO (for Development):: + + ./manage.py lms manage_user studio_worker example@example.com --unusable-password + # DO NOT DO THIS IN PRODUCTION. It will make your auth insecure. + ./manage.py lms create_dot_application studio-sso-id studio_worker \ + --grant-type authorization-code \ + --skip-authorization \ + --redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \ + --scopes user_id \ + --client-id 'studio-sso-id' \ + --client-secret 'studio-sso-secret' + +Set up CMS SSO (for Production): + +* Create the CMS user and the OAuth application:: + + ./manage.py lms manage_user studio_worker --unusable-password + ./manage.py lms create_dot_application studio-sso-id studio_worker \ + --grant-type authorization-code \ + --skip-authorization \ + --redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \ + --scopes user_id + +* Log into Django admin (eg. http://localhost:18000/admin/oauth2_provider/application/), + click into the application you created above (``studio-sso-id``), and copy its "Client secret". +* In your private LMS_CFG yaml file or your private Django settings module: + + * Set ``SOCIAL_AUTH_EDX_OAUTH2_KEY`` to the client ID (``studio-sso-id``). + * Set ``SOCIAL_AUTH_EDX_OAUTH2_SECRET`` to the client secret (which you copied). Run the Platform ---------------- @@ -131,11 +160,11 @@ First, ensure MySQL, Mongo, and Memcached are running. Start the LMS:: - ./manage.py lms runserver + ./manage.py lms runserver 18000 Start the CMS:: - ./manage.py cms runserver + ./manage.py cms runserver 18010 This will give you a mostly-headless Open edX platform. Most frontends have been migrated to "Micro-Frontends (MFEs)" which need to be installed and run diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index e944d67eda1b..1d3a510cdc4c 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -267,7 +267,8 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ################ Using LMS SSO for login to Studio ################ SOCIAL_AUTH_EDX_OAUTH2_KEY = 'studio-sso-key' SOCIAL_AUTH_EDX_OAUTH2_SECRET = 'studio-sso-secret' # in stage, prod would be high-entropy secret -SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = 'http://edx.devstack.lms:18000' # routed internally server-to-server +# routed internally server-to-server +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = ENV_TOKENS.get('SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT', 'http://edx.devstack.lms:18000') SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = 'http://localhost:18000' # used in browser redirect # Don't form the return redirect URL with HTTPS on devstack diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 7b26cb041a0a..4bfc710fe901 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -583,7 +583,7 @@ def test_verification_signal(self): """ Verification signal is sent upon approval. """ - with mock.patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with mock.patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: # Begin the pipeline. pipeline.set_id_verification_status( auth_entry=pipeline.AUTH_ENTRY_LOGIN, diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index 9f6540651eeb..fb8749bf45a9 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -1,7 +1,6 @@ """ Signal handlers for the bulk_email app """ -from django.contrib.auth import get_user_model from django.dispatch import receiver from eventtracking import tracker @@ -32,29 +31,26 @@ def ace_email_sent_handler(sender, **kwargs): """ When an email is sent using ACE, this method will create an event to detect ace email success status """ - # Fetch the message object from kwargs, defaulting to None if not present - message = kwargs.get('message', None) - - user_model = get_user_model() - try: - user_id = user_model.objects.get(email=message.recipient.email_address).id - except user_model.DoesNotExist: - user_id = None - course_email = message.context.get('course_email', None) - course_id = message.context.get('course_id') + # Fetch the message dictionary from kwargs, defaulting to {} if not present + message = kwargs.get('message', {}) + recipient = message.get('recipient', {}) + message_name = message.get('name', None) + context = message.get('context', {}) + email_address = recipient.get('email', None) + user_id = recipient.get('user_id', None) + channel = message.get('channel', None) + course_id = context.get('course_id', None) if not course_id: + course_email = context.get('course_email', None) course_id = course_email.course_id if course_email else None - try: - channel = sender.__class__.__name__ - except AttributeError: - channel = 'Other' + tracker.emit( 'edx.ace.message_sent', { - 'message_type': message.name, + 'message_type': message_name, 'channel': channel, 'course_id': course_id, 'user_id': user_id, - 'user_email': message.recipient.email_address, + 'user_email': email_address, } ) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 2b96af786a97..184dfd0e6869 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -474,6 +474,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas 'course_id': str(course_email.course_id), 'to_list': [user_obj.get('email', '') for user_obj in to_list], 'total_recipients': total_recipients, + 'ace_enabled_for_bulk_email': is_bulk_email_edx_ace_enabled(), } ) # Exclude optouts (if not a retry): diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py index 7279b9426347..a8c9070df038 100644 --- a/lms/djangoapps/ccx/api/v0/tests/test_views.py +++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py @@ -730,8 +730,8 @@ def make_ccx(self, max_students_allowed=200): course_id=ccx_course_key, student_email=self.coach.email, auto_enroll=True, - email_students=False, - email_params=email_params, + message_students=False, + message_params=email_params, ) return ccx diff --git a/lms/djangoapps/ccx/api/v0/views.py b/lms/djangoapps/ccx/api/v0/views.py index b3e345a77022..8ca15e065006 100644 --- a/lms/djangoapps/ccx/api/v0/views.py +++ b/lms/djangoapps/ccx/api/v0/views.py @@ -505,8 +505,8 @@ def post(self, request): course_id=ccx_course_key, student_email=coach.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) # assign staff role for the coach to the newly created ccx assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) @@ -768,8 +768,8 @@ def patch(self, request, ccx_course_id=None): course_id=ccx_course_key, student_email=coach.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) # make the new coach staff on the CCX assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) diff --git a/lms/djangoapps/ccx/utils.py b/lms/djangoapps/ccx/utils.py index 9f7c0eff3963..28ecbba34947 100644 --- a/lms/djangoapps/ccx/utils.py +++ b/lms/djangoapps/ccx/utils.py @@ -269,7 +269,13 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", error) errors.append(error) break - enroll_email(course_key, email, auto_enroll=True, email_students=email_students, email_params=email_params) + enroll_email( + course_key, + email, + auto_enroll=True, + message_students=email_students, + message_params=email_params + ) elif action == 'Unenroll' or action == 'revoke': # lint-amnesty, pylint: disable=consider-using-in for identifier in identifiers: try: @@ -278,7 +284,7 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", exp) errors.append(f"{exp}") continue - unenroll_email(course_key, email, email_students=email_students, email_params=email_params) + unenroll_email(course_key, email, message_students=email_students, message_params=email_params) return errors @@ -348,8 +354,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em course_id=ccx_key, student_email=staff.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) # allow 'staff' access on ccx to staff of master course @@ -373,8 +379,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em course_id=ccx_key, student_email=instructor.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) # allow 'instructor' access on ccx to instructor of master course @@ -417,8 +423,8 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se unenroll_email( course_id=ccx_key, student_email=staff.email, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) for instructor in list_instructor: @@ -430,6 +436,6 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se unenroll_email( course_id=ccx_key, student_email=instructor.email, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py index 3c5f3130a195..7c6a75aaf6d4 100644 --- a/lms/djangoapps/ccx/views.py +++ b/lms/djangoapps/ccx/views.py @@ -223,8 +223,8 @@ def create_ccx(request, course, ccx=None): course_id=ccx_id, student_email=request.user.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) assign_staff_role_to_ccx(ccx_id, request.user, course.id) diff --git a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl index beef611e4393..d7ca8fd9a400 100644 --- a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl +++ b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl @@ -31,7 +31,7 @@ workspace { } grades_app -> signal_handlers "Emits COURSE_GRADE_NOW_PASSED signal" - verify_student_app -> signal_handlers "Emits LEARNER_NOW_VERIFIED signal" + verify_student_app -> signal_handlers "Emits IDV_ATTEMPT_APPROVED signal" student_app -> signal_handlers "Emits ENROLLMENT_TRACK_UPDATED signal" allowlist -> signal_handlers "Emits APPEND_CERTIFICATE_ALLOWLIST signal" signal_handlers -> generation_handler "Invokes generate_allowlist_certificate()" diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index d8db7bbf9ce8..53055bf9c86e 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -32,9 +32,8 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_FAILED, COURSE_GRADE_NOW_PASSED, - LEARNER_NOW_VERIFIED ) -from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, IDV_ATTEMPT_APPROVED User = get_user_model() @@ -118,14 +117,17 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade') -@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed") -def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylint: disable=unused-argument +@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="learner_track_changed") +def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pylint: disable=unused-argument """ Listen for a signal indicating that the user's id verification status has changed. """ if not auto_certificate_generation_enabled(): return + event_data = kwargs.get('idv_attempt') + user = User.objects.get(id=event_data.user.id) + user_enrollments = CourseEnrollment.enrollments_for_user(user=user) expected_verification_status = IDVerificationService.user_status(user) expected_verification_status = expected_verification_status['status'] diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index d475cffbfb66..7b5552801349 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -13,22 +13,20 @@ 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 xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from openedx_events.tests.utils import OpenEdxEventsTestMixin from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.certificates.api import has_self_generated_certificates_enabled from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - GeneratedCertificate -) +from lms.djangoapps.certificates.models import CertificateGenerationConfiguration, GeneratedCertificate from lms.djangoapps.certificates.signals import handle_exam_attempt_rejected_event from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.tests.utils import mock_passing_grade from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class SelfGeneratedCertsSignalTest(ModuleStoreTestCase): @@ -302,10 +300,17 @@ def test_failing_grade_allowlist(self): assert cert.status == CertificateStatuses.downloadable -class LearnerIdVerificationTest(ModuleStoreTestCase): +class LearnerIdVerificationTest(ModuleStoreTestCase, OpenEdxEventsTestMixin): """ Tests for certificate generation task firing on learner id verification """ + ENABLED_OPENEDX_EVENTS = ['org.openedx.learning.idv_attempt.approved.v1'] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.start_events_isolation() + def setUp(self): super().setUp() self.course_one = CourseFactory.create(self_paced=True) diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index 25abcf80d486..f65faf7f2a67 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -399,4 +399,18 @@ def clean_thread_html_body(html_body): for match in html_body.find_all(tag): match.unwrap() + # Replace tags that are not allowed in email + tags_to_update = [ + {"source": "button", "target": "span"}, + {"source": "h1", "target": "h4"}, + {"source": "h2", "target": "h4"}, + {"source": "h3", "target": "h4"}, + ] + for tag_dict in tags_to_update: + for source_tag in html_body.find_all(tag_dict['source']): + target_tag = html_body.new_tag(tag_dict['target'], **source_tag.attrs) + if source_tag.string: + target_tag.string = source_tag.string + source_tag.replace_with(target_tag) + return str(html_body) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py index f1a71fd1239e..d92e1000feb5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py @@ -168,3 +168,29 @@ def test_only_script_tag(self): result = clean_thread_html_body(html_body) self.assertEqual(result.strip(), expected_output) + + def test_button_tag_replace(self): + """ + Tests that the clean_thread_html_body function replaces the button tag with span tag + """ + # Tests for button replacement tag with text + html_body = '' + expected_output = 'Button' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + # Tests button tag replacement without text + html_body = '' + expected_output = '' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_heading_tag_replace(self): + """ + Tests that the clean_thread_html_body function replaces the h1, h2 and h3 tags with h4 tag + """ + for tag in ['h1', 'h2', 'h3']: + html_body = f'<{tag}>Heading' + expected_output = '

Heading

' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 2aa7d36456c4..73c19d27858c 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -109,8 +109,10 @@ def create_message_context(comment, site): 'course_id': str(thread.course_id), 'comment_id': comment.id, 'comment_body': comment.body, + 'comment_body_text': comment.body_text, 'comment_author_id': comment.user_id, 'comment_created_at': comment.created_at, # comment_client models dates are already serialized + 'comment_parent_id': comment.parent_id, 'thread_id': thread.id, 'thread_title': thread.title, 'thread_author_id': thread.user_id, diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index d483a82dbd66..3fef4f5f7cef 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.sites.models import Site from edx_ace import ace +from edx_ace.channel import ChannelType from edx_ace.recipient import Recipient from edx_ace.utils import date from edx_django_utils.monitoring import set_code_owner_attribute @@ -74,6 +75,12 @@ def __init__(self, *args, **kwargs): self.options['transactional'] = True +class CommentNotification(BaseMessageType): + """ + Notify discussion participants of new comments. + """ + + @shared_task(base=LoggedTask) @set_code_owner_attribute def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring @@ -82,16 +89,39 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function if _should_send_message(context): context['site'] = Site.objects.get(id=context['site_id']) thread_author = User.objects.get(id=context['thread_author_id']) - with emulate_http_request(site=context['site'], user=thread_author): - message_context = _build_message_context(context) + comment_author = User.objects.get(id=context['comment_author_id']) + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context, notification_type='forum_response') message = ResponseNotification().personalize( Recipient(thread_author.id, thread_author.email), _get_course_language(context['course_id']), message_context ) - log.info('Sending forum comment email notification with context %s', message_context) - ace.send(message) + log.info('Sending forum comment notification with context %s', message_context) + if _is_first_comment(context['comment_id'], context['thread_id']): + limit_to_channels = None + else: + limit_to_channels = [ChannelType.PUSH] + ace.send(message, limit_to_channels=limit_to_channels) + _track_notification_sent(message, context) + + elif _should_send_subcomment_message(context): + context['site'] = Site.objects.get(id=context['site_id']) + comment_author = User.objects.get(id=context['comment_author_id']) + thread_author = User.objects.get(id=context['thread_author_id']) + + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context) + message = CommentNotification().personalize( + Recipient(thread_author.id, thread_author.email), + _get_course_language(context['course_id']), + message_context + ) + log.info('Sending forum comment notification with context %s', message_context) + ace.send(message, limit_to_channels=[ChannelType.PUSH]) _track_notification_sent(message, context) + else: + return @shared_task(base=LoggedTask) @@ -154,19 +184,36 @@ def _should_send_message(context): return ( _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and _is_not_subcomment(context['comment_id']) and - _is_first_comment(context['comment_id'], context['thread_id']) + not _comment_author_is_thread_author(context) ) +def _should_send_subcomment_message(context): + cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id']) + return ( + _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and + _is_subcomment(context['comment_id']) and + not _comment_author_is_thread_author(context) + ) + + +def _comment_author_is_thread_author(context): + return context.get('comment_author_id', '') == context['thread_author_id'] + + def _is_content_still_reported(context): if context.get('comment_id') is not None: return len(cc.Comment.find(context['comment_id']).abuse_flaggers) > 0 return len(cc.Thread.find(context['thread_id']).abuse_flaggers) > 0 -def _is_not_subcomment(comment_id): +def _is_subcomment(comment_id): comment = cc.Comment.find(id=comment_id).retrieve() - return not getattr(comment, 'parent_id', None) + return getattr(comment, 'parent_id', None) + + +def _is_not_subcomment(comment_id): + return not _is_subcomment(comment_id) def _is_first_comment(comment_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring @@ -204,7 +251,7 @@ def _get_course_language(course_id): return language -def _build_message_context(context): # lint-amnesty, pylint: disable=missing-function-docstring +def _build_message_context(context, notification_type='forum_comment'): # lint-amnesty, pylint: disable=missing-function-docstring message_context = get_base_template_context(context['site']) message_context.update(context) thread_author = User.objects.get(id=context['thread_author_id']) @@ -218,6 +265,14 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu 'thread_username': thread_author.username, 'comment_username': comment_author.username, 'post_link': post_link, + 'push_notification_extra_context': { + 'course_id': str(context['course_id']), + 'parent_id': str(context['comment_parent_id']), + 'notification_type': notification_type, + 'topic_id': str(context['thread_commentable_id']), + 'thread_id': context['thread_id'], + 'comment_id': context['comment_id'], + }, 'comment_created_at': date.deserialize(context['comment_created_at']), 'thread_created_at': date.deserialize(context['thread_created_at']) }) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt new file mode 100644 index 000000000000..391e3d8ef4d7 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt @@ -0,0 +1,3 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %} +{{ comment_body_text }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt new file mode 100644 index 000000000000..a9ea6f298c03 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt new file mode 100644 index 000000000000..ee97a6e329f5 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}: {{ comment_body|truncatechars:200 }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt new file mode 100644 index 000000000000..03caca997346 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index f6cce4437546..92dadac9d9ee 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -19,7 +19,11 @@ import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY -from lms.djangoapps.discussion.tasks import _should_send_message, _track_notification_sent +from lms.djangoapps.discussion.tasks import ( + _is_first_comment, + _should_send_message, + _track_notification_sent, +) from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ForumsConfig @@ -222,6 +226,8 @@ def setUp(self): self.ace_send_patcher = mock.patch('edx_ace.ace.send') self.mock_ace_send = self.ace_send_patcher.start() + self.mock_message_patcher = mock.patch('lms.djangoapps.discussion.tasks.ResponseNotification') + self.mock_message = self.mock_message_patcher.start() thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) @@ -231,10 +237,12 @@ def tearDown(self): super().tearDown() self.request_patcher.stop() self.ace_send_patcher.stop() + self.mock_message_patcher.stop() self.permalink_patcher.stop() @ddt.data(True, False) def test_send_discussion_email_notification(self, user_subscribed): + self.mock_message_patcher.stop() if user_subscribed: non_matching_id = 'not-a-match' # with per_page left with a default value of 1, this ensures @@ -271,8 +279,10 @@ def test_send_discussion_email_notification(self, user_subscribed): expected_message_context.update({ 'comment_author_id': self.comment_author.id, 'comment_body': comment['body'], + 'comment_body_text': comment.body_text, 'comment_created_at': ONE_HOUR_AGO, 'comment_id': comment['id'], + 'comment_parent_id': comment['parent_id'], 'comment_username': self.comment_author.username, 'course_id': self.course.id, 'thread_author_id': self.thread_author.id, @@ -283,7 +293,15 @@ def test_send_discussion_email_notification(self, user_subscribed): 'thread_commentable_id': thread['commentable_id'], 'post_link': f'https://{site.domain}{self.mock_permalink.return_value}', 'site': site, - 'site_id': site.id + 'site_id': site.id, + 'push_notification_extra_context': { + 'notification_type': 'forum_response', + 'topic_id': thread['commentable_id'], + 'course_id': comment['course_id'], + 'parent_id': str(comment['parent_id']), + 'thread_id': thread['id'], + 'comment_id': comment['id'], + }, }) expected_recipient = Recipient(self.thread_author.id, self.thread_author.email) actual_message = self.mock_ace_send.call_args_list[0][0][0] @@ -326,7 +344,9 @@ def run_should_not_send_email_test(self, thread, comment_dict): 'comment_id': comment_dict['id'], 'thread_id': thread['id'], }) - assert actual_result is False + + should_email_send = _is_first_comment(comment_dict['id'], thread['id']) + assert not should_email_send assert not self.mock_ace_send.called def test_subcomment_should_not_send_email(self): diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 9255d113f038..a5d25769ca10 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -86,8 +86,8 @@ def _change_access(course, user, level, action, send_email=True): course_id=course.id, student_email=user.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) role.add_users(user) elif action == 'revoke': diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 896d0deadcd9..ed344876eb42 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -125,7 +125,14 @@ def get_user_email_language(user): return UserPreference.get_value(user, LANGUAGE_KEY) -def enroll_email(course_id, student_email, auto_enroll=False, email_students=False, email_params=None, language=None): +def enroll_email( + course_id, + student_email, + auto_enroll=False, + message_students=False, + message_params=None, + language=None +): """ Enroll a student by email. @@ -133,8 +140,8 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal `auto_enroll` determines what is put in CourseEnrollmentAllowed.auto_enroll if auto_enroll is set, then when the email registers, they will be enrolled in the course automatically. - `email_students` determines if student should be notified of action by email. - `email_params` parameters used while parsing email templates (a `dict`). + `message_students` determines if student should be notified of action by email or push message. + `message_params` parameters used while parsing message templates (a `dict`). `language` is the language used to render the email. returns two EmailEnrollmentState's @@ -142,6 +149,16 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal """ previous_state = EmailEnrollmentState(course_id, student_email) enrollment_obj = None + if message_params: + message_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'enroll', + 'course_id': str(course_id), + }, + }) + else: + message_params = {} if previous_state.user and previous_state.user.is_active: # if the student is currently unenrolled, don't enroll them in their # previous mode @@ -159,85 +176,99 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal course_mode = previous_state.mode enrollment_obj = CourseEnrollment.enroll_by_email(student_email, course_id, course_mode) - if email_students: - email_params['message_type'] = 'enrolled_enroll' - email_params['email_address'] = student_email - email_params['user_id'] = previous_state.user.id - email_params['full_name'] = previous_state.full_name - send_mail_to_student(student_email, email_params, language=language) + if message_students: + message_params['message_type'] = 'enrolled_enroll' + message_params['email_address'] = student_email + message_params['user_id'] = previous_state.user.id + message_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, message_params, language=language) elif not is_email_retired(student_email): cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email) cea.auto_enroll = auto_enroll cea.save() - if email_students: - email_params['message_type'] = 'allowed_enroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'allowed_enroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id - send_mail_to_student(student_email, email_params, language=language) + message_params['user_id'] = previous_state.user.id + send_mail_to_student(student_email, message_params, language=language) after_state = EmailEnrollmentState(course_id, student_email) return previous_state, after_state, enrollment_obj -def unenroll_email(course_id, student_email, email_students=False, email_params=None, language=None): +def unenroll_email(course_id, student_email, message_students=False, message_params=None, language=None): """ Unenroll a student by email. `student_email` is student's emails e.g. "foo@bar.com" - `email_students` determines if student should be notified of action by email. - `email_params` parameters used while parsing email templates (a `dict`). + `message_students` determines if student should be notified of action by email or push message. + `message_params` parameters used while parsing email templates (a `dict`). `language` is the language used to render the email. returns two EmailEnrollmentState's representing state before and after the action. """ previous_state = EmailEnrollmentState(course_id, student_email) + if message_params: + message_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'unenroll', + }, + }) + else: + message_params = {} if previous_state.enrollment: CourseEnrollment.unenroll_by_email(student_email, course_id) - if email_students: - email_params['message_type'] = 'enrolled_unenroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'enrolled_unenroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id - email_params['full_name'] = previous_state.full_name - send_mail_to_student(student_email, email_params, language=language) + message_params['user_id'] = previous_state.user.id + message_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, message_params, language=language) if previous_state.allowed: CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete() - if email_students: - email_params['message_type'] = 'allowed_unenroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'allowed_unenroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id + message_params['user_id'] = previous_state.user.id # Since no User object exists for this student there is no "full_name" available. - send_mail_to_student(student_email, email_params, language=language) + send_mail_to_student(student_email, message_params, language=language) after_state = EmailEnrollmentState(course_id, student_email) return previous_state, after_state -def send_beta_role_email(action, user, email_params): +def send_beta_role_email(action, user, message_params): """ Send an email to a user added or removed as a beta tester. `action` is one of 'add' or 'remove' `user` is the User affected - `email_params` parameters used while parsing email templates (a `dict`). + `message_params` parameters used while parsing email templates (a `dict`). """ if action in ('add', 'remove'): - email_params['message_type'] = '%s_beta_tester' % action - email_params['email_address'] = user.email - email_params['user_id'] = user.id - email_params['full_name'] = user.profile.name + message_params['message_type'] = '%s_beta_tester' % action + message_params['email_address'] = user.email + message_params['user_id'] = user.id + message_params['full_name'] = user.profile.name + message_params['app_label'] = 'instructor' + message_params['push_notification_extra_context'] = { + 'notification_type': message_params['message_type'], + 'course_id': str(getattr(message_params.get('course'), 'id', '')), + } else: raise ValueError(f"Unexpected action received '{action}' - expected 'add' or 'remove'") trying_to_add_inactive_user = not user.is_active and action == 'add' if not trying_to_add_inactive_user: - send_mail_to_student(user.email, email_params, language=get_user_email_language(user)) + send_mail_to_student(user.email, message_params, language=get_user_email_language(user)) @contextmanager diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index e8bcc81318da..51fc514c4879 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -4175,6 +4175,16 @@ def test_change_due_date_with_reason(self): # This operation regenerates the cache, so we can use cached results from edx-when. assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_reset_due_date_with_reason(self): + url = reverse('reset_due_date', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, { + 'student': self.user1.username, + 'url': str(self.week1.location), + 'reason': 'Testing reason.' # this is optional field. + }) + assert response.status_code == 200 + assert 'Successfully reset due date for student' in response.content.decode('utf-8') + def test_change_to_invalid_due_date(self): url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 58556ee9ab02..45c460d32908 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -137,7 +137,6 @@ handle_dashboard_error, keep_field_private, parse_datetime, - require_student_from_identifier, set_due_date_extension, strip_if_string, ) @@ -515,11 +514,13 @@ def post(self, request, course_id): # pylint: disable=too-many-statements reason='Enrolling via csv upload', state_transition=UNENROLLED_TO_ENROLLED, ) - enroll_email(course_id=course_id, - student_email=email, - auto_enroll=True, - email_students=notify_by_email, - email_params=email_params) + enroll_email( + course_id=course_id, + student_email=email, + auto_enroll=True, + message_students=notify_by_email, + message_params=email_params, + ) else: # update the course mode if already enrolled existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) @@ -3035,37 +3036,59 @@ def post(self, request, course_id): due_date.strftime('%Y-%m-%d %H:%M'))) -@handle_dashboard_error -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GIVE_STUDENT_EXTENSION) -@require_post_params('student', 'url') -def reset_due_date(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ResetDueDate(APIView): """ Rescinds a due date extension for a student on a particular unit. """ - course = get_course_by_id(CourseKey.from_string(course_id)) - student = require_student_from_identifier(request.POST.get('student')) - unit = find_unit(course, request.POST.get('url')) - reason = strip_tags(request.POST.get('reason', '')) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = BlockDueDateSerializer - version = getattr(course, 'course_version', None) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + reset a due date extension to a student for a particular unit. + params: + url (str): The URL related to the block that needs the due date update. + student (str): The email or username of the student whose access is being modified. + reason (str): Optional param. + """ + serializer_data = self.serializer_class(data=request.data, context={'disable_due_datetime': True}) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - original_due_date = get_date_for_block(course_id, unit.location, published_version=version) + student = serializer_data.validated_data.get('student') + if not student: + response_payload = { + 'error': f'Could not find student matching identifier: {request.data.get("student")}' + } + return JsonResponse(response_payload) - set_due_date_extension(course, unit, student, None, request.user, reason=reason) - if not original_due_date: - # It's possible the normal due date was deleted after an extension was granted: - return JsonResponse( - _("Successfully removed invalid due date extension (unit has no due date).") - ) + course = get_course_by_id(CourseKey.from_string(course_id)) + unit = find_unit(course, serializer_data.validated_data.get('url')) + reason = strip_tags(serializer_data.validated_data.get('reason', '')) + + version = getattr(course, 'course_version', None) + + original_due_date = get_date_for_block(course_id, unit.location, published_version=version) + + try: + set_due_date_extension(course, unit, student, None, request.user, reason=reason) + if not original_due_date: + # It's possible the normal due date was deleted after an extension was granted: + return JsonResponse( + _("Successfully removed invalid due date extension (unit has no due date).") + ) + + original_due_date_str = original_due_date.strftime('%Y-%m-%d %H:%M') + return JsonResponse(_( + 'Successfully reset due date for student {0} for {1} ' + 'to {2}').format(student.profile.name, _display_unit(unit), + original_due_date_str)) - original_due_date_str = original_due_date.strftime('%Y-%m-%d %H:%M') - return JsonResponse(_( - 'Successfully reset due date for student {0} for {1} ' - 'to {2}').format(student.profile.name, _display_unit(unit), - original_due_date_str)) + except Exception as error: # pylint: disable=broad-except + return JsonResponse({'error': str(error)}, status=400) @handle_dashboard_error diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 9c0939a1c1b8..a248b46ae531 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -51,7 +51,7 @@ path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'), path('change_due_date', api.ChangeDueDate.as_view(), name='change_due_date'), path('send_email', api.SendEmail.as_view(), name='send_email'), - path('reset_due_date', api.reset_due_date, name='reset_due_date'), + path('reset_due_date', api.ResetDueDate.as_view(), name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index da91eba43124..5d123ad66c81 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -215,3 +215,10 @@ def validate_student(self, value): return None return user + + def __init__(self, *args, **kwargs): + # Get context to check if `due_datetime` should be optional + disable_due_datetime = kwargs.get('context', {}).get('disable_due_datetime', False) + super().__init__(*args, **kwargs) + if disable_due_datetime: + self.fields['due_datetime'].required = False diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index b3471715b9dc..3d156f3640ca 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -3,7 +3,7 @@ """ from datetime import date, timedelta -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin from django.conf import settings from django.urls import reverse @@ -132,7 +132,13 @@ def get_upgradeUrl(self, instance): ) if ecommerce_payment_page and verified_sku: - return f"{ecommerce_payment_page}?sku={verified_sku}" + query_params = { + 'sku': verified_sku, + 'course_run_key': str(instance.course_id) + } + encoded_params = urlencode(query_params) + upgrade_url = f"{ecommerce_payment_page}?{encoded_params}" + return upgrade_url def get_resumeUrl(self, instance): return self.context.get("resume_course_urls", {}).get(instance.course_id) diff --git a/lms/djangoapps/mobile_api/notifications/__init__.py b/lms/djangoapps/mobile_api/notifications/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/notifications/urls.py b/lms/djangoapps/mobile_api/notifications/urls.py new file mode 100644 index 000000000000..120fa39a975a --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/urls.py @@ -0,0 +1,12 @@ +""" +URLs for the mobile_api.notifications APIs. +""" +from django.urls import path +from .views import GCMDeviceViewSet + + +create_gcm_device_post_view = GCMDeviceViewSet.as_view({'post': 'create'}) + +urlpatterns = [ + path('create-token/', create_gcm_device_post_view, name='gcmdevice-list'), +] diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py new file mode 100644 index 000000000000..2621c2a3a2fb --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -0,0 +1,53 @@ +""" +This module contains the view for registering a device for push notifications. +""" +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from edx_ace.push_notifications.views import GCMDeviceViewSet as GCMDeviceViewSetBase + +from ..decorators import mobile_view + + +@mobile_view() +class GCMDeviceViewSet(GCMDeviceViewSetBase): + """ + **Use Case** + This endpoint allows clients to register a device for push notifications. + + If the device is already registered, the existing registration will be updated. + If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error. + + **Example Request** + POST /api/mobile/{version}/notifications/create-token/ + **POST Parameters** + The body of the POST request can include the following parameters. + * name (optional) - A name of the device. + * registration_id (required) - The device token of the device. + * device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex) + * active (optional) - Whether the device is active, default is True. + If False, the device will not receive notifications. + * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported. + * application_id (optional) - Opaque application identity, should be filled in for multiple + key/certificate access. Should be equal settings.FCM_APP_NAME. + **Example Response** + ```json + { + "id": 1, + "name": "My Device", + "registration_id": "fj3j4", + "device_id": 1234, + "active": true, + "date_created": "2024-04-18T07:39:37.132787Z", + "cloud_message_type": "FCM", + "application_id": "my_app_id" + } + ``` + """ + + def create(self, request, *args, **kwargs): + if not getattr(settings, 'PUSH_NOTIFICATIONS_SETTINGS', None): + return Response('Push notifications are not configured.', status.HTTP_501_NOT_IMPLEMENTED) + + return super().create(request, *args, **kwargs) diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py index 1ad34ced5de9..c7aacc0b669a 100644 --- a/lms/djangoapps/mobile_api/urls.py +++ b/lms/djangoapps/mobile_api/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('users/', include('lms.djangoapps.mobile_api.users.urls')), path('my_user_info', my_user_info, name='user-info'), + path('notifications/', include('lms.djangoapps.mobile_api.notifications.urls')), path('course_info/', include('lms.djangoapps.mobile_api.course_info.urls')), ] diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py index 4a93aa19f169..891ff9fda5d8 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py @@ -54,7 +54,7 @@ def test_performance(self): #self.assertNumQueries(100) def test_signal_called(self): - with patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: call_command('backfill_sso_verifications_for_old_account_links', '--provider-slug', self.provider.provider_id) # lint-amnesty, pylint: disable=line-too-long assert mock_signal.call_count == 1 diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 9d2195d1e5b0..23729c99a0b9 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -42,8 +42,9 @@ rsa_decrypt, rsa_encrypt ) -from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED from openedx.core.storage import get_storage +from openedx_events.learning.signals import IDV_ATTEMPT_APPROVED +from openedx_events.learning.data import UserData, VerificationAttemptData from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss @@ -248,13 +249,23 @@ def send_approval_signal(self, approved_by='None'): user_id=self.user, reviewer=approved_by )) - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=SSOVerification, - user=self.user + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from SSOVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from SSOVerification' log.info(message.format(user=self.user.username)) @@ -451,13 +462,24 @@ def approve(self, user_id=None, service=""): days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] ) self.save() - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=PhotoVerification, - user=self.user + + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from PhotoVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from PhotoVerification' log.info(message.format(user=self.user.username)) @status_before_must_be("ready", "must_retry") diff --git a/lms/envs/minimal.yml b/lms/envs/minimal.yml index d455d1f3dbf8..51d7bbf499c4 100644 --- a/lms/envs/minimal.yml +++ b/lms/envs/minimal.yml @@ -36,3 +36,6 @@ LMS_INTERNAL_ROOT_URL: "http://localhost" # So that Swagger config code doesn't complain API_ACCESS_MANAGER_EMAIL: "api-access@example.com" + +# So that you can login to studio on bare-metal +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT: 'http://localhost:18000' diff --git a/lms/static/js/instructor_dashboard/instructor_dashboard.js b/lms/static/js/instructor_dashboard/instructor_dashboard.js index 02972a93b6c4..f87e9db8e814 100644 --- a/lms/static/js/instructor_dashboard/instructor_dashboard.js +++ b/lms/static/js/instructor_dashboard/instructor_dashboard.js @@ -50,6 +50,12 @@ such that the value can be defined later than this assignment (file load order). $activeSection = null; + var usesProctoringLegacyView = function () { + // If the element #proctoring-mfe-view is present, then uses the new MFE + // and the legacy views should not be initialized. + return !document.getElementById('proctoring-mfe-view'); + } + SafeWaiter = (function() { function safeWaiter() { this.after_handlers = []; @@ -200,7 +206,7 @@ such that the value can be defined later than this assignment (file load order). } ]; // eslint-disable-next-line no-void - if (edx.instructor_dashboard.proctoring !== void 0) { + if (usesProctoringLegacyView() && edx.instructor_dashboard.proctoring !== void 0) { sectionsToInitialize = sectionsToInitialize.concat([ { constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView, diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/body.txt b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt new file mode 100644 index 000000000000..8373638fb41f --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been invited to be a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/title.txt b/lms/templates/instructor/edx_ace/addbetatester/push/title.txt new file mode 100644 index 000000000000..f1c4c6826cfa --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to a beta test for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt new file mode 100644 index 000000000000..41ff994310e3 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }}. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt new file mode 100644 index 000000000000..865657f1fcb1 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to register for {{ course_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt new file mode 100644 index 000000000000..c7342b6830b5 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from the course {{ course_name }}. Please disregard the invitation previously sent.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt new file mode 100644 index 000000000000..2bc61a840b48 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been unenrolled from {{ course_name }} at {{ site_name }}. This course will no longer appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt new file mode 100644 index 000000000000..e5ef12dc5f75 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been invited to join {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt new file mode 100644 index 000000000000..ebe884b30f08 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been enrolled in {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/body.txt b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt new file mode 100644 index 000000000000..89573aa4be1d --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}. This course will remain on your dashboard, but you will no longer be part of the beta testing group.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/title.txt b/lms/templates/instructor/edx_ace/removebetatester/push/title.txt new file mode 100644 index 000000000000..c09febbb455c --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index 11bfbce5c59f..634ab328ba6b 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -1,11 +1,14 @@ """ Settings for ace_common app. """ +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app ACE_ROUTING_KEY = 'edx.lms.core.default' def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function-docstring, missing-module-docstring + if 'push_notifications' not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append('push_notifications') settings.ACE_ENABLED_CHANNELS = [ 'django_email' ] @@ -22,3 +25,30 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function settings.ACE_ROUTING_KEY = ACE_ROUTING_KEY settings.FEATURES['test_django_plugin'] = True + settings.FCM_APP_NAME = 'fcm-edx-platform' + + settings.ACE_CHANNEL_DEFAULT_PUSH = 'push_notification' + # Note: To local development with Firebase, you must set FIREBASE_CREDENTIALS_PATH + # (path to json file with FIREBASE_CREDENTIALS) + # or FIREBASE_CREDENTIALS dictionary. + settings.FIREBASE_CREDENTIALS_PATH = None + settings.FIREBASE_CREDENTIALS = None + + settings.FIREBASE_APP = setup_firebase_app( + settings.FIREBASE_CREDENTIALS_PATH or settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME + ) + + if getattr(settings, 'FIREBASE_APP', None): + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('course_push_notification_optout') + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index cc4da91c18db..9ff56292012e 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -1,4 +1,5 @@ """Common environment variables unique to the ace_common plugin.""" +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app def plugin_settings(settings): @@ -26,3 +27,26 @@ def plugin_settings(settings): settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL = settings.ENV_TOKENS.get( 'ACE_CHANNEL_TRANSACTIONAL_EMAIL', settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL ) + settings.FCM_APP_NAME = settings.ENV_TOKENS.get('FCM_APP_NAME', settings.FCM_APP_NAME) + settings.FIREBASE_CREDENTIALS_PATH = settings.ENV_TOKENS.get( + 'FIREBASE_CREDENTIALS_PATH', settings.FIREBASE_CREDENTIALS_PATH + ) + settings.FIREBASE_CREDENTIALS = settings.ENV_TOKENS.get('FIREBASE_CREDENTIALS', settings.FIREBASE_CREDENTIALS) + + settings.FIREBASE_APP = setup_firebase_app( + settings.FIREBASE_CREDENTIALS_PATH or settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME + ) + if settings.FIREBASE_APP: + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('course_push_notification_optout') + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py new file mode 100644 index 000000000000..7cf38c821976 --- /dev/null +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -0,0 +1,21 @@ +""" +Utility functions for edx-ace. +""" +import logging + +log = logging.getLogger(__name__) + + +def setup_firebase_app(firebase_credentials, app_name='fcm-app'): + """ + Returns a Firebase app instance if the Firebase credentials are provided. + """ + import firebase_admin # pylint: disable=import-outside-toplevel + + if firebase_credentials: + try: + app = firebase_admin.get_app(app_name) + except ValueError: + certificate = firebase_admin.credentials.Certificate(firebase_credentials) + app = firebase_admin.initialize_app(certificate, name=app_name) + return app diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 71d09590d003..7fe964128e5a 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -315,6 +315,8 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: client.index(temp_index_name).update_distinct_attribute(Fields.usage_key) # Mark which attributes can be used for filtering/faceted search: client.index(temp_index_name).update_filterable_attributes([ + # Get specific block/collection using combination of block_id and context_key + Fields.block_id, Fields.block_type, Fields.context_key, Fields.org, diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 0b7a695a1c3e..c86f7eb40515 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -1,5 +1,5 @@ # pylint: disable=missing-docstring,protected-access - +from bs4 import BeautifulSoup from openedx.core.djangoapps.django_comment_common.comment_client import models, settings @@ -99,6 +99,14 @@ def unFlagAbuse(self, user, voteable, removeAll): ) voteable._update_from_response(response) + @property + def body_text(self): + """ + Return the text content of the comment html body. + """ + soup = BeautifulSoup(self.body, 'html.parser') + return soup.get_text() + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 02b49df89444..b57d88cea616 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -212,8 +212,8 @@ 'name': 'ora_grade_assigned', 'is_core': False, 'info': '', - 'web': False, - 'email': False, + 'web': True, + 'email': True, 'push': False, 'email_cadence': EmailCadence.DAILY, 'non_editable': [], diff --git a/openedx/core/djangoapps/notifications/policies.py b/openedx/core/djangoapps/notifications/policies.py new file mode 100644 index 000000000000..8d0a2d8d43a5 --- /dev/null +++ b/openedx/core/djangoapps/notifications/policies.py @@ -0,0 +1,41 @@ +"""Policies for the notifications app.""" + +from edx_ace.channel import ChannelType +from edx_ace.policy import Policy, PolicyResult +from opaque_keys.edx.keys import CourseKey + +from .models import CourseNotificationPreference + + +class CoursePushNotificationOptout(Policy): + """ + Course Push Notification optOut Policy. + """ + + def check(self, message): + """ + Check if the user has opted out of push notifications for the given course. + :param message: + :return: PolicyResult + """ + course_ids = message.context.get('course_ids', []) + app_label = message.context.get('app_label') + + if not (app_label or message.context.get('push_notification_extra_context', {})): + return PolicyResult(deny={ChannelType.PUSH}) + + course_keys = [CourseKey.from_string(course_id) for course_id in course_ids] + for course_key in course_keys: + course_notification_preference = CourseNotificationPreference.get_user_course_preference( + message.recipient.lms_user_id, + course_key + ) + push_notification_preference = course_notification_preference.get_notification_type_config( + app_label, + notification_type='push', + ).get('push', False) + + if not push_notification_preference: + return PolicyResult(deny={ChannelType.PUSH}) + + return PolicyResult(deny=frozenset()) diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 27b369d925af..b7bd0414a27f 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -315,8 +315,8 @@ def _expected_api_response(self, course=None): 'info': 'Notifications for submission grading.' }, 'ora_grade_assigned': { - 'web': False, - 'email': False, + 'web': True, + 'email': True, 'push': False, 'email_cadence': 'Daily', 'info': '' diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index ca693b4d109b..495389152f7a 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -36,9 +36,5 @@ # ] COURSE_GRADE_NOW_FAILED = Signal() -# Signal that indicates that a user has become verified for certificate purposes -# providing_args=['user'] -LEARNER_NOW_VERIFIED = Signal() - # providing_args=['user'] USER_ACCOUNT_ACTIVATED = Signal() # Signal indicating email verification diff --git a/requirements/constraints.txt b/requirements/constraints.txt index b149539147ff..fffc9ac163b7 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.12 +edx-enterprise==4.25.13 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ebf6d293554c..0b246816f559 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -401,7 +401,7 @@ drf-yasg==1.21.7 # via # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/kernel.in edx-api-doc-tools==1.8.0 # via @@ -467,7 +467,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.12 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -834,7 +834,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/bundled.in packaging==24.1 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 90e25ef858dc..e16f83b049f7 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -657,7 +657,7 @@ drf-yasg==1.21.7 # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.12 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1387,7 +1387,7 @@ optimizely-sdk==4.1.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==6.11.2 +ora2==6.12.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 b2daa6c3395c..2f916b40c535 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -481,7 +481,7 @@ drf-yasg==1.21.7 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/base.txt edx-api-doc-tools==1.8.0 # via @@ -547,7 +547,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.12 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -993,7 +993,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/base.txt packaging==24.1 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 15d04cce397c..93a0313a1123 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -505,7 +505,7 @@ drf-yasg==1.21.7 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/base.txt edx-api-doc-tools==1.8.0 # via @@ -571,7 +571,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.12 +edx-enterprise==4.25.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1044,7 +1044,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/base.txt packaging==24.1 # via diff --git a/setup.py b/setup.py index bf662b563c7f..28a25cc91476 100644 --- a/setup.py +++ b/setup.py @@ -129,7 +129,8 @@ 'discussions_link = openedx.core.djangoapps.discussions.transformers:DiscussionsTopicLinkTransformer', ], "openedx.ace.policy": [ - "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout" + "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout", + "course_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long ], "openedx.call_to_action": [ "personalized_learner_schedules = openedx.features.personalized_learner_schedules.call_to_action:PersonalizedLearnerScheduleCallToAction" # lint-amnesty, pylint: disable=line-too-long