Merge branch 'master' into remove-extra-recommender-xblock-requirement

This commit is contained in:
Edward Zarecor
2023-06-07 17:14:37 -04:00
committed by GitHub
26 changed files with 852 additions and 182 deletions

View File

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

View File

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

View File

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

View File

@@ -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']

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)

View 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

View File

@@ -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}')

View File

@@ -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',
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
}
],
...