Merge branch 'master' into remove-extra-recommender-xblock-requirement
This commit is contained in:
@@ -21,6 +21,8 @@ from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INVALID_GRANT_ERROR = "invalid_grant"
|
||||
|
||||
|
||||
class AccessTokenExpiredException(Exception):
|
||||
"""
|
||||
@@ -28,6 +30,12 @@ class AccessTokenExpiredException(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class BadRequestException(Exception):
|
||||
"""
|
||||
Raised when access token has been expired.
|
||||
"""
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to generate transfer identifiers for apple users using their apple_id
|
||||
@@ -115,7 +123,11 @@ class Command(BaseCommand):
|
||||
}
|
||||
response = requests.post(migration_url, data=payload, headers=headers)
|
||||
if response.status_code == 400:
|
||||
raise AccessTokenExpiredException
|
||||
error = response.json().get('error')
|
||||
log.info("Error while fetching transfer_id for uid %s. Error: %s", apple_id, error)
|
||||
if error == INVALID_GRANT_ERROR:
|
||||
raise AccessTokenExpiredException
|
||||
raise BadRequestException
|
||||
|
||||
return response.json().get('transfer_sub')
|
||||
|
||||
@@ -123,6 +135,9 @@ class Command(BaseCommand):
|
||||
"""
|
||||
Given an Apple ID from the old transferring team,
|
||||
create and return its respective transfer id.
|
||||
Update access token if expired.
|
||||
Skip the current apple_id in case of a malformed
|
||||
request error and log info.
|
||||
"""
|
||||
try:
|
||||
transfer_id = self._fetch_transfer_id(apple_id, target_team_id)
|
||||
@@ -130,12 +145,15 @@ class Command(BaseCommand):
|
||||
log.info('Access token expired. Re-creating access token.')
|
||||
self._update_token_and_secret()
|
||||
transfer_id = self._fetch_transfer_id(apple_id, target_team_id)
|
||||
except BadRequestException:
|
||||
log.info('Bad request for uid %s.', apple_id)
|
||||
transfer_id = ''
|
||||
|
||||
return transfer_id
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('target_team_id', help='Team ID to which the app is to be migrated to.')
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
target_team_id = options['target_team_id']
|
||||
|
||||
@@ -148,11 +166,13 @@ class Command(BaseCommand):
|
||||
apple_ids = UserSocialAuth.objects.filter(provider=AppleIdAuth.name).exclude(
|
||||
uid__in=already_processed_apple_ids).values_list('uid', flat=True)
|
||||
for apple_id in apple_ids:
|
||||
log.info("Begin processing uid %s", apple_id)
|
||||
transfer_id = self._get_transfer_id_for_apple_id(apple_id, target_team_id)
|
||||
if transfer_id:
|
||||
apple_user_id_info, _ = AppleMigrationUserIdInfo.objects.get_or_create(old_apple_id=apple_id)
|
||||
apple_user_id_info.transfer_id = transfer_id
|
||||
apple_user_id_info.save()
|
||||
log.info('Updated transfer_id for uid %s', apple_id)
|
||||
with transaction.atomic():
|
||||
apple_user_id_info, _ = AppleMigrationUserIdInfo.objects.get_or_create(old_apple_id=apple_id)
|
||||
apple_user_id_info.transfer_id = transfer_id
|
||||
apple_user_id_info.save()
|
||||
log.info('Updated transfer_id for uid %s', apple_id)
|
||||
else:
|
||||
log.info('Unable to fetch transfer_id for uid %s', apple_id)
|
||||
log.info('Unable to fetch transfer_id for uid %s. Skipping.', apple_id)
|
||||
|
||||
@@ -19,6 +19,8 @@ from common.djangoapps.third_party_auth.appleid import AppleIdAuth
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INVALID_GRANT_ERROR = "invalid_grant"
|
||||
|
||||
|
||||
class AccessTokenExpiredException(Exception):
|
||||
"""
|
||||
@@ -26,13 +28,19 @@ class AccessTokenExpiredException(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class BadRequestException(Exception):
|
||||
"""
|
||||
Raised when access token has been expired.
|
||||
"""
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to exchange transfer identifiers for new team-scoped identifier for
|
||||
the user in new migrated team.
|
||||
|
||||
Usage:
|
||||
manage.py generate_and_store_apple_transfer_ids
|
||||
manage.py generate_and_store_new_apple_ids
|
||||
"""
|
||||
|
||||
def _generate_client_secret(self):
|
||||
@@ -112,7 +120,11 @@ class Command(BaseCommand):
|
||||
}
|
||||
response = requests.post(migration_url, data=payload, headers=headers)
|
||||
if response.status_code == 400:
|
||||
raise AccessTokenExpiredException
|
||||
error = response.json().get('error')
|
||||
log.info("Error while fetching apple_id for transfer_id %s. Error: %s", transfer_id, error)
|
||||
if error == INVALID_GRANT_ERROR:
|
||||
raise AccessTokenExpiredException
|
||||
raise BadRequestException
|
||||
|
||||
return response.json().get('sub')
|
||||
|
||||
@@ -120,6 +132,9 @@ class Command(BaseCommand):
|
||||
"""
|
||||
For a Transfer ID obtained from the transferring team,
|
||||
return the correlating Apple ID belonging to the recipient team.
|
||||
Update access token if expired.
|
||||
Skip the current transfer_id in case of a malformed request
|
||||
error and log info.
|
||||
"""
|
||||
try:
|
||||
new_apple_id = self._fetch_new_apple_id(transfer_id)
|
||||
@@ -127,10 +142,12 @@ class Command(BaseCommand):
|
||||
log.info('Access token expired. Re-creating access token.')
|
||||
self._update_token_and_secret()
|
||||
new_apple_id = self._fetch_new_apple_id(transfer_id)
|
||||
except BadRequestException:
|
||||
log.info('Bad request for transfer_id %s.', transfer_id)
|
||||
new_apple_id = ''
|
||||
|
||||
return new_apple_id
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
self._update_token_and_secret()
|
||||
if not self.access_token:
|
||||
@@ -139,12 +156,14 @@ class Command(BaseCommand):
|
||||
apple_user_ids_info = AppleMigrationUserIdInfo.objects.filter(Q(new_apple_id__isnull=True) | Q(new_apple_id=""),
|
||||
~Q(transfer_id=""), transfer_id__isnull=False)
|
||||
for apple_user_id_info in apple_user_ids_info:
|
||||
new_apple_id = self._exchange_transfer_id_for_new_apple_id(apple_user_id_info.transfer_id)
|
||||
transfer_id = apple_user_id_info.transfer_id
|
||||
old_apple_id = apple_user_id_info.old_apple_id
|
||||
log.info("Begin processing old_apple_id %s, transfer_id %s", old_apple_id, transfer_id)
|
||||
new_apple_id = self._exchange_transfer_id_for_new_apple_id(transfer_id)
|
||||
if new_apple_id:
|
||||
apple_user_id_info.new_apple_id = new_apple_id
|
||||
apple_user_id_info.save()
|
||||
log.info('Updated new Apple ID for uid %s',
|
||||
apple_user_id_info.old_apple_id)
|
||||
with transaction.atomic():
|
||||
apple_user_id_info.new_apple_id = new_apple_id
|
||||
apple_user_id_info.save()
|
||||
log.info('Updated new Apple ID for uid %s', old_apple_id)
|
||||
else:
|
||||
log.info('Unable to fetch new Apple ID for uid %s',
|
||||
apple_user_id_info.old_apple_id)
|
||||
log.info('Unable to fetch new Apple ID for uid %s', old_apple_id)
|
||||
|
||||
@@ -23,7 +23,6 @@ class Command(BaseCommand):
|
||||
manage.py update_new_apple_ids_in_social_auth
|
||||
"""
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
apple_user_ids_info = AppleMigrationUserIdInfo.objects.filter(
|
||||
~Q(new_apple_id=''), new_apple_id__isnull=False
|
||||
@@ -35,10 +34,11 @@ class Command(BaseCommand):
|
||||
uid=apple_user_id_info.old_apple_id, provider=AppleIdAuth.name
|
||||
).first()
|
||||
if user_social_auth:
|
||||
user_social_auth.uid = apple_user_id_info.new_apple_id
|
||||
user_social_auth.save()
|
||||
log.info(
|
||||
'Replaced Apple ID %s with %s',
|
||||
apple_user_id_info.old_apple_id,
|
||||
apple_user_id_info.new_apple_id
|
||||
)
|
||||
with transaction.atomic():
|
||||
user_social_auth.uid = apple_user_id_info.new_apple_id
|
||||
user_social_auth.save()
|
||||
log.info(
|
||||
'Replaced Apple ID %s with %s',
|
||||
apple_user_id_info.old_apple_id,
|
||||
apple_user_id_info.new_apple_id
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -169,7 +169,7 @@ def listen_for_certificate_created_event(sender, signal, **kwargs):
|
||||
if SEND_CERTIFICATE_CREATED_SIGNAL.is_enabled():
|
||||
get_producer().send(
|
||||
signal=CERTIFICATE_CREATED,
|
||||
topic='certificates',
|
||||
topic='learning-certificate-lifecycle',
|
||||
event_key_field='certificate.course.course_key',
|
||||
event_data={'certificate': kwargs['certificate']},
|
||||
event_metadata=kwargs['metadata']
|
||||
|
||||
@@ -513,5 +513,5 @@ class CertificateEventBusTests(ModuleStoreTestCase):
|
||||
data = mock_producer.return_value.send.call_args.kwargs
|
||||
assert data['signal'].event_type == CERTIFICATE_CREATED.event_type
|
||||
assert data['event_data']['certificate'] == expected_certificate_data
|
||||
assert data['topic'] == 'certificates'
|
||||
assert data['topic'] == 'learning-certificate-lifecycle'
|
||||
assert data['event_key_field'] == 'certificate.course.course_key'
|
||||
|
||||
@@ -654,13 +654,13 @@ class PersistentCourseGrade(TimeStampedModel):
|
||||
course_id=course_id,
|
||||
user_id=user_id
|
||||
)
|
||||
grade.passed_timestamp = now()
|
||||
grade.save()
|
||||
COURSE_GRADE_PASSED_UPDATE_IN_LEARNER_PATHWAY.send(
|
||||
sender=None,
|
||||
user_id=user_id,
|
||||
course_id=course_id,
|
||||
)
|
||||
grade.passed_timestamp = now()
|
||||
grade.save()
|
||||
|
||||
cls._emit_grade_calculated_event(grade)
|
||||
cls._update_cache(course_id, user_id, grade)
|
||||
|
||||
@@ -305,7 +305,8 @@ class StaffAssessSerializer(serializers.Serializer):
|
||||
def get_options_selected(self, instance):
|
||||
options_selected = {}
|
||||
for criterion in instance.get("criteria"):
|
||||
options_selected[criterion["name"]] = criterion["selectedOption"]
|
||||
if criterion["selectedOption"]:
|
||||
options_selected[criterion["name"]] = criterion["selectedOption"]
|
||||
|
||||
return options_selected
|
||||
|
||||
|
||||
@@ -718,6 +718,13 @@ class TestStaffAssessSerializer(TestCase):
|
||||
],
|
||||
}
|
||||
|
||||
grade_data_no_selected_option = {
|
||||
"overallFeedback": "was pretty good",
|
||||
"criteria": [
|
||||
{"name": "firstCriterion", "selectedOption": None},
|
||||
],
|
||||
}
|
||||
|
||||
submission_uuid = "foo"
|
||||
|
||||
def test_staff_assess_serializer(self):
|
||||
@@ -757,3 +764,18 @@ class TestStaffAssessSerializer(TestCase):
|
||||
}
|
||||
|
||||
assert serializer.data == expected_value
|
||||
|
||||
def test_staff_assess_no_selected_option(self):
|
||||
"""When selected option is None, it should be ignored"""
|
||||
context = {"submission_uuid": self.submission_uuid}
|
||||
serializer = StaffAssessSerializer(self.grade_data_no_selected_option, context=context)
|
||||
|
||||
expected_value = {
|
||||
"options_selected": {},
|
||||
"criterion_feedback": {},
|
||||
"overall_feedback": "was pretty good",
|
||||
"submission_uuid": self.submission_uuid,
|
||||
"assess_type": "full-grade",
|
||||
}
|
||||
|
||||
assert serializer.data == expected_value
|
||||
|
||||
@@ -54,33 +54,70 @@ from openedx.core.djangolib.js_utils import (
|
||||
% if download_video_link or public_sharing_enabled:
|
||||
<div class="wrapper-download-video">
|
||||
<h4 class="hd hd-5">${_('Video')}</h4>
|
||||
% if download_video_link:
|
||||
<span class="icon fa fa-download" aria-hidden="true"></span>
|
||||
<a class="btn-link video-sources video-download-button" href="${download_video_link}">
|
||||
${_('Download video file')}
|
||||
</a>
|
||||
% endif
|
||||
% if download_video_link and public_sharing_enabled:
|
||||
<br>
|
||||
% endif
|
||||
% if sharing_sites_info:
|
||||
<div class="wrapper-social-share">
|
||||
<span class="icon fa fa-share-alt" aria-hidden="true"></span>
|
||||
${_('Share on:')}
|
||||
% for sharing_site_info in sharing_sites_info:
|
||||
<button
|
||||
style="background-image: none; background-color: rgb(0, 38, 43); border-radius: 0px; color: white"
|
||||
class="social-toggle-btn btn"
|
||||
>
|
||||
<span class="icon fa fa-share-alt mr-2" style="text-shadow: none"></span>
|
||||
${_('Share this video')}
|
||||
</button>
|
||||
<div
|
||||
hidden
|
||||
class="container-social-share color-black p-2"
|
||||
style="width: 300px; border-radius: 6px; background-color: white; box-shadow: 0 .5rem 1rem rgba(0,0,0,.15),0 .25rem .625rem rgba(0,0,0,.15)"
|
||||
>
|
||||
${_('Share this video')}
|
||||
<div class="btn-link close-btn float-right">
|
||||
<span style="color: black" class="icon fa fa-close" />
|
||||
</div>
|
||||
|
||||
<br />
|
||||
% for sharing_site_info in sharing_sites_info:
|
||||
<a
|
||||
class="btn-link social-share-link"
|
||||
data-source="${sharing_site_info['name']}"
|
||||
href="${sharing_site_info['sharing_url']}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="font-size: 1.5rem"
|
||||
>
|
||||
<span class="icon fa ${sharing_site_info['fa_icon_name']}" aria-hidden="true"></span>
|
||||
<span class="sr">${_("Share on {site}").format(site=sharing_site_info['name'])}</span>
|
||||
</a>
|
||||
% endfor
|
||||
% endfor
|
||||
<br />
|
||||
<div style="background-color: #F2F0EF" class="public-video-url-container p-2">
|
||||
<a href=${public_video_url} class="d-inline-block align-middle" style="width: 200px">
|
||||
<div
|
||||
class="text-nowrap"
|
||||
style="color: black; overflow: hidden; text-overflow: ellipsis; vertical-align: middle"
|
||||
>
|
||||
${public_video_url}
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
class="public-video-copy-btn btn-link d-inline-block float-right"
|
||||
data-url=${public_video_url}
|
||||
>
|
||||
<span class="icon fa fa-link pr-1"></span>
|
||||
<span>${_('Copy')}</span>
|
||||
</div>
|
||||
<span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
% if download_video_link and public_sharing_enabled:
|
||||
<br>
|
||||
% endif
|
||||
% if download_video_link:
|
||||
<span class="icon fa fa-download" aria-hidden="true"></span>
|
||||
<a class="btn-link video-sources video-download-button" href="${download_video_link}">
|
||||
${_('Download video file')}
|
||||
</a>
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
% if track:
|
||||
@@ -116,6 +153,7 @@ from openedx.core.djangolib.js_utils import (
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
% if cdn_eval:
|
||||
<script>
|
||||
//TODO: refactor this js into a separate file.
|
||||
|
||||
@@ -4,15 +4,24 @@ Django Admin for Notifications
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Notification, NotificationPreference
|
||||
from .models import Notification, CourseNotificationPreference
|
||||
|
||||
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class NotificationPreferenceAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
class CourseNotificationPreferenceAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin for Course Notification Preferences
|
||||
"""
|
||||
model = CourseNotificationPreference
|
||||
list_display = ['get_username', 'course_id', 'notification_preference_config']
|
||||
|
||||
@admin.display(description='Username', ordering='user__username')
|
||||
def get_username(self, obj):
|
||||
return obj.user.username
|
||||
|
||||
|
||||
admin.site.register(Notification, NotificationAdmin)
|
||||
admin.site.register(NotificationPreference, NotificationPreferenceAdmin)
|
||||
admin.site.register(CourseNotificationPreference, CourseNotificationPreferenceAdmin)
|
||||
|
||||
180
openedx/core/djangoapps/notifications/base_notification.py
Normal file
180
openedx/core/djangoapps/notifications/base_notification.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Base setup for Notification Apps and Types.
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
COURSE_NOTIFICATION_TYPES = {
|
||||
'new_comment_on_response': {
|
||||
'notification_app': 'discussion',
|
||||
'name': 'new_comment_on_response',
|
||||
'is_core': True,
|
||||
'info': 'Comment on response',
|
||||
'content_template': '<p><strong>{replier_name}</strong> replied on your response in '
|
||||
'<strong>{post_title}</strong></p>',
|
||||
'content_context': {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
},
|
||||
'email_template': '',
|
||||
},
|
||||
'new_comment': {
|
||||
'notification_app': 'discussion',
|
||||
'name': 'new_comment',
|
||||
'is_core': False,
|
||||
'web': True,
|
||||
'email': True,
|
||||
'push': True,
|
||||
'info': 'Comment on post',
|
||||
'non-editable': ['web', 'email'],
|
||||
'content_template': '<p><strong>{replier_name}</strong> replied on <strong>{author_name}</strong> response '
|
||||
'to your post <strong>{post_title}</strong></p>',
|
||||
'content_context': {
|
||||
'post_title': 'Post title',
|
||||
'author_name': 'author name',
|
||||
'replier_name': 'replier name',
|
||||
},
|
||||
'email_template': '',
|
||||
},
|
||||
'new_response': {
|
||||
'notification_app': 'discussion',
|
||||
'name': 'new_response',
|
||||
'is_core': False,
|
||||
'web': True,
|
||||
'email': True,
|
||||
'push': True,
|
||||
'info': 'Response on post',
|
||||
'non-editable': [],
|
||||
'content_template': '<p><strong>{replier_name}</strong> responded to your '
|
||||
'post <strong>{post_title}</strong></p>',
|
||||
'content_context': {
|
||||
'post_title': 'Post title',
|
||||
'replier_name': 'replier name',
|
||||
},
|
||||
'email_template': '',
|
||||
},
|
||||
}
|
||||
|
||||
COURSE_NOTIFICATION_APPS = {
|
||||
'discussion': {
|
||||
'enabled': True,
|
||||
'core_info': '',
|
||||
'core_web': True,
|
||||
'core_email': True,
|
||||
'core_push': True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NotificationTypeManager:
|
||||
"""
|
||||
Manager for notification types
|
||||
"""
|
||||
notification_types: Dict = {}
|
||||
|
||||
def __init__(self):
|
||||
self.notification_types = COURSE_NOTIFICATION_TYPES
|
||||
|
||||
def get_notification_types_by_app(self, notification_app):
|
||||
"""
|
||||
Returns notification types for the given notification app.
|
||||
"""
|
||||
return [
|
||||
notification_type for _, notification_type in self.notification_types.items()
|
||||
if notification_type.get('notification_app', None) == notification_app
|
||||
]
|
||||
|
||||
def get_core_and_non_core_notification_types(self, notification_app):
|
||||
"""
|
||||
Returns core notification types for the given app name.
|
||||
"""
|
||||
notification_types = self.get_notification_types_by_app(notification_app)
|
||||
core_notification_types = []
|
||||
non_core_notification_types = []
|
||||
for notification_type in notification_types:
|
||||
if notification_type.get('is_core', None):
|
||||
core_notification_types.append(notification_type)
|
||||
else:
|
||||
non_core_notification_types.append(notification_type)
|
||||
return core_notification_types, non_core_notification_types
|
||||
|
||||
@staticmethod
|
||||
def get_non_editable_notification_channels(notification_types):
|
||||
"""
|
||||
Returns non-editable notification channels for the given notification types.
|
||||
"""
|
||||
non_editable_notification_channels = {}
|
||||
for notification_type in notification_types:
|
||||
if notification_type.get('non-editable', None):
|
||||
non_editable_notification_channels[notification_type.get('name')] = \
|
||||
notification_type.get('non-editable')
|
||||
return non_editable_notification_channels
|
||||
|
||||
@staticmethod
|
||||
def get_non_core_notification_type_preferences(non_core_notification_types):
|
||||
"""
|
||||
Returns non-core notification type preferences for the given notification types.
|
||||
"""
|
||||
non_core_notification_type_preferences = {}
|
||||
for notification_type in non_core_notification_types:
|
||||
non_core_notification_type_preferences[notification_type.get('name')] = {
|
||||
'web': notification_type.get('web', False),
|
||||
'email': notification_type.get('email', False),
|
||||
'push': notification_type.get('push', False),
|
||||
'info': notification_type.get('info', ''),
|
||||
}
|
||||
return non_core_notification_type_preferences
|
||||
|
||||
def get_notification_app_preference(self, notification_app):
|
||||
"""
|
||||
Returns notification app preferences for the given notification app.
|
||||
"""
|
||||
core_notification_types, non_core_notification_types = self.get_core_and_non_core_notification_types(
|
||||
notification_app,
|
||||
)
|
||||
non_core_notification_types_preferences = self.get_non_core_notification_type_preferences(
|
||||
non_core_notification_types,
|
||||
)
|
||||
non_editable_notification_channels = self.get_non_editable_notification_channels(non_core_notification_types)
|
||||
core_notification_types_name = [notification_type.get('name') for notification_type in core_notification_types]
|
||||
return non_core_notification_types_preferences, core_notification_types_name, non_editable_notification_channels
|
||||
|
||||
|
||||
class NotificationAppManager:
|
||||
"""
|
||||
Notification app manager
|
||||
"""
|
||||
notification_apps: Dict = {}
|
||||
|
||||
def __init__(self):
|
||||
self.notification_apps = COURSE_NOTIFICATION_APPS
|
||||
|
||||
def add_core_notification_preference(self, notification_app_attrs, notification_types):
|
||||
"""
|
||||
Adds core notification preference for the given notification app.
|
||||
"""
|
||||
notification_types['core'] = {
|
||||
'web': notification_app_attrs.get('core_web', False),
|
||||
'email': notification_app_attrs.get('core_email', False),
|
||||
'push': notification_app_attrs.get('core_push', False),
|
||||
'info': notification_app_attrs.get('core_info', ''),
|
||||
}
|
||||
|
||||
def get_notification_app_preferences(self):
|
||||
"""
|
||||
Returns notification app preferences for the given name.
|
||||
"""
|
||||
course_notification_preference_config = {}
|
||||
for notification_app_key, notification_app_attrs in COURSE_NOTIFICATION_APPS.items():
|
||||
notification_app_preferences = {}
|
||||
notification_types, core_notifications, \
|
||||
non_editable_channels = NotificationTypeManager().get_notification_app_preference(notification_app_key)
|
||||
self.add_core_notification_preference(notification_app_attrs, notification_types)
|
||||
|
||||
notification_app_preferences['enabled'] = notification_app_attrs.get('enabled', False)
|
||||
notification_app_preferences['core_notification_types'] = core_notifications
|
||||
notification_app_preferences['notification_types'] = notification_types
|
||||
notification_app_preferences['non_editable'] = non_editable_channels
|
||||
course_notification_preference_config[notification_app_key] = notification_app_preferences
|
||||
|
||||
return course_notification_preference_config
|
||||
return None
|
||||
@@ -8,7 +8,7 @@ from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
|
||||
from openedx.core.djangoapps.notifications.models import NotificationPreference
|
||||
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,10 +17,11 @@ log = logging.getLogger(__name__)
|
||||
def course_enrollment_post_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Watches for post_save signal for creates on the CourseEnrollment table.
|
||||
Generate a NotificationPreference if new Enrollment is created
|
||||
Generate a CourseNotificationPreference if new Enrollment is created
|
||||
"""
|
||||
if created and ENABLE_NOTIFICATIONS.is_enabled(instance.course_id):
|
||||
try:
|
||||
NotificationPreference.objects.create(user=instance.user, course_id=instance.course_id)
|
||||
CourseNotificationPreference.objects.create(user=instance.user, course_id=instance.course_id)
|
||||
except IntegrityError:
|
||||
log.info(f'NotificationPreference already exists for user {instance.user} and course {instance.course_id}')
|
||||
log.info(f'CourseNotificationPreference already exists for user {instance.user} '
|
||||
f'and course {instance.course_id}')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 3.2.19 on 2023-06-07 07:57
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import opaque_keys.edx.django.models
|
||||
import openedx.core.djangoapps.notifications.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('notifications', '0003_alter_notification_app_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CourseNotificationPreference',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('course_id', opaque_keys.edx.django.models.CourseKeyField(max_length=255)),
|
||||
('notification_preference_config', models.JSONField(default=openedx.core.djangoapps.notifications.models.get_course_notification_preference_config)),
|
||||
('config_version', models.IntegerField(default=openedx.core.djangoapps.notifications.models.get_course_notification_preference_config_version)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'course_id')},
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='notification',
|
||||
name='content',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='course_id',
|
||||
field=opaque_keys.edx.django.models.CourseKeyField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notification',
|
||||
name='app_name',
|
||||
field=models.CharField(db_index=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notification',
|
||||
name='content_context',
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='notification',
|
||||
name='notification_type',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='NotificationPreference',
|
||||
),
|
||||
]
|
||||
@@ -1,44 +1,79 @@
|
||||
"""
|
||||
Models for notifications
|
||||
"""
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from model_utils.models import TimeStampedModel
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
|
||||
# When notification preferences are updated, we need to update the CONFIG_VERSION.
|
||||
NOTIFICATION_PREFERENCE_CONFIG = {
|
||||
"discussion": {
|
||||
"new_post": {
|
||||
"web": False,
|
||||
"push": False,
|
||||
"email": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
# Update this version when NOTIFICATION_PREFERENCE_CONFIG is updated.
|
||||
CONFIG_VERSION = 1
|
||||
from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
NOTIFICATION_CHANNELS = ['web', 'push', 'email']
|
||||
|
||||
# Update this version when there is a change to any course specific notification type or app.
|
||||
COURSE_NOTIFICATION_CONFIG_VERSION = 1
|
||||
|
||||
|
||||
class NotificationApplication(models.TextChoices):
|
||||
def get_course_notification_preference_config():
|
||||
"""
|
||||
Application choices where notifications are generated from
|
||||
Returns the course specific notification preference config.
|
||||
|
||||
Sample Response:
|
||||
{
|
||||
'discussion': {
|
||||
'enabled': True,
|
||||
'not_editable': {
|
||||
'new_comment_on_post': ['push'],
|
||||
'new_response_on_post': ['web'],
|
||||
'new_response_on_comment': ['web', 'push']
|
||||
},
|
||||
'notification_types': {
|
||||
'new_comment_on_post': {
|
||||
'email': True,
|
||||
'push': True,
|
||||
'web': True,
|
||||
'info': 'Comment on post'
|
||||
},
|
||||
'new_response_on_comment': {
|
||||
'email': True,
|
||||
'push': True,
|
||||
'web': True,
|
||||
'info': 'Response on comment'
|
||||
},
|
||||
'new_response_on_post': {
|
||||
'email': True,
|
||||
'push': True,
|
||||
'web': True,
|
||||
'info': 'New Response on Post'
|
||||
},
|
||||
'core': {
|
||||
'email': True,
|
||||
'push': True,
|
||||
'web': True,
|
||||
'info': 'comment on post and response on comment'
|
||||
}
|
||||
},
|
||||
'core_notification_types': []
|
||||
}
|
||||
}
|
||||
"""
|
||||
DISCUSSION = 'DISCUSSION'
|
||||
return NotificationAppManager().get_notification_app_preferences()
|
||||
|
||||
|
||||
class NotificationType(models.TextChoices):
|
||||
def get_course_notification_preference_config_version():
|
||||
"""
|
||||
Notification type choices
|
||||
Returns the notification preference config version.
|
||||
"""
|
||||
NEW_CONTRIBUTION = 'NEW_CONTRIBUTION'
|
||||
return COURSE_NOTIFICATION_CONFIG_VERSION
|
||||
|
||||
|
||||
class NotificationTypeContent:
|
||||
def get_notification_channels():
|
||||
"""
|
||||
Notification type content
|
||||
Returns the notification channels.
|
||||
"""
|
||||
NEW_CONTRIBUTION_NOTIFICATION_CONTENT = 'There is a new contribution. {new_contribution}'
|
||||
return NOTIFICATION_CHANNELS
|
||||
|
||||
|
||||
class Notification(TimeStampedModel):
|
||||
@@ -48,63 +83,33 @@ class Notification(TimeStampedModel):
|
||||
.. no_pii:
|
||||
"""
|
||||
user = models.ForeignKey(User, related_name="notifications", on_delete=models.CASCADE)
|
||||
app_name = models.CharField(max_length=64, choices=NotificationApplication.choices, db_index=True)
|
||||
notification_type = models.CharField(max_length=64, choices=NotificationType.choices)
|
||||
content = models.CharField(max_length=1024)
|
||||
content_context = models.JSONField(default={})
|
||||
course_id = CourseKeyField(max_length=255, null=True, blank=True)
|
||||
app_name = models.CharField(max_length=64, db_index=True)
|
||||
notification_type = models.CharField(max_length=64)
|
||||
content_context = models.JSONField(default=dict)
|
||||
content_url = models.URLField(null=True, blank=True)
|
||||
last_read = models.DateTimeField(null=True, blank=True)
|
||||
last_seen = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} - {self.app_name} - {self.notification_type} - {self.content}'
|
||||
|
||||
def get_content(self):
|
||||
return self.content
|
||||
|
||||
def get_content_url(self):
|
||||
return self.content_url
|
||||
|
||||
def get_notification_type(self):
|
||||
return self.notification_type
|
||||
|
||||
def get_app_name(self):
|
||||
return self.app_name
|
||||
|
||||
def get_content_context(self):
|
||||
return self.content_context
|
||||
|
||||
def get_user(self):
|
||||
return self.user
|
||||
return f'{self.user.username} - {self.course_id} - {self.app_name} - {self.notification_type}'
|
||||
|
||||
|
||||
class NotificationPreference(TimeStampedModel):
|
||||
class CourseNotificationPreference(TimeStampedModel):
|
||||
"""
|
||||
Model to store notification preferences for users
|
||||
|
||||
.. no_pii:
|
||||
"""
|
||||
user = models.ForeignKey(User, related_name="notification_preferences", on_delete=models.CASCADE)
|
||||
course_id = CourseKeyField(max_length=255, blank=True, default=None)
|
||||
notification_preference_config = models.JSONField(default=NOTIFICATION_PREFERENCE_CONFIG)
|
||||
course_id = CourseKeyField(max_length=255, null=False, blank=False)
|
||||
notification_preference_config = models.JSONField(default=get_course_notification_preference_config)
|
||||
# This version indicates the current version of this notification preference.
|
||||
config_version = models.IntegerField(blank=True, default=1)
|
||||
config_version = models.IntegerField(default=get_course_notification_preference_config_version)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'course_id')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} - {self.course_id} - {self.notification_preference_config}'
|
||||
|
||||
def get_user(self):
|
||||
return self.user
|
||||
|
||||
def get_course_id(self):
|
||||
return self.course_id
|
||||
|
||||
def get_notification_preference_config(self):
|
||||
return self.notification_preference_config
|
||||
|
||||
def get_config_version(self):
|
||||
return self.config_version
|
||||
|
||||
def get_is_active(self):
|
||||
return self.is_active
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""
|
||||
Serializers for the notifications API.
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.notifications.models import Notification, NotificationPreference
|
||||
from openedx.core.djangoapps.notifications.models import (
|
||||
get_notification_channels,
|
||||
Notification,
|
||||
CourseNotificationPreference,
|
||||
)
|
||||
|
||||
|
||||
class CourseOverviewSerializer(serializers.ModelSerializer):
|
||||
@@ -29,14 +34,14 @@ class NotificationCourseEnrollmentSerializer(serializers.ModelSerializer):
|
||||
fields = ('course',)
|
||||
|
||||
|
||||
class UserNotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
class UserCourseNotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for user notification preferences.
|
||||
"""
|
||||
course_name = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = NotificationPreference
|
||||
model = CourseNotificationPreference
|
||||
fields = ('id', 'course_name', 'course_id', 'notification_preference_config',)
|
||||
read_only_fields = ('id', 'course_name', 'course_id',)
|
||||
write_only_fields = ('notification_preference_config',)
|
||||
@@ -47,9 +52,75 @@ class UserNotificationPreferenceSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
return CourseOverview.get_from_id(obj.course_id).display_name
|
||||
|
||||
|
||||
class UserNotificationPreferenceUpdateSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for user notification preferences update.
|
||||
"""
|
||||
|
||||
notification_app = serializers.CharField()
|
||||
value = serializers.BooleanField()
|
||||
notification_type = serializers.CharField(required=False)
|
||||
notification_channel = serializers.CharField(required=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""
|
||||
Validation for notification preference update form
|
||||
"""
|
||||
notification_app = attrs.get('notification_app')
|
||||
notification_type = attrs.get('notification_type')
|
||||
notification_channel = attrs.get('notification_channel')
|
||||
|
||||
notification_app_config = self.instance.notification_preference_config
|
||||
|
||||
if notification_type and not notification_channel:
|
||||
raise ValidationError(
|
||||
'notification_channel is required for notification_type.'
|
||||
)
|
||||
if notification_channel and not notification_type:
|
||||
raise ValidationError(
|
||||
'notification_type is required for notification_channel.'
|
||||
)
|
||||
|
||||
if not notification_app_config.get(notification_app, None):
|
||||
raise ValidationError(
|
||||
f'{notification_app} is not a valid notification app.'
|
||||
)
|
||||
|
||||
if notification_type:
|
||||
notification_types = notification_app_config.get(notification_app).get('notification_types')
|
||||
|
||||
if not notification_types.get(notification_type, None):
|
||||
raise ValidationError(
|
||||
f'{notification_type} is not a valid notification type.'
|
||||
)
|
||||
|
||||
if notification_channel and notification_channel not in get_notification_channels():
|
||||
raise ValidationError(
|
||||
f'{notification_channel} is not a valid notification channel.'
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
for key, val in validated_data.items():
|
||||
setattr(instance, key, val)
|
||||
"""
|
||||
Update notification preference config.
|
||||
"""
|
||||
notification_app = validated_data.get('notification_app')
|
||||
notification_type = validated_data.get('notification_type')
|
||||
notification_channel = validated_data.get('notification_channel')
|
||||
value = validated_data.get('value')
|
||||
user_notification_preference_config = instance.notification_preference_config
|
||||
|
||||
if notification_type and notification_channel:
|
||||
# Update the notification preference for specific notification type
|
||||
user_notification_preference_config[
|
||||
notification_app]['notification_types'][notification_type][notification_channel] = value
|
||||
|
||||
else:
|
||||
# Update the notification preference for notification_app
|
||||
user_notification_preference_config[notification_app]['enabled'] = value
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
@@ -65,7 +136,6 @@ class NotificationSerializer(serializers.ModelSerializer):
|
||||
'id',
|
||||
'app_name',
|
||||
'notification_type',
|
||||
'content',
|
||||
'content_context',
|
||||
'content_url',
|
||||
'last_read',
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests for the views in the notifications app.
|
||||
"""
|
||||
import json
|
||||
|
||||
import ddt
|
||||
from django.dispatch import Signal
|
||||
from django.urls import reverse
|
||||
from edx_toggles.toggles.testutils import override_waffle_flag
|
||||
@@ -13,7 +14,10 @@ from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
|
||||
from openedx.core.djangoapps.notifications.models import Notification, NotificationPreference
|
||||
from openedx.core.djangoapps.notifications.models import (
|
||||
Notification,
|
||||
CourseNotificationPreference,
|
||||
)
|
||||
from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
@@ -31,7 +35,6 @@ class CourseEnrollmentListViewTest(ModuleStoreTestCase):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
# self.client.force_authenticate(user=self.user)
|
||||
course_1 = CourseFactory.create(
|
||||
org='testorg',
|
||||
number='testcourse',
|
||||
@@ -121,18 +124,20 @@ class CourseEnrollmentPostSaveTest(ModuleStoreTestCase):
|
||||
created=True
|
||||
)
|
||||
|
||||
# Assert that NotificationPreference object was created with correct attributes
|
||||
notification_preferences = NotificationPreference.objects.all()
|
||||
# Assert that CourseNotificationPreference object was created with correct attributes
|
||||
notification_preferences = CourseNotificationPreference.objects.all()
|
||||
|
||||
self.assertEqual(notification_preferences.count(), 1)
|
||||
self.assertEqual(notification_preferences[0].user, self.user)
|
||||
|
||||
|
||||
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
|
||||
@ddt.ddt
|
||||
class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
|
||||
"""
|
||||
Test for user notification preference API.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
@@ -158,27 +163,44 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
|
||||
created=True
|
||||
)
|
||||
|
||||
def _expected_api_response(self, overrides=None):
|
||||
def _expected_api_response(self):
|
||||
"""
|
||||
Helper method to return expected API response.
|
||||
"""
|
||||
expected_response = {
|
||||
return {
|
||||
'id': 1,
|
||||
'course_name': 'course-v1:testorg+testcourse+testrun Course',
|
||||
'course_id': 'course-v1:testorg+testcourse+testrun',
|
||||
'notification_preference_config': {
|
||||
'discussion': {
|
||||
'new_post': {
|
||||
'web': False,
|
||||
'push': False,
|
||||
'email': False
|
||||
'enabled': True,
|
||||
'core_notification_types': ['new_comment_on_response'],
|
||||
'notification_types': {
|
||||
'new_comment': {
|
||||
'web': True,
|
||||
'email': True,
|
||||
'push': True,
|
||||
'info': 'Comment on post'
|
||||
},
|
||||
'new_response': {
|
||||
'web': True,
|
||||
'email': True,
|
||||
'push': True,
|
||||
'info': 'Response on post'
|
||||
},
|
||||
'core': {
|
||||
'web': True,
|
||||
'email': True,
|
||||
'push': True,
|
||||
'info': ''
|
||||
}
|
||||
},
|
||||
'non_editable': {
|
||||
'new_comment': ['web', 'email']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if overrides:
|
||||
expected_response.update(overrides)
|
||||
return expected_response
|
||||
|
||||
def test_get_user_notification_preference_without_login(self):
|
||||
"""
|
||||
@@ -196,28 +218,50 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, self._expected_api_response())
|
||||
|
||||
def test_patch_user_notification_preference(self):
|
||||
@ddt.data(
|
||||
('discussion', None, None, True, status.HTTP_200_OK, 'app_update'),
|
||||
('discussion', None, None, False, status.HTTP_200_OK, 'app_update'),
|
||||
('invalid_notification_app', None, None, True, status.HTTP_400_BAD_REQUEST, None),
|
||||
|
||||
('discussion', 'new_comment', 'web', True, status.HTTP_200_OK, 'type_update'),
|
||||
('discussion', 'new_response', 'web', False, status.HTTP_200_OK, 'type_update'),
|
||||
|
||||
('discussion', 'core', 'email', True, status.HTTP_200_OK, 'type_update'),
|
||||
('discussion', 'core', 'email', False, status.HTTP_200_OK, 'type_update'),
|
||||
|
||||
('discussion', 'invalid_notification_type', 'email', True, status.HTTP_400_BAD_REQUEST, None),
|
||||
('discussion', 'new_comment', 'invalid_notification_channel', False, status.HTTP_400_BAD_REQUEST, None),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_patch_user_notification_preference(
|
||||
self, notification_app, notification_type, notification_channel, value, expected_status, update_type,
|
||||
):
|
||||
"""
|
||||
Test update of user notification preference.
|
||||
"""
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
updated_notification_config_data = {
|
||||
"notification_preference_config": {
|
||||
"discussion": {
|
||||
"new_post": {
|
||||
"web": True,
|
||||
"push": False,
|
||||
"email": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
payload = {
|
||||
'notification_app': notification_app,
|
||||
'value': value,
|
||||
}
|
||||
response = self.client.patch(
|
||||
self.path, json.dumps(updated_notification_config_data), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
expected_data = self._expected_api_response(overrides=updated_notification_config_data)
|
||||
self.assertEqual(response.data, expected_data)
|
||||
if notification_type:
|
||||
payload['notification_type'] = notification_type
|
||||
if notification_channel:
|
||||
payload['notification_channel'] = notification_channel
|
||||
|
||||
response = self.client.patch(self.path, json.dumps(payload), content_type='application/json')
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
if update_type == 'app_update':
|
||||
expected_data = self._expected_api_response()
|
||||
expected_data['notification_preference_config'][notification_app]['enabled'] = value
|
||||
self.assertEqual(response.data, expected_data)
|
||||
|
||||
elif update_type == 'type_update':
|
||||
expected_data = self._expected_api_response()
|
||||
expected_data['notification_preference_config'][notification_app][
|
||||
'notification_types'][notification_type][notification_channel] = value
|
||||
self.assertEqual(response.data, expected_data)
|
||||
|
||||
|
||||
class NotificationListAPIViewTest(APITestCase):
|
||||
@@ -238,7 +282,6 @@ class NotificationListAPIViewTest(APITestCase):
|
||||
user=self.user,
|
||||
app_name='app1',
|
||||
notification_type='info',
|
||||
content='This is a notification.',
|
||||
)
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
|
||||
@@ -253,7 +296,6 @@ class NotificationListAPIViewTest(APITestCase):
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['app_name'], 'app1')
|
||||
self.assertEqual(data[0]['notification_type'], 'info')
|
||||
self.assertEqual(data[0]['content'], 'This is a notification.')
|
||||
|
||||
def test_list_notifications_with_app_name_filter(self):
|
||||
"""
|
||||
@@ -264,13 +306,11 @@ class NotificationListAPIViewTest(APITestCase):
|
||||
user=self.user,
|
||||
app_name='app1',
|
||||
notification_type='info',
|
||||
content='This is a notification for app1.',
|
||||
)
|
||||
Notification.objects.create(
|
||||
user=self.user,
|
||||
app_name='app2',
|
||||
notification_type='info',
|
||||
content='This is a notification for app2.',
|
||||
)
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
|
||||
@@ -285,7 +325,6 @@ class NotificationListAPIViewTest(APITestCase):
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['app_name'], 'app1')
|
||||
self.assertEqual(data[0]['notification_type'], 'info')
|
||||
self.assertEqual(data[0]['content'], 'This is a notification for app1.')
|
||||
|
||||
def test_list_notifications_without_authentication(self):
|
||||
"""
|
||||
@@ -349,6 +388,7 @@ class MarkNotificationsUnseenAPIViewTestCase(APITestCase):
|
||||
"""
|
||||
Tests for the MarkNotificationsUnseenAPIView.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = UserFactory()
|
||||
|
||||
|
||||
@@ -12,14 +12,18 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from openedx.core.djangoapps.notifications.models import NotificationPreference
|
||||
from openedx.core.djangoapps.notifications.models import (
|
||||
CourseNotificationPreference,
|
||||
get_course_notification_preference_config_version
|
||||
)
|
||||
|
||||
from .config.waffle import ENABLE_NOTIFICATIONS
|
||||
from .models import Notification
|
||||
from .serializers import (
|
||||
NotificationCourseEnrollmentSerializer,
|
||||
NotificationSerializer,
|
||||
UserNotificationPreferenceSerializer
|
||||
UserCourseNotificationPreferenceSerializer,
|
||||
UserNotificationPreferenceUpdateSerializer
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -83,12 +87,23 @@ class UserNotificationPreferenceView(APIView):
|
||||
'course_id': 'course-v1:testorg+testcourse+testrun',
|
||||
'notification_preference_config': {
|
||||
'discussion': {
|
||||
'new_post': {
|
||||
'enabled': False,
|
||||
'core': {
|
||||
'info': '',
|
||||
'web': False,
|
||||
'push': False,
|
||||
'email': False,
|
||||
}
|
||||
}
|
||||
},
|
||||
'notification_types': {
|
||||
'new_post': {
|
||||
'info': '',
|
||||
'web': False,
|
||||
'push': False,
|
||||
'email': False,
|
||||
},
|
||||
},
|
||||
'not_editable': {},
|
||||
},
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -109,22 +124,33 @@ class UserNotificationPreferenceView(APIView):
|
||||
'course_id': 'course-v1:testorg+testcourse+testrun',
|
||||
'notification_preference_config': {
|
||||
'discussion': {
|
||||
'new_post': {
|
||||
'enabled': False,
|
||||
'core': {
|
||||
'info': '',
|
||||
'web': False,
|
||||
'push': False,
|
||||
'email': False,
|
||||
}
|
||||
}
|
||||
},
|
||||
'notification_types': {
|
||||
'new_post': {
|
||||
'info': '',
|
||||
'web': False,
|
||||
'push': False,
|
||||
'email': False,
|
||||
},
|
||||
},
|
||||
'not_editable': {},
|
||||
},
|
||||
}
|
||||
}
|
||||
"""
|
||||
course_id = CourseKey.from_string(course_key_string)
|
||||
user_notification_preference, _ = NotificationPreference.objects.get_or_create(
|
||||
user_notification_preference, _ = CourseNotificationPreference.objects.get_or_create(
|
||||
user=request.user,
|
||||
course_id=course_id,
|
||||
is_active=True,
|
||||
)
|
||||
serializer = UserNotificationPreferenceSerializer(user_notification_preference)
|
||||
serializer = UserCourseNotificationPreferenceSerializer(user_notification_preference)
|
||||
return Response(serializer.data)
|
||||
|
||||
def patch(self, request, course_key_string):
|
||||
@@ -142,16 +168,24 @@ class UserNotificationPreferenceView(APIView):
|
||||
400: Validation error
|
||||
"""
|
||||
course_id = CourseKey.from_string(course_key_string)
|
||||
user_notification_preference = NotificationPreference.objects.get(
|
||||
user_course_notification_preference = CourseNotificationPreference.objects.get(
|
||||
user=request.user,
|
||||
course_id=course_id,
|
||||
is_active=True,
|
||||
)
|
||||
serializer = UserNotificationPreferenceSerializer(user_notification_preference, data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
if user_course_notification_preference.config_version != get_course_notification_preference_config_version():
|
||||
return Response(
|
||||
{'error': 'The notification preference config version is not up to date.'},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
preference_update_serializer = UserNotificationPreferenceUpdateSerializer(
|
||||
user_course_notification_preference, data=request.data, partial=True
|
||||
)
|
||||
preference_update_serializer.is_valid(raise_exception=True)
|
||||
updated_notification_preferences = preference_update_serializer.save()
|
||||
serializer = UserCourseNotificationPreferenceSerializer(updated_notification_preferences)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class NotificationListAPIView(generics.ListAPIView):
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
""" Management command for populaing current job of learners in learner profile. """
|
||||
|
||||
import logging
|
||||
|
||||
from edx_rest_api_client.client import OAuthAPIClient
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from common.djangoapps.student.models import User
|
||||
from openedx.core.djangoapps.user_api import errors
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Command to populate current job of learners in extended learner profile.
|
||||
|
||||
This command will fetch the current job of learners from course-discovery service
|
||||
and populate it in the extended learner profile in edx-platform. This command is
|
||||
supposed to be run only once in the system.
|
||||
|
||||
Example usage:
|
||||
$ # Update the current job of learners.
|
||||
$ ./manage.py lms populate_enterprise_learner_current_job
|
||||
"""
|
||||
|
||||
help = 'Populates current job of learners in extended learner profile.'
|
||||
|
||||
TOTAL_USERS_COUNT = 0
|
||||
TOTAL_USERS_UPDATED = 0
|
||||
|
||||
def _get_edx_api_client(self):
|
||||
return OAuthAPIClient(
|
||||
base_url=settings.ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL,
|
||||
client_id=settings.ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY,
|
||||
client_secret=settings.ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET,
|
||||
)
|
||||
|
||||
def _fetch_learner_job_data(self, url=None):
|
||||
"""
|
||||
Get the username and current job of learners from discovery service.
|
||||
|
||||
Returns:
|
||||
list: List of dictionaries containing username and current job of learners.
|
||||
"""
|
||||
client = self._get_edx_api_client()
|
||||
|
||||
if not url:
|
||||
course_discovery_url = settings.COURSE_CATALOG_URL_ROOT
|
||||
learner_jobs_endpoint = '/taxonomy/api/v1/learners-current-job/?page_size=1000'
|
||||
url = urljoin(course_discovery_url, learner_jobs_endpoint)
|
||||
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
|
||||
self.TOTAL_USERS_COUNT = response['count']
|
||||
return response
|
||||
|
||||
def _get_user_profile(self, username):
|
||||
"""
|
||||
Helper method to return the user profile object based on username.
|
||||
"""
|
||||
try:
|
||||
user = User.objects.filter(username=username).select_related('profile').first()
|
||||
except ObjectDoesNotExist:
|
||||
raise errors.UserNotFound() # lint-amnesty, pylint: disable=raise-missing-from
|
||||
return user.profile
|
||||
|
||||
def _update_current_job_of_learner(self, username, current_job):
|
||||
"""
|
||||
Update the current job of learner in their extended profile.
|
||||
|
||||
Args:
|
||||
user_profile (UserProfile): Extended profile of the learner.
|
||||
current_job (number): Current job of the learner.
|
||||
"""
|
||||
try:
|
||||
user_profile = self._get_user_profile(username)
|
||||
meta = user_profile.get_meta()
|
||||
meta['enterprise_learner_current_job'] = current_job
|
||||
user_profile.set_meta(meta)
|
||||
user_profile.save()
|
||||
|
||||
self.TOTAL_USERS_UPDATED += 1
|
||||
except Exception: # lint-amnesty, pylint: disable=broad-except
|
||||
LOGGER.exception('Could not update profile of user %s as %s.', username, current_job)
|
||||
|
||||
def _populate_learner_current_job(self, response):
|
||||
"""
|
||||
Populate the current job of learners in extended learner profile. An example of
|
||||
response is as follows:
|
||||
[
|
||||
{
|
||||
"username": "learner1",
|
||||
"current_job": 1
|
||||
},
|
||||
{
|
||||
"username": "learner2",
|
||||
"current_job": 2
|
||||
}
|
||||
]
|
||||
|
||||
Args:
|
||||
response (list): List of dictionaries containing username and current job of learners.
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
for learner_data in response:
|
||||
self._update_current_job_of_learner(
|
||||
username=learner_data['username'],
|
||||
current_job=learner_data['current_job'],
|
||||
)
|
||||
|
||||
def _populate_learner_profiles(self):
|
||||
"""
|
||||
Populate the current job of learners in extended learner profile.
|
||||
"""
|
||||
response = self._fetch_learner_job_data()
|
||||
self._populate_learner_current_job(response['results'])
|
||||
while response['next']:
|
||||
response = self._fetch_learner_job_data(response['next'])
|
||||
self._populate_learner_current_job(response['results'])
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Handle the command.
|
||||
|
||||
Args:
|
||||
*args: Variable length argument list.
|
||||
**options: Arbitrary keyword arguments.
|
||||
"""
|
||||
try:
|
||||
LOGGER.info('Populating current job of learners in their extended profiles.')
|
||||
self._populate_learner_profiles()
|
||||
LOGGER.info('Successfully populated current job of %s learner(s) from %s total learners.',
|
||||
self.TOTAL_USERS_UPDATED, self.TOTAL_USERS_COUNT)
|
||||
except Exception as err: # lint-amnesty, pylint: disable=broad-except
|
||||
LOGGER.exception('Could not populate current job of learners. %s', err)
|
||||
@@ -26,7 +26,7 @@ django-storages==1.9.1
|
||||
# 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==3.66.0
|
||||
edx-enterprise==3.66.1
|
||||
|
||||
# oauthlib>3.0.1 causes test failures ( also remove the django-oauth-toolkit constraint when this is fixed )
|
||||
oauthlib==3.0.1
|
||||
|
||||
@@ -486,7 +486,7 @@ edx-drf-extensions==8.8.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# learner-pathway-progress
|
||||
edx-enterprise==3.66.0
|
||||
edx-enterprise==3.66.1
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/base.in
|
||||
|
||||
@@ -610,7 +610,7 @@ edx-drf-extensions==8.8.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# learner-pathway-progress
|
||||
edx-enterprise==3.66.0
|
||||
edx-enterprise==3.66.1
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/testing.txt
|
||||
|
||||
@@ -588,7 +588,7 @@ edx-drf-extensions==8.8.0
|
||||
# edx-when
|
||||
# edxval
|
||||
# learner-pathway-progress
|
||||
edx-enterprise==3.66.0
|
||||
edx-enterprise==3.66.1
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
# -r requirements/edx/base.txt
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
}
|
||||
|
||||
_.bindAll(this, 'clickHandler');
|
||||
_.bindAll(this, 'copyHandler');
|
||||
_.bindAll(this, 'hideHandler');
|
||||
_.bindAll(this, 'showHandler');
|
||||
|
||||
this.container = element;
|
||||
|
||||
@@ -35,10 +38,19 @@
|
||||
// Initializes the module.
|
||||
initialize: function() {
|
||||
this.el = this.container.find('.wrapper-social-share');
|
||||
this.el.on('click', '.btn-link', this.clickHandler);
|
||||
this.baseVideoUrl = this.el.data('url');
|
||||
this.course_id = this.container.data('courseId');
|
||||
this.block_id = this.container.data('blockId');
|
||||
this.el.on('click', '.social-share-link', this.clickHandler);
|
||||
|
||||
this.closeBtn = this.el.find('.close-btn');
|
||||
this.toggleBtn = this.el.find('.social-toggle-btn');
|
||||
this.copyBtn = this.el.find('.public-video-copy-btn');
|
||||
this.shareContainer = this.el.find('.container-social-share');
|
||||
this.closeBtn.on('click', this.hideHandler);
|
||||
this.toggleBtn.on('click', this.showHandler);
|
||||
this.copyBtn.on('click', this.copyHandler);
|
||||
|
||||
},
|
||||
|
||||
// Fire an analytics event on share button click.
|
||||
@@ -48,6 +60,20 @@
|
||||
self.sendAnalyticsEvent(source);
|
||||
},
|
||||
|
||||
hideHandler: function(event) {
|
||||
this.shareContainer.hide();
|
||||
this.toggleBtn.show();
|
||||
},
|
||||
|
||||
showHandler: function(event) {
|
||||
this.shareContainer.show();
|
||||
this.toggleBtn.hide();
|
||||
},
|
||||
|
||||
copyHandler: function(event) {
|
||||
navigator.clipboard.writeText(this.copyBtn.data('url'));
|
||||
},
|
||||
|
||||
// Send an analytics event for share button tracking.
|
||||
sendAnalyticsEvent: function(source) {
|
||||
window.analytics.track(
|
||||
|
||||
@@ -150,9 +150,8 @@ def _write_styles(selector, output_root, classes, css_attribute, suffix):
|
||||
))
|
||||
module_styles_lines.extend(f' @import "{name}";' for name in fragment_names)
|
||||
module_styles_lines.append('}')
|
||||
file_hash = hashlib.md5("".join(fragment_names).encode('ascii')).hexdigest()
|
||||
|
||||
contents[f"{class_.__name__}{suffix}.{file_hash}.scss"] = '\n'.join(module_styles_lines)
|
||||
contents[f"{class_.__name__}{suffix}.scss"] = '\n'.join(module_styles_lines)
|
||||
|
||||
_write_files(output_root, contents)
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ class XModuleWebpackLoader(WebpackLoader):
|
||||
'path': '/openedx/edx-platform/common/static/bundles/AnnotatableBlockPreview.js.map'
|
||||
},
|
||||
{
|
||||
'name': 'AnnotatableBlockPreview.85745121.css',
|
||||
'path': 'common/static/css/xmodule/AnnotatableBlockPreview.85745121.css',
|
||||
'publicPath': '/static/css/xmodule/AnnotatableBlockPreview.85745121.css'
|
||||
'name': 'AnnotatableBlockPreview.css',
|
||||
'path': 'common/static/css/xmodule/AnnotatableBlockPreview.css',
|
||||
'publicPath': '/static/css/xmodule/AnnotatableBlockPreview.css'
|
||||
}
|
||||
],
|
||||
...
|
||||
|
||||
Reference in New Issue
Block a user