Skip to content

Commit

Permalink
feat: receiver for invalidate certificate (#33319)
Browse files Browse the repository at this point in the history
* feat: receiver for invalidate certificate

- consumes event of exam attempt rejected
- initial commit, need to make tests

* temp: moving consumer from signals to handlers.py

- Still need to make this work
- Need to make tests work too

* feat: refactored underlying code to api.py

- tests still need to be tweaked

* fix: commit history

* fix: improve api func name + add source param
  • Loading branch information
ilee2u authored Oct 5, 2023
1 parent db25297 commit 2ae0738
Show file tree
Hide file tree
Showing 4 changed files with 152 additions and 35 deletions.
33 changes: 33 additions & 0 deletions lms/djangoapps/certificates/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
certificate_status_for_student as _certificate_status_for_student,
)
from lms.djangoapps.instructor import access
from lms.djangoapps.utils import _get_key
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -920,3 +922,34 @@ def _has_passed_or_is_allowlisted(course, student, course_grade):
has_passed = course_grade and course_grade.passed

return has_passed or is_allowlisted


def invalidate_certificate(user_id, course_key_or_id, source):
"""
Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this
course run.
This function is called in services.py and handlers.py within the certificates folder. As of now,
The call in services.py occurs when an exam attempt is rejected in the legacy exams backend, edx-proctoring.
The call in handlers.py is occurs when an exam attempt is rejected in the newer exams backend, edx-exams.
"""
course_key = _get_key(course_key_or_id, CourseKey)
if _is_on_certificate_allowlist(user_id, course_key):
log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.')
return False

try:
generated_certificate = GeneratedCertificate.objects.get(
user=user_id,
course_id=course_key
)
generated_certificate.invalidate(source=source)
except ObjectDoesNotExist:
log.warning(
'Invalidation failed because a certificate for user %d in course %s does not exist.',
user_id,
course_key
)
return False

return True
28 changes: 28 additions & 0 deletions lms/djangoapps/certificates/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Handlers for credits
"""
import logging

from django.contrib.auth import get_user_model
from django.dispatch import receiver
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED

from lms.djangoapps.certificates.api import invalidate_certificate

User = get_user_model()

log = logging.getLogger(__name__)


@receiver(EXAM_ATTEMPT_REJECTED)
def handle_exam_attempt_rejected_event(sender, signal, **kwargs):
"""
Consume `EXAM_ATTEMPT_REJECTED` events from the event bus.
Pass the received data to invalidate_certificate in the services.py file in this folder.
"""
event_data = kwargs.get('exam_attempt')
user_data = event_data.student_user
course_key = event_data.course_key

# Note that the course_key is the same as the course_key_or_id, and is being passed in as the course_key param
invalidate_certificate(user_data.id, course_key, source='exam_event')
39 changes: 4 additions & 35 deletions lms/djangoapps/certificates/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,7 @@
Certificate service
"""


import logging

from django.core.exceptions import ObjectDoesNotExist
from opaque_keys.edx.keys import CourseKey

from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.utils import _get_key

log = logging.getLogger(__name__)
from lms.djangoapps.certificates.api import invalidate_certificate


class CertificateService:
Expand All @@ -21,27 +11,6 @@ class CertificateService:
"""

def invalidate_certificate(self, user_id, course_key_or_id):
"""
Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this
course run.
"""
course_key = _get_key(course_key_or_id, CourseKey)
if is_on_certificate_allowlist(user_id, course_key):
log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.')
return False

try:
generated_certificate = GeneratedCertificate.objects.get(
user=user_id,
course_id=course_key
)
generated_certificate.invalidate(source='certificate_service')
except ObjectDoesNotExist:
log.warning(
'Invalidation failed because a certificate for user %d in course %s does not exist.',
user_id,
course_key
)
return False

return True
# The original code for this function was moved to this helper function to be call-able
# By both the legacy and current exams backends (edx-proctoring and edx-exams).
return invalidate_certificate(user_id, course_key_or_id, source='certificate_service')
87 changes: 87 additions & 0 deletions lms/djangoapps/certificates/tests/test_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Unit tests for certificates signals
"""
from datetime import datetime, timezone
from unittest import mock
from uuid import uuid4

from django.test import TestCase
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx_events.data import EventsMetadata
from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData
from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED

from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.certificates.handlers import handle_exam_attempt_rejected_event


class ExamCompletionEventBusTests(TestCase):
"""
Tests completion events from the event bus.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course')
cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection'
cls.usage_key = UsageKey.from_string(cls.subsection_id)
cls.student_user = UserFactory(
username='student_user',
)

@staticmethod
def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None):
""" create ExamAttemptData object for exam based event """
if requesting_user:
requesting_user_data = UserData(
id=requesting_user.id,
is_active=True,
pii=None
)
else:
requesting_user_data = None

return ExamAttemptData(
student_user=UserData(
id=student_user.id,
is_active=True,
pii=UserPersonalData(
username=student_user.username,
email=student_user.email,
),
),
course_key=course_key,
usage_key=usage_key,
requesting_user=requesting_user_data,
exam_type=exam_type,
)

@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('lms.djangoapps.certificates.handlers.invalidate_certificate')
def test_exam_attempt_rejected_event(self, mock_api_function):
"""
Assert that CertificateService api's invalidate_certificate is called upon consuming the event
"""
exam_event_data = self._get_exam_event_data(self.student_user,
self.course_key,
self.usage_key,
exam_type='proctored')
event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED)

event_kwargs = {
'exam_attempt': exam_event_data,
'metadata': event_metadata
}
handle_exam_attempt_rejected_event(None, EXAM_ATTEMPT_REJECTED, **event_kwargs)
mock_api_function.assert_called_once_with(self.student_user.id, self.course_key, source='exam_event')

0 comments on commit 2ae0738

Please sign in to comment.