diff --git a/openassessment/__init__.py b/openassessment/__init__.py index d2ecd58513..8a95f62793 100644 --- a/openassessment/__init__.py +++ b/openassessment/__init__.py @@ -2,4 +2,4 @@ Initialization Information for Open Assessment Module """ -__version__ = '6.11.3' +__version__ = '6.12.0' diff --git a/openassessment/workflow/errors.py b/openassessment/workflow/errors.py index 4643435b7d..94637a041e 100644 --- a/openassessment/workflow/errors.py +++ b/openassessment/workflow/errors.py @@ -55,3 +55,7 @@ def __init__(self, assessment_name, api_path): assessment_name, api_path ) super().__init__(msg) + + +class ItemNotFoundError(Exception): + """An item was not found in the modulestore""" diff --git a/openassessment/workflow/models.py b/openassessment/workflow/models.py index db6c4e6e01..20511acb3e 100644 --- a/openassessment/workflow/models.py +++ b/openassessment/workflow/models.py @@ -26,6 +26,7 @@ from submissions import api as sub_api, team_api as sub_team_api from openassessment.assessment.errors.base import AssessmentError from openassessment.assessment.signals import assessment_complete_signal +from openassessment.xblock.utils.notifications import send_grade_assigned_notification from .errors import AssessmentApiLoadError, AssessmentWorkflowError, AssessmentWorkflowInternalError @@ -376,6 +377,13 @@ def update_from_assessments( if override_submitter_requirements: step.submitter_completed_at = common_now step.save() + if self.status == self.STATUS.done: + score = self.get_score(assessment_requirements, course_settings, step_for_name) + submission_dict = sub_api.get_submission_and_student(self.submission_uuid) + if submission_dict['student_item']['student_id']: + send_grade_assigned_notification(self.item_id, + submission_dict['student_item']['student_id'], score) + return if self.status == self.STATUS.done: return @@ -443,6 +451,9 @@ def update_from_assessments( if score.get("staff_id") is None: self.set_score(score) new_status = self.STATUS.done + submission_dict = sub_api.get_submission_and_student(self.submission_uuid) + if submission_dict['student_item']['student_id']: + send_grade_assigned_notification(self.item_id, submission_dict['student_item']['student_id'], score) # Finally save our changes if the status has changed if self.status != new_status: diff --git a/openassessment/xblock/test/test_notifications.py b/openassessment/xblock/test/test_notifications.py index f0b3786d4b..4369c6bb4b 100644 --- a/openassessment/xblock/test/test_notifications.py +++ b/openassessment/xblock/test/test_notifications.py @@ -4,7 +4,14 @@ import unittest from unittest.mock import patch, MagicMock -from openassessment.xblock.utils.notifications import send_staff_notification +from opaque_keys import InvalidKeyError + +from django.contrib.auth import get_user_model +from django.core.exceptions import FieldError +from openassessment.xblock.utils.notifications import send_staff_notification, send_grade_assigned_notification +from openassessment.workflow.errors import ItemNotFoundError + +User = get_user_model() class TestSendStaffNotification(unittest.TestCase): @@ -64,3 +71,122 @@ def test_send_staff_notification_error_logging(self, mock_send_event, mock_logge # Assertions mock_logger_error.assert_called_once_with(f"Error while sending ora staff notification: {mock_exception}") + + +class TestSendGradeAssignedNotification(unittest.TestCase): + + def setUp(self): + self.usage_id = 'block-v1:TestX+TST+TST+type@problem+block@ora' + self.ora_user_anonymized_id = 'anon_user_1' + self.score = { + 'points_earned': 10, + 'points_possible': 20, + } + + @patch('openassessment.xblock.utils.notifications.User.objects.get') + @patch('openassessment.xblock.utils.notifications.UsageKey.from_string') + @patch('openassessment.xblock.utils.notifications.modulestore') + @patch('openassessment.xblock.utils.notifications.USER_NOTIFICATION_REQUESTED.send_event') + @patch('openassessment.data.map_anonymized_ids_to_usernames') + def test_send_notification_success(self, mock_map_to_username, mock_send_event, mock_modulestore, mock_from_string, + mock_get_user): + """ + Test that the notification is sent when all data is valid. + """ + mock_map_to_username.return_value = {self.ora_user_anonymized_id: 'student1'} + mock_get_user.return_value = MagicMock(id=2) + mock_from_string.return_value = MagicMock(course_key='course-v1:TestX+TST+TST') + mock_modulestore.return_value.get_item.return_value = MagicMock(display_name="ORA Assignment") + mock_modulestore.return_value.get_course.return_value = MagicMock(display_name="Test Course") + + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + + mock_send_event.assert_called_once() + args, kwargs = mock_send_event.call_args + notification_data = kwargs['notification_data'] + self.assertEqual(notification_data.user_ids, [2]) + self.assertEqual(notification_data.context['ora_name'], 'ORA Assignment') + self.assertEqual(notification_data.context['course_name'], 'Test Course') + self.assertEqual(notification_data.context['points_earned'], 10) + self.assertEqual(notification_data.context['points_possible'], 20) + self.assertEqual(notification_data.notification_type, "ora_grade_assigned") + + @patch('openassessment.xblock.utils.notifications.User.objects.get') + @patch('openassessment.xblock.utils.notifications.UsageKey.from_string') + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.USER_NOTIFICATION_REQUESTED.send_event') + @patch('openassessment.data.map_anonymized_ids_to_usernames') + def test_invalid_key_error_logging(self, mock_map_to_username, mock_send_event, mock_logger_error, + mock_from_string, mock_get_user): + """ + Test error logging when InvalidKeyError is raised. + """ + mock_map_to_username.return_value = {self.ora_user_anonymized_id: 'student1'} + mock_get_user.return_value = MagicMock(id=2) + mock_from_string.return_value = MagicMock(course_key='course-v1:TestX+TST+TST') + mock_exception = InvalidKeyError('Invalid key error', 'some_serialized_data') + + # Force the exception + with patch('openassessment.xblock.utils.notifications.UsageKey.from_string', side_effect=mock_exception): + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + + # Assertions + mock_logger_error.assert_called_once_with(f"Bad ORA location provided: {self.usage_id}") + mock_send_event.assert_not_called() + + @patch('openassessment.xblock.utils.notifications.User.objects.get') + @patch('openassessment.xblock.utils.notifications.UsageKey.from_string') + @patch('openassessment.xblock.utils.notifications.modulestore') + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.USER_NOTIFICATION_REQUESTED.send_event') + @patch('openassessment.data.map_anonymized_ids_to_usernames') + def test_item_not_found_error_logging(self, mock_map_to_username, mock_send_event, mock_logger_error, + mock_modulestore, mock_from_string, mock_get_user): + """ + Test error logging when ItemNotFoundError is raised. + """ + mock_map_to_username.return_value = {self.ora_user_anonymized_id: 'student1'} + mock_get_user.return_value = MagicMock(id=2) + mock_from_string.return_value = MagicMock(course_key='course-v1:TestX+TST+TST') + mock_exception = ItemNotFoundError('Item not found') + mock_modulestore.return_value.get_item.side_effect = mock_exception + + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + + # Assertions + mock_logger_error.assert_called_once_with(f"Bad ORA location provided: {self.usage_id}") + mock_send_event.assert_not_called() + + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.USER_NOTIFICATION_REQUESTED.send_event') + @patch('openassessment.data.map_anonymized_ids_to_usernames') + @patch('openassessment.xblock.utils.notifications.User.objects.get') + def test_user_does_not_exist_error_logging(self, mock_get_user, mock_map_to_username, mock_send_event, + mock_logger_error): + """ + Test error logging when User.DoesNotExist is raised. + """ + mock_map_to_username.return_value = {self.ora_user_anonymized_id: 'non_existent_user'} + mock_get_user.side_effect = User.DoesNotExist('User does not exist') + + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + + # Assertions + mock_logger_error.assert_called_once_with('Unknown User Error: User does not exist') + mock_send_event.assert_not_called() + + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.USER_NOTIFICATION_REQUESTED.send_event') + @patch('openassessment.data.map_anonymized_ids_to_usernames') + def test_getting_user_name_error_logging(self, mock_map_to_username, mock_send_event, mock_logger_error): + """ + Test error logging when FieldError is raised. + """ + mock_map_to_username.side_effect = FieldError('FieldError: Cannot resolve keyword \'anonymoususerid\'') + + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + + # Assertions + mock_logger_error.assert_called_once_with('Error while getting user name for the user id anon_user_1: ' + 'FieldError: Cannot resolve keyword \'anonymoususerid\'') + mock_send_event.assert_not_called() diff --git a/openassessment/xblock/utils/notifications.py b/openassessment/xblock/utils/notifications.py index 756699f576..6270d83c30 100644 --- a/openassessment/xblock/utils/notifications.py +++ b/openassessment/xblock/utils/notifications.py @@ -3,12 +3,19 @@ """ import logging +from opaque_keys.edx.keys import UsageKey, CourseKey +from opaque_keys import InvalidKeyError + from django.conf import settings -from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED -from openedx_events.learning.data import CourseNotificationData +from django.core.exceptions import FieldError +from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED, USER_NOTIFICATION_REQUESTED +from openedx_events.learning.data import CourseNotificationData, UserNotificationData +from django.contrib.auth import get_user_model from openassessment.runtime_imports.functions import modulestore +from openassessment.workflow.errors import ItemNotFoundError logger = logging.getLogger(__name__) +User = get_user_model() def send_staff_notification(course_id, problem_id, ora_name): @@ -34,3 +41,52 @@ def send_staff_notification(course_id, problem_id, ora_name): COURSE_NOTIFICATION_REQUESTED.send_event(course_notification_data=notification_data) except Exception as e: logger.error(f"Error while sending ora staff notification: {e}") + + +def send_grade_assigned_notification(usage_id, ora_user_anonymized_id, score): + """ + Send a user notification for a course for a new grade being assigned + """ + from openassessment.data import map_anonymized_ids_to_usernames as map_to_username + + user_name_list = [] + try: + # Get ORA user name + user_name_list = map_to_username([ora_user_anonymized_id]) + except FieldError as exc: + logger.error(f'Error while getting user name for the user id {ora_user_anonymized_id}: {exc}') + + try: + if (not user_name_list) or (not user_name_list[ora_user_anonymized_id]): + return + # Get ORA user + ora_user = User.objects.get(username=user_name_list[ora_user_anonymized_id]) + # Get ORA block + ora_usage_key = UsageKey.from_string(usage_id) + ora_metadata = modulestore().get_item(ora_usage_key) + # Get course metadata + course_id = CourseKey.from_string(str(ora_usage_key.course_key)) + course_metadata = modulestore().get_course(course_id) + notification_data = UserNotificationData( + user_ids=[ora_user.id], + context={ + 'ora_name': ora_metadata.display_name, + 'course_name': course_metadata.display_name, + 'points_earned': score['points_earned'], + 'points_possible': score['points_possible'], + }, + notification_type="ora_grade_assigned", + content_url=f"{getattr(settings, 'LMS_ROOT_URL', '')}/courses/{str(course_id)}" + f"/jump_to/{str(ora_usage_key)}", + app_name="grading", + course_key=course_id, + ) + USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data) + + # Catch bad ORA location + except (InvalidKeyError, ItemNotFoundError): + logger.error(f"Bad ORA location provided: {usage_id}") + + # Error with getting User + except User.DoesNotExist as exc: + logger.error(f'Unknown User Error: {exc}') diff --git a/package.json b/package.json index a56b9b8565..fa93d08a6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "edx-ora2", - "version": "6.11.3", + "version": "6.12.0", "repository": "https://github.com/openedx/edx-ora2.git", "dependencies": { "@edx/frontend-build": "8.0.6",