chore: resolve conflicts
This commit is contained in:
@@ -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())
|
||||
|
||||
0
openedx/core/djangoapps/credit/signals/__init__.py
Normal file
0
openedx/core/djangoapps/credit/signals/__init__.py
Normal file
@@ -5,15 +5,78 @@ This file contains receivers of course publication signals.
|
||||
|
||||
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
|
||||
@@ -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')
|
||||
@@ -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 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.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 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.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 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
|
||||
|
||||
|
||||
@@ -76,7 +94,7 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
|
||||
|
||||
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')
|
||||
@@ -129,3 +147,146 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
|
||||
"""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()
|
||||
|
||||
@@ -11,7 +11,7 @@ from edx_proctoring.api import create_exam
|
||||
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
|
||||
|
||||
|
||||
@@ -1066,7 +1066,9 @@ class EnrollmentAllowedView(APIView):
|
||||
|
||||
**Example Request**
|
||||
|
||||
POST /api/enrollment/v1/enrollment_allowed
|
||||
POST /api/enrollment/v1/enrollment_allowed/
|
||||
|
||||
Note: The URL for this request must finish with /
|
||||
|
||||
Example request data:
|
||||
```
|
||||
@@ -1086,7 +1088,6 @@ class EnrollmentAllowedView(APIView):
|
||||
- `auto_enroll` (optional, bool: default=false, _body_)
|
||||
|
||||
**Responses**
|
||||
- 200: Success, enrollment allowed found.
|
||||
- 400: Bad request, missing data.
|
||||
- 403: Forbidden, you need to be staff.
|
||||
- 409: Conflict, enrollment allowed already exists.
|
||||
@@ -1122,7 +1123,9 @@ class EnrollmentAllowedView(APIView):
|
||||
|
||||
**Example Request**
|
||||
|
||||
DELETE /api/enrollment/v1/enrollment_allowed
|
||||
DELETE /api/enrollment/v1/enrollment_allowed/
|
||||
|
||||
Note: The URL for this request must finish with /
|
||||
|
||||
Example request data:
|
||||
```
|
||||
|
||||
@@ -344,7 +344,7 @@ def _track_user_login(user, request):
|
||||
segment.identify(
|
||||
user.id,
|
||||
{
|
||||
'email': request.POST.get('email'),
|
||||
'email': user.email,
|
||||
'username': user.username
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user