Files
edx-platform/openedx/core/djangoapps/notifications/views.py

824 lines
32 KiB
Python

"""
Views for the notifications API.
"""
import copy
from datetime import datetime, timedelta
from django.conf import settings
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework import generics, status
from rest_framework.decorators import api_view
from rest_framework.generics import UpdateAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY
from openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch
from openedx.core.djangoapps.notifications.models import get_course_notification_preference_config_version, \
NotificationPreference
from openedx.core.djangoapps.notifications.permissions import allow_any_authenticated_user
from openedx.core.djangoapps.notifications.serializers import add_info_to_notification_config
from openedx.core.djangoapps.user_api.models import UserPreference
from .base_notification import COURSE_NOTIFICATION_APPS, NotificationAppManager, COURSE_NOTIFICATION_TYPES, \
NotificationTypeManager
from .config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS
from .events import (
notification_preference_update_event,
notification_preferences_viewed_event,
notification_read_event,
notification_tray_opened_event,
notifications_app_all_read_event
)
from .models import CourseNotificationPreference, Notification
from .serializers import (
NotificationCourseEnrollmentSerializer,
NotificationSerializer,
UserCourseNotificationPreferenceSerializer,
UserNotificationPreferenceUpdateAllSerializer,
UserNotificationPreferenceUpdateSerializer,
add_non_editable_in_preference
)
from .tasks import create_notification_preference
from .utils import (
aggregate_notification_configs,
filter_out_visible_preferences_by_course_ids,
get_show_notifications_tray,
exclude_inaccessible_preferences
)
@allow_any_authenticated_user()
class CourseEnrollmentListView(generics.ListAPIView):
"""
API endpoint to get active CourseEnrollments for requester.
**Permissions**: User must be authenticated.
**Response Format** (paginated):
{
"next": (str) url_to_next_page_of_courses,
"previous": (str) url_to_previous_page_of_courses,
"count": (int) total_number_of_courses,
"num_pages": (int) total_number_of_pages,
"current_page": (int) current_page_number,
"start": (int) index_of_first_course_on_page,
"results" : [
{
"course": {
"id": (int) course_id,
"display_name": (str) course_display_name
},
},
...
],
}
Response Error Codes:
- 403: The requester cannot access resource.
"""
serializer_class = NotificationCourseEnrollmentSerializer
def get_paginated_response(self, data):
"""
Return a response given serialized page data with show_preferences flag.
"""
response = super().get_paginated_response(data)
response.data["show_preferences"] = get_show_notifications_tray(self.request.user)
return response
def get_queryset(self):
user = self.request.user
return CourseEnrollment.objects.filter(user=user, is_active=True)
def list(self, request, *args, **kwargs):
"""
Returns the list of active course enrollments for which ENABLE_NOTIFICATIONS
Waffle flag is enabled
"""
queryset = self.filter_queryset(self.get_queryset())
course_ids = queryset.values_list('course_id', flat=True)
for course_id in course_ids:
if not ENABLE_NOTIFICATIONS.is_enabled(course_id):
queryset = queryset.exclude(course_id=course_id)
queryset = queryset.select_related('course').order_by('-id')
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
return Response({
"show_preferences": get_show_notifications_tray(request.user),
"results": self.get_serializer(queryset, many=True).data
})
@allow_any_authenticated_user()
class UserNotificationPreferenceView(APIView):
"""
Supports retrieving and patching the UserNotificationPreference
model.
**Example Requests**
GET /api/notifications/configurations/{course_id}
PATCH /api/notifications/configurations/{course_id}
**Example Response**:
{
'id': 1,
'course_name': 'testcourse',
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': {
'discussion': {
'enabled': False,
'core': {
'info': '',
'web': False,
'push': False,
'email': False,
},
'notification_types': {
'new_post': {
'info': '',
'web': False,
'push': False,
'email': False,
},
},
'not_editable': {},
},
}
}
"""
def get(self, request, course_key_string):
"""
Returns notification preference for user for a course.
Parameters:
request (Request): The request object.
course_key_string (int): The ID of the course to retrieve notification preference.
Returns:
{
'id': 1,
'course_name': 'testcourse',
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': {
'discussion': {
'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_preference = CourseNotificationPreference.get_updated_user_course_preferences(request.user, course_id)
serializer_context = {
'course_id': course_id,
'user': request.user
}
serializer = UserCourseNotificationPreferenceSerializer(user_preference, context=serializer_context)
notification_preferences_viewed_event(request, course_id)
return Response(serializer.data)
def patch(self, request, course_key_string):
"""
Update an existing user notification preference with the data in the request body.
Parameters:
request (Request): The request object
course_key_string (int): The ID of the course of the notification preference to be updated.
Returns:
200: The updated preference, serialized using the UserNotificationPreferenceSerializer
404: If the preference does not exist
403: If the user does not have permission to update the preference
400: Validation error
"""
course_id = CourseKey.from_string(course_key_string)
user_course_notification_preference = CourseNotificationPreference.objects.get(
user=request.user,
course_id=course_id,
is_active=True,
)
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,
)
if request.data.get('notification_channel', '') == 'email_cadence':
request.data['email_cadence'] = request.data['value']
del request.data['value']
preference_update = UserNotificationPreferenceUpdateSerializer(
user_course_notification_preference, data=request.data, partial=True
)
preference_update.is_valid(raise_exception=True)
updated_notification_preferences = preference_update.save()
if request.data.get('notification_channel', '') == 'email' and request.data.get('value', False):
UserPreference.objects.filter(
user_id=request.user.id,
key=ONE_CLICK_EMAIL_UNSUB_KEY
).delete()
notification_preference_update_event(request.user, course_id, preference_update.validated_data)
serializer_context = {
'course_id': course_id,
'user': request.user
}
serializer = UserCourseNotificationPreferenceSerializer(updated_notification_preferences,
context=serializer_context)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_any_authenticated_user()
class NotificationListAPIView(generics.ListAPIView):
"""
API view for listing notifications for a user.
**Permissions**: User must be authenticated.
**Response Format** (paginated):
{
"results" : [
{
"id": (int) notification_id,
"app_name": (str) app_name,
"notification_type": (str) notification_type,
"content": (str) content,
"content_context": (dict) content_context,
"content_url": (str) content_url,
"last_read": (datetime) last_read,
"last_seen": (datetime) last_seen
},
...
],
"count": (int) total_number_of_notifications,
"next": (str) url_to_next_page_of_notifications,
"previous": (str) url_to_previous_page_of_notifications,
"page_size": (int) number_of_notifications_per_page,
}
Response Error Codes:
- 403: The requester cannot access resource.
"""
serializer_class = NotificationSerializer
def get_queryset(self):
"""
Override the get_queryset method to filter the queryset by app name, request.user and created
"""
expiry_date = datetime.now(UTC) - timedelta(days=settings.NOTIFICATIONS_EXPIRY)
app_name = self.request.query_params.get('app_name')
if self.request.query_params.get('tray_opened'):
unseen_count = Notification.objects.filter(user_id=self.request.user, last_seen__isnull=True).count()
notification_tray_opened_event(self.request.user, unseen_count)
params = {
'user': self.request.user,
'created__gte': expiry_date,
'web': True
}
if app_name:
params['app_name'] = app_name
return Notification.objects.filter(**params).order_by('-created')
@allow_any_authenticated_user()
class NotificationCountView(APIView):
"""
API view for getting the unseen notifications count and show_notification_tray flag for a user.
"""
def get(self, request):
"""
Get the unseen notifications count and show_notification_tray flag for a user.
**Permissions**: User must be authenticated.
**Response Format**:
```json
{
"show_notifications_tray": (bool) show_notifications_tray,
"count": (int) total_number_of_unseen_notifications,
"count_by_app_name": {
(str) app_name: (int) number_of_unseen_notifications,
...
},
"notification_expiry_days": 60
}
```
**Response Error Codes**:
- 403: The requester cannot access resource.
"""
# Get the unseen notifications count for each app name.
count_by_app_name = (
Notification.objects
.filter(user_id=request.user, last_seen__isnull=True, web=True)
.values('app_name')
.annotate(count=Count('*'))
)
count_total = 0
show_notifications_tray = get_show_notifications_tray(self.request.user)
count_by_app_name_dict = {
app_name: 0
for app_name in COURSE_NOTIFICATION_APPS
}
for item in count_by_app_name:
app_name = item['app_name']
count = item['count']
count_total += count
count_by_app_name_dict[app_name] = count
return Response({
"show_notifications_tray": show_notifications_tray,
"count": count_total,
"count_by_app_name": count_by_app_name_dict,
"notification_expiry_days": settings.NOTIFICATIONS_EXPIRY,
})
@allow_any_authenticated_user()
class MarkNotificationsSeenAPIView(UpdateAPIView):
"""
API view for marking user's all notifications seen for a provided app_name.
"""
def update(self, request, *args, **kwargs):
"""
Marks all notifications for the given app name seen for the authenticated user.
**Args:**
app_name: The name of the app to mark notifications seen for.
**Response Format:**
A `Response` object with a 200 OK status code if the notifications were successfully marked seen.
**Response Error Codes**:
- 400: Bad Request status code if the app name is invalid.
"""
app_name = self.kwargs.get('app_name')
if not app_name:
return Response({'error': _('Invalid app name.')}, status=400)
notifications = Notification.objects.filter(
user=request.user,
app_name=app_name,
last_seen__isnull=True,
)
notifications.update(last_seen=datetime.now())
return Response({'message': _('Notifications marked as seen.')}, status=200)
@allow_any_authenticated_user()
class NotificationReadAPIView(APIView):
"""
API view for marking user notifications as read, either all notifications or a single notification
"""
def patch(self, request, *args, **kwargs):
"""
Marks all notifications or single notification read for the given
app name or notification id for the authenticated user.
Requests:
PATCH /api/notifications/read/
Parameters:
request (Request): The request object containing the app name or notification id.
{
"app_name": (str) app_name,
"notification_id": (int) notification_id
}
Returns:
- 200: OK status code if the notification or notifications were successfully marked read.
- 400: Bad Request status code if the app name is invalid.
- 403: Forbidden status code if the user is not authenticated.
- 404: Not Found status code if the notification was not found.
"""
notification_id = request.data.get('notification_id', None)
read_at = datetime.now(UTC)
if notification_id:
notification = get_object_or_404(Notification, pk=notification_id, user=request.user)
first_time_read = notification.last_read is None
notification.last_read = read_at
notification.save()
notification_read_event(request.user, notification, first_time_read)
return Response({'message': _('Notification marked read.')}, status=status.HTTP_200_OK)
app_name = request.data.get('app_name', '')
if app_name in COURSE_NOTIFICATION_APPS:
notifications = Notification.objects.filter(
user=request.user,
app_name=app_name,
last_read__isnull=True,
)
notifications.update(last_read=read_at)
notifications_app_all_read_event(request.user, app_name)
return Response({'message': _('Notifications marked read.')}, status=status.HTTP_200_OK)
return Response({'error': _('Invalid app_name or notification_id.')}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET', 'POST'])
def preference_update_from_encrypted_username_view(request, username, patch):
"""
View to update user preferences from encrypted username and patch.
username and patch must be string
"""
update_user_preferences_from_patch(username, patch)
return Response({"result": "success"}, status=status.HTTP_200_OK)
@allow_any_authenticated_user()
class UpdateAllNotificationPreferencesView(APIView):
"""
API view for updating all notification preferences for the current user.
"""
def post(self, request):
"""
Update all notification preferences for the current user.
"""
# check if request have required params
serializer = UserNotificationPreferenceUpdateAllSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'status': 'error',
'message': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
# check if required config is not editable
try:
with transaction.atomic():
# Get all active notification preferences for the current user
notification_preferences = (
CourseNotificationPreference.objects
.select_for_update()
.filter(
user=request.user,
is_active=True
)
)
if not notification_preferences.exists():
return Response({
'status': 'error',
'message': 'No active notification preferences found'
}, status=status.HTTP_404_NOT_FOUND)
data = serializer.validated_data
app = data['notification_app']
email_cadence = data.get('email_cadence', None)
channel = data.get('notification_channel', 'email_cadence' if email_cadence else None)
notification_type = data['notification_type']
value = data.get('value', email_cadence if email_cadence else None)
updated_courses = []
errors = []
# Update each preference
for preference in notification_preferences:
try:
# Create a deep copy of the current config
updated_config = copy.deepcopy(preference.notification_preference_config)
# Check if the path exists and update the value
if (
updated_config.get(app, {})
.get('notification_types', {})
.get(notification_type, {})
.get(channel)
) is not None:
# Update the specific setting in the config
updated_config[app]['notification_types'][notification_type][channel] = value
# Update the notification preference
preference.notification_preference_config = updated_config
preference.save()
updated_courses.append({
'course_id': str(preference.course_id),
'current_setting': updated_config[app]['notification_types'][notification_type]
})
else:
errors.append({
'course_id': str(preference.course_id),
'error': f'Invalid path: {app}.notification_types.{notification_type}.{channel}'
})
except (KeyError, AttributeError, ValueError) as e:
errors.append({
'course_id': str(preference.course_id),
'error': str(e)
})
if channel == 'email' and value:
UserPreference.objects.filter(
user_id=request.user,
key=ONE_CLICK_EMAIL_UNSUB_KEY
).delete()
response_data = {
'status': 'success' if updated_courses else 'partial_success' if errors else 'error',
'message': 'Notification preferences update completed',
'data': {
'updated_value': value,
'notification_type': notification_type,
'channel': channel,
'app': app,
'successfully_updated_courses': updated_courses,
'total_updated': len(updated_courses),
'total_courses': notification_preferences.count()
}
}
if errors:
response_data['errors'] = errors
event_data = {
'notification_app': app,
'notification_type': notification_type,
'notification_channel': channel,
'value': value,
'email_cadence': value
}
notification_preference_update_event(
request.user,
[course['course_id'] for course in updated_courses],
event_data
)
return Response(
response_data,
status=status.HTTP_200_OK if updated_courses else status.HTTP_400_BAD_REQUEST
)
except (KeyError, AttributeError, ValueError) as e:
return Response({
'status': 'error',
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@allow_any_authenticated_user()
class AggregatedNotificationPreferences(APIView):
"""
API view for getting the aggregate notification preferences for the current user.
"""
def get(self, request):
"""
API view for getting the aggregate notification preferences for the current user.
"""
notification_preferences = CourseNotificationPreference.get_user_notification_preferences(request.user)
if not notification_preferences.exists():
return Response({
'status': 'error',
'message': 'No active notification preferences found'
}, status=status.HTTP_404_NOT_FOUND)
notification_configs = notification_preferences.values_list('notification_preference_config', flat=True)
notification_configs = aggregate_notification_configs(
notification_configs
)
course_ids = notification_preferences.values_list('course_id', flat=True)
filter_out_visible_preferences_by_course_ids(
request.user,
notification_configs,
course_ids,
)
notification_preferences_viewed_event(request)
notification_configs = add_info_to_notification_config(notification_configs)
discussion_config = notification_configs.get('discussion', {})
notification_types = discussion_config.get('notification_types', {})
if not any(ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key) for course_key in course_ids):
notification_types.pop('new_instructor_all_learners_post', None)
return Response({
'status': 'success',
'message': 'Notification preferences retrieved',
'data': add_non_editable_in_preference(notification_configs)
}, status=status.HTTP_200_OK)
@allow_any_authenticated_user()
class NotificationPreferencesView(APIView):
"""
API view to retrieve and structure the notification preferences for the
authenticated user.
"""
def get(self, request):
"""
Handles GET requests to retrieve notification preferences.
This method fetches the user's active notification preferences and
merges them with a default structure provided by NotificationAppManager.
This provides a complete view of all possible notifications and the
user's current settings for them.
Returns:
Response: A DRF Response object containing the structured
notification preferences or an error message.
"""
user_preferences_qs = NotificationPreference.objects.filter(user=request.user)
user_preferences_map = {pref.type: pref for pref in user_preferences_qs}
# Ensure all notification types are present in the user's preferences.
# If any are missing, create them with default values.
diff = set(COURSE_NOTIFICATION_TYPES.keys()) - set(user_preferences_map.keys())
missing_types = []
for missing_type in diff:
new_pref = create_notification_preference(
user_id=request.user.id,
notification_type=missing_type,
)
missing_types.append(new_pref)
user_preferences_map[missing_type] = new_pref
if missing_types:
NotificationPreference.objects.bulk_create(missing_types)
# If no user preferences are found, return an error response.
if not user_preferences_map:
return Response({
'status': 'error',
'message': 'No active notification preferences found for this user.'
}, status=status.HTTP_404_NOT_FOUND)
# Get the structured preferences from the NotificationAppManager.
# This will include all apps and their notification types.
structured_preferences = NotificationAppManager().get_notification_app_preferences()
for app_name, app_settings in structured_preferences.items():
notification_types = app_settings.get('notification_types', {})
# Process all notification types (core and non-core) in a single loop.
for type_name, type_details in notification_types.items():
if type_name == 'core':
if structured_preferences[app_name]['core_notification_types']:
# If the app has core notification types, use the first one as the type name.
# This assumes that the first core notification type is representative of the core settings.
notification_type = structured_preferences[app_name]['core_notification_types'][0]
else:
notification_type = 'core'
user_pref = user_preferences_map.get(notification_type)
else:
user_pref = user_preferences_map.get(type_name)
if user_pref:
# If a preference exists, update the dictionary for this type.
# This directly modifies the 'type_details' dictionary.
type_details['web'] = user_pref.web
type_details['email'] = user_pref.email
type_details['push'] = user_pref.push
type_details['email_cadence'] = user_pref.email_cadence
exclude_inaccessible_preferences(structured_preferences, request.user)
return Response({
'status': 'success',
'message': 'Notification preferences retrieved successfully.',
'data': add_non_editable_in_preference(structured_preferences)
}, status=status.HTTP_200_OK)
def put(self, request):
"""
Handles PUT requests to update notification preferences.
This method updates the user's notification preferences based on the
provided data in the request body. It expects a dictionary with
notification types and their settings.
Returns:
Response: A DRF Response object indicating success or failure.
"""
# Validate incoming data
serializer = UserNotificationPreferenceUpdateAllSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'status': 'error',
'message': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
# Get validated data for easier access
validated_data = serializer.validated_data
# Build query set based on notification type
query_set = NotificationPreference.objects.filter(user_id=request.user.id)
if validated_data['notification_type'] == 'core':
# Get core notification types for the app
__, core_types = NotificationTypeManager().get_notification_app_preference(
notification_app=validated_data['notification_app']
)
query_set = query_set.filter(type__in=core_types)
else:
# Filter by single notification type
query_set = query_set.filter(type=validated_data['notification_type'])
# Prepare update data based on channel type
updated_data = self._prepare_update_data(validated_data)
# Update preferences
query_set.update(**updated_data)
# Log the event
self._log_preference_update_event(request.user, validated_data)
# Prepare and return response
response_data = self._prepare_response_data(validated_data)
return Response(response_data, status=status.HTTP_200_OK)
def _prepare_update_data(self, validated_data):
"""
Prepare the data dictionary for updating notification preferences.
Args:
validated_data (dict): Validated serializer data
Returns:
dict: Dictionary with update data
"""
channel = validated_data['notification_channel']
if channel == 'email_cadence':
return {channel: validated_data['email_cadence']}
else:
return {channel: validated_data['value']}
def _log_preference_update_event(self, user, validated_data):
"""
Log the notification preference update event.
Args:
user: The user making the update
validated_data (dict): Validated serializer data
"""
event_data = {
'notification_app': validated_data['notification_app'],
'notification_type': validated_data['notification_type'],
'notification_channel': validated_data['notification_channel'],
'value': validated_data.get('value'),
'email_cadence': validated_data.get('email_cadence'),
}
notification_preference_update_event(user, [], event_data)
def _prepare_response_data(self, validated_data):
"""
Prepare the response data dictionary.
Args:
validated_data (dict): Validated serializer data
Returns:
dict: Response data dictionary
"""
email_cadence = validated_data.get('email_cadence', None)
# Determine the updated value
updated_value = validated_data.get('value', email_cadence if email_cadence else None)
# Determine the channel
channel = validated_data.get('notification_channel')
if not channel and validated_data.get('email_cadence'):
channel = 'email_cadence'
return {
'status': 'success',
'message': 'Notification preferences update completed',
'data': {
'updated_value': updated_value,
'notification_type': validated_data['notification_type'],
'channel': channel,
'app': validated_data['notification_app'],
}
}