Files
edx-platform/openedx/core/djangoapps/notifications/views.py
Tarun Tak 18d5abb2f6 chore: Replace pytz with zoneinfo for UTC handling - Part 1 (#37523)
First PR to replace pytz with zoneinfo for UTC handling across codebase.

This PR migrates all UTC timezone handling from pytz to Python’s standard
library zoneinfo. The pytz library is now deprecated, and its documentation
recommends using zoneinfo for all new code. This update modernizes our
codebase, removes legacy pytz usage, and ensures compatibility with
current best practices for timezone management in Python 3.9+. No functional
changes to timezone logic - just a direct replacement for UTC handling.

https://github.com/openedx/edx-platform/issues/33980
2025-10-28 16:23:22 -04:00

449 lines
17 KiB
Python

"""
Views for the notifications API.
"""
from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import Count
from django_ratelimit.core import is_ratelimited
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from zoneinfo import ZoneInfo
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 openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch, username_from_hash
from openedx.core.djangoapps.notifications.models import NotificationPreference
from openedx.core.djangoapps.notifications.permissions import allow_any_authenticated_user
from .base_notification import COURSE_NOTIFICATION_APPS, NotificationAppManager, COURSE_NOTIFICATION_TYPES, \
NotificationTypeManager
from .events import (
notification_preference_update_event,
notification_read_event,
notification_tray_opened_event,
notifications_app_all_read_event
)
from .models import Notification
from .serializers import (
NotificationSerializer,
UserNotificationPreferenceUpdateAllSerializer,
add_info_to_notification_config,
add_non_editable_in_preference
)
from .tasks import create_notification_preference
from .utils import (
get_show_notifications_tray,
exclude_inaccessible_preferences
)
@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(ZoneInfo("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(ZoneInfo("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
"""
if is_ratelimited(
request=request, group="unsubscribe", key=username_from_hash,
rate=settings.ONE_CLICK_UNSUBSCRIBE_RATE_LIMIT, increment=True,
):
return Response({"error": "Too many requests"}, status=status.HTTP_429_TOO_MANY_REQUESTS)
update_user_preferences_from_patch(username)
return Response({"result": "success"}, 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)
structured_preferences = add_non_editable_in_preference(
add_info_to_notification_config(structured_preferences)
)
return Response({
'status': 'success',
'message': 'Notification preferences retrieved successfully.',
'show_preferences': get_show_notifications_tray(self.request.user),
'data': 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',
'show_preferences': get_show_notifications_tray(self.request.user),
'data': {
'updated_value': updated_value,
'notification_type': validated_data['notification_type'],
'channel': channel,
'app': validated_data['notification_app'],
}
}