feat: add detailed logging and error handling to notification API view#154
feat: add detailed logging and error handling to notification API view#154Rammohan-dev1 wants to merge 6 commits intorelease-ulmofrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aims to improve observability and resiliency of the Notifications API by adding structured logging and additional error handling across several DRF views.
Changes:
- Added module-level logger and additional
info/warning/errorlogs across notification list/count/read/seen endpoints. - Wrapped several view handlers in try/except blocks and introduced more explicit error responses (including some new 500 responses).
- Refactored imports and tightened/updated multiple docstrings and response messages.
Comments suppressed due to low confidence (7)
openedx/core/djangoapps/notifications/views.py:209
QuerySet.filter()/aggregation here will not raiseNotification.DoesNotExist, so including it in this exception handler is misleading. If you want to handle unexpected failures, either remove the try/except and let DRF handle errors, or catch the specific exceptions that can actually be raised in this block (and avoid turning programming errors into a generic 500).
except (Notification.DoesNotExist, AttributeError) as exc:
logger.error(
'Failed to retrieve notification count for user %s: %s',
request.user.id,
str(exc),
)
return Response(
{'error': 'Failed to retrieve notification count'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
openedx/core/djangoapps/notifications/views.py:266
Notification.objects.filter(...).update(...)will not raiseNotification.DoesNotExist, so this exception list is misleading/dead. Consider removingNotification.DoesNotExisthere and only catching exceptions you can meaningfully handle (or let DRF propagate unexpected errors).
except (Notification.DoesNotExist, AttributeError, TypeError) as exc:
logger.error(
'Failed to mark notifications seen for user %s: %s',
request.user.id,
str(exc),
)
return Response(
{'error': _('Failed to mark notifications as seen.')},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
openedx/core/djangoapps/notifications/views.py:379
- This
exceptblock includesNotification.DoesNotExist, but the operations in thetry(get_object_or_404,filter().update()) won’t raise that exception (404s come fromHttp404). Consider removingNotification.DoesNotExisthere and only handling the exceptions you expect, to avoid masking real bugs behind a generic 500.
except (Notification.DoesNotExist, AttributeError, TypeError) as exc:
logger.error(
'Failed to mark notification as read for user %s: %s',
request.user.id,
str(exc),
)
return Response(
{'error': _('Failed to mark notification as read.')},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
openedx/core/djangoapps/notifications/views.py:133
Notification.objects.filter(...).order_by(...)will never raiseNotification.DoesNotExist, so thisexcept Notification.DoesNotExistblock is effectively dead code. Consider removing the try/except (or catching and handling the specific exceptions that can actually occur here).
except Notification.DoesNotExist as exc:
logger.error(
'Failed to retrieve notifications for user %s: %s',
self.request.user.id,
str(exc)
)
raise
openedx/core/djangoapps/notifications/views.py:235
- This warning log uses an f-string, while the rest of the file uses parameterized logging (
logger.warning('...', arg)). Using parameterized logging avoids eager string formatting when the log level is disabled and keeps formatting consistent.
logger.warning(
f'Invalid app_name provided by user {request.user.id}'
)
openedx/core/djangoapps/notifications/views.py:540
NotificationPreference.objects.filter(...)will not raiseNotificationPreference.DoesNotExist, so including it in this exception tuple is misleading. Consider removing...DoesNotExisthere and only catching exceptions you intend to handle (or let DRF surface unexpected errors for easier debugging).
except (NotificationPreference.DoesNotExist, KeyError, AttributeError, TypeError) as exc:
logger.error(
'Failed to retrieve notification preferences for user %s: %s',
request.user.id,
str(exc),
)
return Response({
'status': 'error',
'message': 'Failed to retrieve notification preferences.'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
openedx/core/djangoapps/notifications/views.py:612
- Similar to the GET handler:
filter(...).update(...)won’t raiseNotificationPreference.DoesNotExist, so this exception tuple is misleading. Consider removing...DoesNotExistand narrowing exception handling to cases that can actually occur (or rely on DRF’s default error handling).
except (NotificationPreference.DoesNotExist, KeyError, AttributeError, TypeError) as exc:
logger.error(
'Failed to update notification preferences for user %s: %s',
request.user.id,
str(exc),
)
return Response({
'status': 'error',
'message': 'Failed to update notification preferences.'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
asharma4-sonata
left a comment
There was a problem hiding this comment.
The second commit should use chore: for example: "chore: added a trailing newline to resolve the quality check requirement" and not feat: since it doesn’t introduce any new functionality. I’d also recommend keeping the message shorter, such as chore: enabled quality check pipeline to succeed.
Also, please address the Copilot errors, as well as the special characters appearing in a few lines of code.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (5)
openedx/core/djangoapps/notifications/views.py:136
Notification.objects.filter(...).order_by(...)will never raiseNotification.DoesNotExist(that exception is only raised by.get()). Thetry/except Notification.DoesNotExistblock is therefore dead code and can give a false sense of error handling. Consider removing thisexceptor, if you want to handle unexpected DB issues, catch a more appropriate exception (e.g.,DatabaseError) and return/log accordingly.
try:
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
queryset = Notification.objects.filter(
**params
).order_by('-created')
logger.info(
'Retrieved notifications for user %s with app_name=%s',
self.request.user.id,
app_name,
)
return queryset
except Notification.DoesNotExist as exc:
logger.error(
'Failed to retrieve notifications for user %s: %s',
self.request.user.id,
str(exc)
)
raise
openedx/core/djangoapps/notifications/views.py:238
- Avoid f-strings in logger calls; they eagerly format the message even when the log level is disabled. Prefer parameterized logging (e.g., with
%s) for consistency with the rest of this file and to avoid unnecessary string interpolation.
logger.warning(
f'Invalid app_name provided by user {request.user.id}'
)
openedx/core/djangoapps/notifications/views.py:324
notification_idis taken directly fromrequest.dataand then passed aspkintoget_object_or_404. If a client sends a non-integer value (e.g.,'abc'), Django will raiseValueErrorduring PK coercion, which will currently bubble up as a 500 (and is not caught by the currentexcept). Consider validating/castingnotification_id(or using a serializer) and returning a 400 for invalid types; alternatively includeValueErrorin the handled exceptions with an appropriate 400 response.
notification_id = request.data.get('notification_id')
app_name = request.data.get('app_name')
# Require at least one identifier.
if not notification_id and not app_name:
logger.warning(
'Invalid app_name (%s) or notification_id (%s) from user %s',
app_name,
notification_id,
request.user.id,
)
return Response(
{'error': _('Invalid app_name or notification_id.')},
status=status.HTTP_400_BAD_REQUEST,
)
read_at = datetime.now(UTC)
try:
# If notification_id is provided, it takes precedence
# over app_name.
if notification_id:
notification = get_object_or_404(
Notification,
pk=notification_id,
user=request.user,
)
openedx/core/djangoapps/notifications/views.py:685
- This
except (KeyError, AttributeError, TypeError, ValueError)block logsuser.id, butAttributeErroris explicitly caught—ifuserlacksid, the logger call can raise anotherAttributeErrorand mask the original error. Log with a safe lookup (e.g.,getattr(user, 'id', None)) in both the success and error paths, or avoid catchingAttributeErrorhere.
logger.debug(
'Logged preference update events for user %s',
user.id
)
except (KeyError, AttributeError, TypeError, ValueError) as exc:
logger.error(
'Failed to log preference update events for user %s: %s',
user.id,
str(exc)
)
openedx/core/djangoapps/notifications/views.py:437
- The new error branches here (invalid encrypted token -> 400, user not found -> 404, bad request -> 400) aren’t covered by the existing
UpdatePreferenceFromEncryptedDataViewtests, which currently only exercise success and rate limiting. Please add tests for these new response paths to prevent regressions and to lock in the intended status code mapping.
try:
update_user_preferences_from_patch(username)
logger.info(
"Updated preferences from one-click unsubscribe request"
)
return Response({"result": "success"}, status=status.HTTP_200_OK)
except UsernameDecryptionException:
logger.warning(
"Invalid encrypted username token in one-click unsubscribe request"
)
return Response(
{"error": "Invalid token"},
status=status.HTTP_400_BAD_REQUEST,
)
except Http404:
logger.warning(
"User not found for one-click unsubscribe request"
)
return Response(
{"error": "User not found"},
status=status.HTTP_404_NOT_FOUND,
)
except BadRequest as exc:
logger.warning(
"Bad request in one-click unsubscribe: %s",
str(exc),
)
return Response(
{"error": "Bad request"},
status=status.HTTP_400_BAD_REQUEST,
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (8)
openedx/core/djangoapps/notifications/views.py:437
- There are existing view tests under
openedx/core/djangoapps/notifications/tests/test_views.py, but they currently only cover the success and 429 paths for this one-click unsubscribe view. Since this PR adds new response behaviors for invalid tokens (400) and missing users (404), it would be good to add/extend tests to assert those status codes and payloads so regressions don’t slip in.
try:
update_user_preferences_from_patch(username)
logger.info(
"Updated preferences from one-click unsubscribe request"
)
return Response({"result": "success"}, status=status.HTTP_200_OK)
except UsernameDecryptionException:
logger.warning(
"Invalid encrypted username token in one-click unsubscribe request"
)
return Response(
{"error": "Invalid token"},
status=status.HTTP_400_BAD_REQUEST,
)
except Http404:
logger.warning(
"User not found for one-click unsubscribe request"
)
return Response(
{"error": "User not found"},
status=status.HTTP_404_NOT_FOUND,
)
except BadRequest as exc:
logger.warning(
"Bad request in one-click unsubscribe: %s",
str(exc),
)
return Response(
{"error": "Bad request"},
status=status.HTTP_400_BAD_REQUEST,
)
openedx/core/djangoapps/notifications/views.py:548
NotificationPreference.DoesNotExistwon’t be raised by the operations in thistryblock (filter,bulk_create, dict access). Catching it here is misleading and makes it harder to reason about what failures are actually expected vs. truly exceptional. Consider removingNotificationPreference.DoesNotExistfrom the caught exceptions (or narrowing the try/except to only the statements that can raise the listed exceptions).
except (NotificationPreference.DoesNotExist, KeyError, AttributeError, TypeError) as exc:
logger.error(
openedx/core/djangoapps/notifications/views.py:173
- This query uses
user_id=request.user(and similarlyuser_id=self.request.userabove). Elsewhere in this module the code consistently filters withuser=request.user. For clarity/consistency (and to avoid any ambiguity around_idlookups), preferuser=request.useroruser_id=request.user.idhere.
Notification.objects
.filter(
user_id=request.user,
last_seen__isnull=True,
web=True
)
openedx/core/djangoapps/notifications/views.py:238
- This log line uses an f-string. The rest of the file uses parameterized logging (
logger.info('...', arg1, arg2)), which avoids eager string formatting and is the standard pattern for structured logs. Consider switching this to parameterized logging for consistency.
logger.warning(
f'Invalid app_name provided by user {request.user.id}'
)
openedx/core/djangoapps/notifications/views.py:266
Notification.DoesNotExistwon’t be raised byfilter(...).update(...), so including it in thisexcepttuple is misleading. If the intent is to handle unexpected failures, consider catchingException(or removing the try/except entirely) and usinglogger.exception(...)to capture a traceback before returning a 500.
except (Notification.DoesNotExist, AttributeError, TypeError) as exc:
logger.error(
'Failed to mark notifications seen for user %s: %s',
getattr(request.user, 'id', None),
str(exc),
)
return Response(
openedx/core/djangoapps/notifications/views.py:490
user_preferences_mapis populated from the queryset and then immediately filled with any missing types (and bulk-created). After that,if not user_preferences_map:is effectively unreachable in normal operation and adds confusing/contradictory behavior (auto-create defaults vs. return 404 for “no preferences”). Consider removing this check, or moving it before the missing-types creation if you truly want a 404 when the user has no saved prefs.
# Ensure all notification types present in user's preferences.
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)
logger.info(
'Created %d missing notification preferences for user %s',
len(missing_types),
request.user.id
)
# If no user preferences found, return error response.
if not user_preferences_map:
logger.warning(
'No active notification preferences for user %s',
request.user.id
)
return Response({
'status': 'error',
'message': 'No active notification preferences found.'
}, status=status.HTTP_404_NOT_FOUND)
openedx/core/djangoapps/notifications/views.py:620
NotificationPreference.DoesNotExistis unlikely to be raised here since this method usesfilter(...).update(...)rather thanget(). Removing that exception type (and narrowing the try/except scope) would make the error handling clearer and avoid implying a failure mode that can’t happen.
except (NotificationPreference.DoesNotExist, KeyError, AttributeError, TypeError) as exc:
logger.error(
openedx/core/djangoapps/notifications/views.py:136
Notification.objects.filter(...)/.order_by(...)will never raiseNotification.DoesNotExist, so thisexcept Notification.DoesNotExistblock is effectively dead code and won’t catch real failures. Consider removing it, or if you want to log unexpected failures here, catchExceptionand uselogger.exception(...)before re-raising so you get a traceback in logs.
try:
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
queryset = Notification.objects.filter(
**params
).order_by('-created')
logger.info(
'Retrieved notifications for user %s with app_name=%s',
self.request.user.id,
app_name,
)
return queryset
except Notification.DoesNotExist as exc:
logger.error(
'Failed to retrieve notifications for user %s: %s',
self.request.user.id,
str(exc)
)
raise
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
asharma4-sonata
left a comment
There was a problem hiding this comment.
Please make minimal functional changes to the original code first so it can be reviewed. After that, we can apply auto-formatting. Otherwise, formatting changes make it difficult to see what was modified
|
You can delete this PR, revert the formatting commits, make the functional changes first, and then open a new PR. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Added logger.info() at API entry points - Added logger.warning() for invalid inputs - Log update counts for meaningful operations - No logic or formatting changes - Minimal PR diff
661b74c to
e68097f
Compare
Context: Why these changes were needed (e.g., improve observability of notification API behavior)
Changes Made: Added logging, enhanced error handling, improved API responses, etc.
Testing: Describe how you verified (manual/automated tests, API response checks)
Impact: Mention if it affects any production settings or downstream tasks (if none, say “No production impact”)