feat: [FC-0047] add settings for edx-ace push notifications

feat: [FC-0047] Add push notifications for user enroll

feat: [FC-0047] Add push notifications for user unenroll

feat: [FC-0047] Add push notifications for add course beta testers

feat: [FC-0047] Add push notifications for remove course beta testers

feat: [FC-0047] Add push notification event to discussions
This commit is contained in:
Ivan Niedielnitsev
2024-04-29 15:42:04 +02:00
committed by Іван Нєдєльніцев
parent f8bf592483
commit 28eb406f8d
35 changed files with 372 additions and 13 deletions

View File

@@ -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,

View File

@@ -12,6 +12,7 @@ from django.conf import settings # lint-amnesty, pylint: disable=unused-import
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 @@ class ReportedContentNotification(BaseMessageType):
self.options['transactional'] = True
class CommentNotification(BaseMessageType):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.options['transactional'] = True
@shared_task(base=LoggedTask)
@set_code_owner_attribute
def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring
@@ -82,17 +89,40 @@ 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)
@set_code_owner_attribute
@@ -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'])
})

View File

@@ -0,0 +1,3 @@
{% load i18n %}
{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %}
{{ comment_body_text }}

View File

@@ -0,0 +1,3 @@
{% load i18n %}
{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %}

View File

@@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}{% endblocktrans %}

View File

@@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %}

View File

@@ -19,7 +19,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
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 +222,8 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
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 +233,12 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
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 +275,10 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
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 +289,15 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
'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 +340,9 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin
'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 should_email_send is False
assert not self.mock_ace_send.called
def test_subcomment_should_not_send_email(self):

View File

@@ -142,6 +142,14 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal
"""
previous_state = EmailEnrollmentState(course_id, student_email)
enrollment_obj = None
if email_params:
email_params.update({
'app_label': 'instructor',
'push_notification_extra_context': {
'notification_type': 'enroll',
'course_id': str(course_id),
},
})
if previous_state.user and previous_state.user.is_active:
# if the student is currently unenrolled, don't enroll them in their
# previous mode
@@ -195,6 +203,13 @@ def unenroll_email(course_id, student_email, email_students=False, email_params=
representing state before and after the action.
"""
previous_state = EmailEnrollmentState(course_id, student_email)
if email_params:
email_params.update({
'app_label': 'instructor',
'push_notification_extra_context': {
'notification_type': 'unenroll',
},
})
if previous_state.enrollment:
CourseEnrollment.unenroll_by_email(student_email, course_id)
if email_students:
@@ -233,6 +248,11 @@ def send_beta_role_email(action, user, email_params):
email_params['email_address'] = user.email
email_params['user_id'] = user.id
email_params['full_name'] = user.profile.name
email_params['app_label'] = 'instructor'
email_params['push_notification_extra_context'] = {
'notification_type': email_params['message_type'],
'course_id': str(getattr(email_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'

View File

@@ -0,0 +1,10 @@
from django.urls import path
from .views import GCMDeviceViewSet
CREATE_GCM_DEVICE = GCMDeviceViewSet.as_view({'post': 'create'})
urlpatterns = [
path('create-token/', CREATE_GCM_DEVICE, name='gcmdevice-list'),
]

View File

@@ -0,0 +1,50 @@
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.
**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)

View File

@@ -10,5 +10,6 @@ from .users.views import my_user_info
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')),
]

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1,5 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Dear student,{% endblocktrans %}
{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }}. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %}
{% endautoescape %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}You have been invited to register for {{ course_name }}.{% endblocktrans %}
{% endautoescape %}

View File

@@ -0,0 +1,5 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Dear Student,{% endblocktrans %}
{% blocktrans %}You have been unenrolled from the course {{ course_name }}. Please disregard the invitation previously sent.{% endblocktrans %}
{% endautoescape %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %}
{% endautoescape %}

View File

@@ -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 %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %}
{% endautoescape %}

View File

@@ -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 %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}You have been enrolled in {{ course_name }}{% endblocktrans %}
{% endautoescape %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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('bulk_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,
}

View File

@@ -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('bulk_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,
}

View File

@@ -0,0 +1,25 @@
"""
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.
"""
try:
import firebase_admin # pylint: disable=import-outside-toplevel
except ImportError:
log.error('Could not import firebase_admin package.')
return
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

View File

@@ -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 @@ class Comment(models.Model):
)
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"

View File

@@ -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:
"""
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())

View File

@@ -4,6 +4,8 @@
#
# make upgrade
#
-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications
# via -r requirements/edx/github.in
-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
# via -r requirements/edx/github.in
acid-xblock==0.3.1

View File

@@ -4,6 +4,10 @@
#
# make upgrade
#
-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
# via
# -r requirements/edx/doc.txt

View File

@@ -4,6 +4,8 @@
#
# make upgrade
#
-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications
# via -r requirements/edx/base.txt
-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.5

View File

@@ -90,3 +90,5 @@
# 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
-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications

View File

@@ -4,6 +4,8 @@
#
# make upgrade
#
-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications
# via -r requirements/edx/base.txt
-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
# via -r requirements/edx/base.txt
acid-xblock==0.3.1

View File

@@ -129,7 +129,8 @@ setup(
'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",
"bulk_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