diff --git a/openassessment/workflow/errors.py b/openassessment/workflow/errors.py index 4643435b7d..488442b676 100644 --- a/openassessment/workflow/errors.py +++ b/openassessment/workflow/errors.py @@ -55,3 +55,23 @@ 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""" + pass + + +class ExceptionWithContext(Exception): + """An exception with optional context dict to be supplied in serialized result""" + + def __init__(self, context=None): + super().__init__(self) + self.context = context + + +class XBlockInternalError(ExceptionWithContext): + """Errors from XBlock handlers""" + + def __str__(self): + return str(self.context) \ No newline at end of file diff --git a/openassessment/workflow/models.py b/openassessment/workflow/models.py index db6c4e6e01..782ee3d739 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,11 @@ 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) + send_grade_assigned_notification(self.item_id, submission_dict['student_item']['student_id'], score) + return if self.status == self.STATUS.done: return @@ -443,6 +449,8 @@ 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) + 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..6a344480a1 100644 --- a/openassessment/xblock/test/test_notifications.py +++ b/openassessment/xblock/test/test_notifications.py @@ -4,7 +4,10 @@ import unittest from unittest.mock import patch, MagicMock -from openassessment.xblock.utils.notifications import send_staff_notification +from opaque_keys import InvalidKeyError + +from openassessment.xblock.utils.notifications import send_staff_notification, send_grade_assigned_notification +from openassessment.workflow.errors import ItemNotFoundError, XBlockInternalError class TestSendStaffNotification(unittest.TestCase): @@ -64,3 +67,77 @@ 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") + + with patch('django.conf.settings.LMS_ROOT_URL', 'http://localhost'): + 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") + self.assertEqual(notification_data.content_url, + 'http://localhost/courses/course-v1:TestX+TST+TST/jump_to/block-v1:TestX+TST+TST+type@problem+block@ora') + + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.UsageKey.from_string', side_effect=InvalidKeyError) + def test_invalid_key_error(self, mock_from_string, mock_logger_error): + """ + Test that InvalidKeyError is logged correctly. + """ + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + mock_logger_error.assert_called_once_with(f"Bad ORA location provided: {self.usage_id}") + + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.modulestore.get_item', side_effect=ItemNotFoundError) + def test_item_not_found_error(self, mock_get_item, mock_logger_error): + """ + Test that ItemNotFoundError is logged correctly. + """ + with patch('openassessment.xblock.utils.notifications.UsageKey.from_string', + return_value=MagicMock(course_key='course-v1:TestX+TST+TST')): + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + mock_logger_error.assert_called_once_with(f"Bad ORA location provided: {self.usage_id}") + + @patch('openassessment.xblock.utils.notifications.logger.error') + @patch('openassessment.xblock.utils.notifications.modulestore.get_item', + side_effect=XBlockInternalError("XBlock error")) + def test_xblock_internal_error(self, mock_get_item, mock_logger_error): + """ + Test that XBlockInternalError is logged correctly. + """ + with patch('openassessment.xblock.utils.notifications.UsageKey.from_string', + return_value=MagicMock(course_key='course-v1:TestX+TST+TST')): + send_grade_assigned_notification(self.usage_id, self.ora_user_anonymized_id, self.score) + mock_logger_error.assert_called_once_with("XBlock error") diff --git a/openassessment/xblock/utils/notifications.py b/openassessment/xblock/utils/notifications.py index 756699f576..f347964dfa 100644 --- a/openassessment/xblock/utils/notifications.py +++ b/openassessment/xblock/utils/notifications.py @@ -3,12 +3,18 @@ """ 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 openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED, USER_NOTIFICATION_REQUESTED +from openedx_events.learning.data import CourseNotificationData, UserNotificationData from openassessment.runtime_imports.functions import modulestore +from django.contrib.auth import get_user_model +from openassessment.workflow.errors import ItemNotFoundError, XBlockInternalError logger = logging.getLogger(__name__) +User = get_user_model() def send_staff_notification(course_id, problem_id, ora_name): @@ -34,3 +40,43 @@ 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 + + try: + # Get ORA user + user_name_list = map_to_username([ora_user_anonymized_id]) + 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)}/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}") + + # Issues with the XBlock handlers + except XBlockInternalError as ex: + logger.error(ex)