Skip to content

Commit

Permalink
feat: handle exam events for credit state (openedx#33402)
Browse files Browse the repository at this point in the history
Handles exam related events from the event bus that impact credit state. Functionally this will behave the same as the existing CreditService which is called by edx-proctoring. This enables edx-exams (an IDA) to have the same behavior.
  • Loading branch information
zacharis278 authored Oct 3, 2023
1 parent 96992e7 commit 835a81c
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 15 deletions.
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ def update_special_exams_and_publish(course_key_str):
"""
from cms.djangoapps.contentstore.exams import register_exams
from cms.djangoapps.contentstore.proctoring import register_special_exams as register_exams_legacy
from openedx.core.djangoapps.credit.signals import on_course_publish
from openedx.core.djangoapps.credit.signals.handlers import on_course_publish

course_key = CourseKey.from_string(course_key_str)
LOGGER.info('Attempting to register exams for course %s', course_key_str)
Expand Down
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def test_exam_service_enabled_success(self, _mock_register_exams_proctoring, _mo
@mock.patch('cms.djangoapps.contentstore.proctoring.register_special_exams')
def test_register_exams_failure(self, _mock_register_exams_proctoring, _mock_register_exams_service):
""" credit requirements update signal fires even if exam registration fails """
with mock.patch('openedx.core.djangoapps.credit.signals.on_course_publish') as course_publish:
with mock.patch('openedx.core.djangoapps.credit.signals.handlers.on_course_publish') as course_publish:
_mock_register_exams_proctoring.side_effect = Exception('boom!')
update_special_exams_and_publish(str(self.course.id))
course_publish.assert_called()
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from cms.djangoapps.contentstore.utils import reverse_course_url
from openedx.core.djangoapps.credit.api import get_credit_requirements
from openedx.core.djangoapps.credit.models import CreditCourse
from openedx.core.djangoapps.credit.signals import on_course_publish
from openedx.core.djangoapps.credit.signals.handlers import on_course_publish
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order


Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/credit/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class CreditConfig(AppConfig):
name = 'openedx.core.djangoapps.credit'

def ready(self):
from . import signals # lint-amnesty, pylint: disable=unused-import
from .signals import handlers # lint-amnesty, pylint: disable=unused-import
if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
from .services import CreditService
set_runtime_service('credit', CreditService())
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,78 @@

import logging

from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from django.dispatch import receiver
from django.utils import timezone
from opaque_keys.edx.keys import CourseKey

from openedx_events.learning.signals import (
EXAM_ATTEMPT_ERRORED,
EXAM_ATTEMPT_REJECTED,
EXAM_ATTEMPT_RESET,
EXAM_ATTEMPT_SUBMITTED,
EXAM_ATTEMPT_VERIFIED
)

from openedx.core.djangoapps.credit.api.eligibility import (
is_credit_course,
remove_credit_requirement_status,
set_credit_requirement_status
)
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED

User = get_user_model()

log = logging.getLogger(__name__)


def handle_exam_event(signal, event_data, credit_status=None):
"""
update credit requirements based on exam event
"""
user_data = event_data.student_user
course_key = event_data.course_key
usage_key = event_data.usage_key
request_namespace = 'exam'

# quick exit, if course is not credit enabled
if not is_credit_course(course_key):
return

# log any activity to the credit requirements table
log.info(
f'Recieved {signal} signal, changing credit requirement for '
f'user_id={user_data.id}, course_key_or_id={course_key} '
f'content_id={usage_key}'
)

try:
user = User.objects.get(id=user_data.id)
except ObjectDoesNotExist:
log.error(
'Error occurred while handling exam event for '
f'{user_data.id} and content_id {usage_key}. '
'User does not exist!'
)
return

if signal == EXAM_ATTEMPT_RESET:
remove_credit_requirement_status(
user.username,
course_key,
request_namespace,
str(usage_key),
)
else:
set_credit_requirement_status(
user.username,
course_key,
request_namespace,
str(usage_key),
credit_status
)


def on_course_publish(course_key):
"""
Will receive a delegated 'course_published' signal from cms/djangoapps/contentstore/signals.py
Expand Down Expand Up @@ -94,3 +157,48 @@ def listen_for_grade_calculation(sender, user, course_grade, course_key, deadlin
api.set_credit_requirement_status(
user, course_id, 'grade', 'grade', status=status, reason=reason
)


@receiver(EXAM_ATTEMPT_RESET)
def listen_for_exam_reset(sender, signal, **kwargs):
"""
exam reset event from the event bus
"""
event_data = kwargs.get('exam_attempt')
handle_exam_event(signal, event_data)


@receiver(EXAM_ATTEMPT_SUBMITTED)
def listen_for_exam_submitted(sender, signal, **kwargs):
"""
exam submission event from the event bus
"""
event_data = kwargs.get('exam_attempt')
handle_exam_event(signal, event_data, credit_status='submitted')


@receiver(EXAM_ATTEMPT_VERIFIED)
def listen_for_exam_verified(sender, signal, **kwargs):
"""
exam verification event from the event bus
"""
event_data = kwargs.get('exam_attempt')
handle_exam_event(signal, event_data, credit_status='satisfied')


@receiver(EXAM_ATTEMPT_REJECTED)
def listen_for_exam_rejected(sender, signal, **kwargs):
"""
exam rejection event from the event bus
"""
event_data = kwargs.get('exam_attempt')
handle_exam_event(signal, event_data, credit_status='failed')


@receiver(EXAM_ATTEMPT_ERRORED)
def listen_for_exam_errored(sender, signal, **kwargs):
"""
exam error event from the event bus
"""
event_data = kwargs.get('exam_attempt')
handle_exam_event(signal, event_data, credit_status='failed')
179 changes: 170 additions & 9 deletions openedx/core/djangoapps/credit/tests/test_signals.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
"""
Tests for minimum grade requirement status
Tests for minimum grade and credit requirement status
"""

from datetime import datetime, timedelta, timezone
from unittest import mock
from uuid import uuid4

from datetime import datetime, timedelta

from unittest.mock import MagicMock
import ddt
import pytz
from django.test.client import RequestFactory
from opaque_keys.edx.keys import UsageKey
from openedx_events.data import EventsMetadata
from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData
from openedx_events.learning.signals import (
EXAM_ATTEMPT_ERRORED,
EXAM_ATTEMPT_REJECTED,
EXAM_ATTEMPT_RESET,
EXAM_ATTEMPT_SUBMITTED,
EXAM_ATTEMPT_VERIFIED
)

from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.credit.api import get_credit_requirement_status, set_credit_requirements
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider
from openedx.core.djangoapps.credit.signals import listen_for_grade_calculation
from openedx.core.djangoapps.credit.signals.handlers import (
listen_for_exam_errored,
listen_for_exam_rejected,
listen_for_exam_reset,
listen_for_exam_submitted,
listen_for_exam_verified,
listen_for_grade_calculation
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import \
ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order


Expand Down Expand Up @@ -76,7 +94,7 @@ def setUp(self):

def assert_requirement_status(self, grade, due_date, expected_status):
""" Verify the user's credit requirement status is as expected after simulating a grading calculation. """
course_grade = MagicMock()
course_grade = mock.MagicMock()
course_grade.percent = grade
listen_for_grade_calculation(None, self.user, course_grade, self.course.id, due_date)
req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade')
Expand Down Expand Up @@ -129,3 +147,146 @@ def test_requirement_failed_for_non_verified_enrollment(self, mode):
"""Test with valid grades submitted before deadline with non-verified enrollment."""
self.enrollment.update_enrollment(mode, True)
self.assert_requirement_status(0.8, self.VALID_DUE_DATE, None)


@skip_unless_lms
@ddt.ddt
class TestExamEvents(ModuleStoreTestCase):
"""
Test exam events
"""
HANDLERS = {
EXAM_ATTEMPT_ERRORED: listen_for_exam_errored,
EXAM_ATTEMPT_REJECTED: listen_for_exam_rejected,
EXAM_ATTEMPT_RESET: listen_for_exam_reset,
EXAM_ATTEMPT_SUBMITTED: listen_for_exam_submitted,
EXAM_ATTEMPT_VERIFIED: listen_for_exam_verified,
}

def setUp(self):
super().setUp()
self.course = CourseFactory.create(
org='TestX', number='999', display_name='Test Course'
)
self.subsection_key = UsageKey.from_string('block-v1:edX+TestX+Test_Course+type@sequential+block@subsection')

self.user = UserFactory()

# Enable the course for credit
CreditCourse.objects.create(
course_key=self.course.id,
enabled=True,
)

@staticmethod
def _get_exam_event_data(user, course, usage_key):
""" create ExamAttemptData object for user """
return ExamAttemptData(
student_user=UserData(
id=user.id,
is_active=True,
pii=UserPersonalData(
username=user.username,
email=user.email,
),
),
course_key=course.id,
usage_key=usage_key,
exam_type='timed',
requesting_user=None,
)

@staticmethod
def _get_exam_event_metadata(event_signal):
""" create metadata object for event """
return EventsMetadata(
event_type=event_signal.event_type,
id=uuid4(),
minorversion=0,
source='openedx/lms/web',
sourcehost='lms.test',
time=datetime.now(timezone.utc)
)

@mock.patch('openedx.core.djangoapps.credit.signals.handlers.remove_credit_requirement_status', autospec=True)
def test_exam_reset(self, mock_remove_credit_status):
"""
Test exam reset event
"""
event_data = self._get_exam_event_data(self.user, self.course, self.subsection_key)
event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET)

listen_for_exam_reset(None, EXAM_ATTEMPT_RESET, event_metadata=event_metadata, exam_attempt=event_data)

mock_remove_credit_status.assert_called_once_with(
self.user.username, self.course.id, 'exam', str(self.subsection_key)
)

@ddt.data(
(EXAM_ATTEMPT_ERRORED, 'failed'),
(EXAM_ATTEMPT_REJECTED, 'failed'),
(EXAM_ATTEMPT_VERIFIED, 'satisfied'),
(EXAM_ATTEMPT_SUBMITTED, 'submitted'),
)
@ddt.unpack
@mock.patch('openedx.core.djangoapps.credit.signals.handlers.set_credit_requirement_status', autospec=True)
def test_exam_update_event(self, event_signal, expected_status, mock_set_credit_status):
"""
Test exam events that update credit status
"""
event_data = self._get_exam_event_data(self.user, self.course, self.subsection_key)
event_metadata = self._get_exam_event_metadata(event_signal)

handler = self.HANDLERS.get(event_signal)
handler(None, event_signal, event_metadata=event_metadata, exam_attempt=event_data)

mock_set_credit_status.assert_called_once_with(
self.user.username, self.course.id, 'exam', str(self.subsection_key), status=expected_status
)

@ddt.data(
EXAM_ATTEMPT_RESET,
EXAM_ATTEMPT_REJECTED,
EXAM_ATTEMPT_ERRORED,
EXAM_ATTEMPT_VERIFIED,
EXAM_ATTEMPT_SUBMITTED,
)
def test_exam_event_bad_user(self, event_signal):
"""
Test exam event with a user that does not exist in the LMS
"""
self.user.id = 999 # don't save to db so user doesn't exist
event_data = self._get_exam_event_data(self.user, self.course, self.subsection_key)
event_metadata = self._get_exam_event_metadata(event_signal)
handler = self.HANDLERS.get(event_signal)

with mock.patch('openedx.core.djangoapps.credit.signals.handlers.log.error') as mock_log:
handler(None, event_signal, event_metadata=event_metadata, exam_attempt=event_data)
mock_log.assert_called_once_with(
'Error occurred while handling exam event for '
f'{self.user.id} and content_id {self.subsection_key}. '
'User does not exist!'
)

@ddt.data(
EXAM_ATTEMPT_RESET,
EXAM_ATTEMPT_REJECTED,
EXAM_ATTEMPT_ERRORED,
EXAM_ATTEMPT_VERIFIED,
EXAM_ATTEMPT_SUBMITTED,
)
@mock.patch('openedx.core.djangoapps.credit.signals.handlers.remove_credit_requirement_status', autospec=True)
@mock.patch('openedx.core.djangoapps.credit.signals.handlers.set_credit_requirement_status', autospec=True)
def test_exam_event_non_credit_course(self, event_signal, mock_remove_credit_status, mock_set_credit_status):
"""
Credit credit logic should not run on non-credit courses
"""
non_credit_course = CourseFactory.create()
event_data = self._get_exam_event_data(self.user, non_credit_course, self.subsection_key)
event_metadata = self._get_exam_event_metadata(event_signal)
handler = self.HANDLERS.get(event_signal)

handler(None, event_signal, event_metadata=event_metadata, exam_attempt=event_data)

mock_remove_credit_status.assert_not_called()
mock_set_credit_status.assert_not_called()
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/credit/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from openedx.core.djangoapps.credit.api import get_credit_requirements
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements
from openedx.core.djangoapps.credit.models import CreditCourse
from openedx.core.djangoapps.credit.signals import on_course_publish
from openedx.core.djangoapps.credit.signals.handlers import on_course_publish
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order

Expand Down

0 comments on commit 835a81c

Please sign in to comment.