Example #1
0
    def get(self, request, attempt_id):
        """
        HTTP GET Handler. Returns the status of the exam attempt.
        """
        attempt = get_exam_attempt_by_id(attempt_id)

        if not attempt:
            err_msg = (u'Attempted to access attempt_id {attempt_id} but '
                       u'it does not exist.'.format(attempt_id=attempt_id))
            raise StudentExamAttemptDoesNotExistsException(err_msg)
        # make sure the the attempt belongs to the calling user_id
        if attempt['user']['id'] != request.user.id:
            err_msg = (u'Attempted to access attempt_id {attempt_id} but '
                       u'does not have access to it.'.format(
                           attempt_id=attempt_id))
            raise ProctoredExamPermissionDenied(err_msg)

        # add in the computed time remaining as a helper
        time_remaining_seconds = get_time_remaining_for_attempt(attempt)

        attempt['time_remaining_seconds'] = time_remaining_seconds

        accessibility_time_string = _(
            u'you have {remaining_time} remaining').format(
                remaining_time=humanized_time(
                    int(round(time_remaining_seconds / 60.0, 0))))

        # special case if we are less than a minute, since we don't produce
        # text translations of granularity at the seconds range
        if time_remaining_seconds < 60:
            accessibility_time_string = _(
                u'you have less than a minute remaining')

        attempt['accessibility_time_string'] = accessibility_time_string
        return Response(attempt)
Example #2
0
    def delete(self, request, attempt_id):  # pylint: disable=unused-argument
        """
        HTTP DELETE handler. Removes an exam attempt.
        """
        try:
            attempt = get_exam_attempt_by_id(attempt_id)

            if not attempt:
                err_msg = (
                    'Attempted to access attempt_id {attempt_id} but '
                    'it does not exist.'.format(
                        attempt_id=attempt_id
                    )
                )
                raise StudentExamAttemptDoesNotExistsException(err_msg)

            remove_exam_attempt(attempt_id, request.user)
            return Response()

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"detail": str(ex)}
            )
Example #3
0
def remove_exam_attempt(attempt_id):
    """
    Removes an exam attempt given the attempt id.
    """

    existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(
        attempt_id)
    if not existing_attempt:
        err_msg = ('Cannot remove attempt for attempt_id = {attempt_id} '
                   'because it does not exist!').format(attempt_id=attempt_id)

        raise StudentExamAttemptDoesNotExistsException(err_msg)

    username = existing_attempt.user.username
    user_id = existing_attempt.user.id
    course_id = existing_attempt.proctored_exam.course_id
    content_id = existing_attempt.proctored_exam.content_id
    to_status = existing_attempt.status

    log_msg = ('{attempt_code} - {username} ({email}) '
               'Removing exam attempt {attempt_id}'.format(
                   attempt_id=attempt_id,
                   attempt_code=existing_attempt.attempt_code,
                   username=username,
                   email=existing_attempt.user.email))
    log.info(log_msg)

    existing_attempt.delete_exam_attempt()
    instructor_service = get_runtime_service('instructor')

    if instructor_service:
        instructor_service.delete_student_attempt(username, course_id,
                                                  content_id)
Example #4
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        attempt = get_exam_attempt_by_id(attempt_id)

        if not attempt:
            err_msg = (u'Attempted to access attempt_id {attempt_id} but '
                       u'it does not exist.'.format(attempt_id=attempt_id))
            raise StudentExamAttemptDoesNotExistsException(err_msg)

        # make sure the the attempt belongs to the calling user_id
        if attempt['user']['id'] != request.user.id:
            err_msg = (u'Attempted to access attempt_id {attempt_id} but '
                       u'does not have access to it.'.format(
                           attempt_id=attempt_id))
            raise ProctoredExamPermissionDenied(err_msg)

        action = request.data.get('action')

        if action == 'stop':
            exam_attempt_id = stop_exam_attempt(
                exam_id=attempt['proctored_exam']['id'],
                user_id=request.user.id)
        elif action == 'start':
            exam_attempt_id = start_exam_attempt(
                exam_id=attempt['proctored_exam']['id'],
                user_id=request.user.id)
        elif action == 'submit':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'], request.user.id,
                ProctoredExamStudentAttemptStatus.submitted)
        elif action == 'click_download_software':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'], request.user.id,
                ProctoredExamStudentAttemptStatus.download_software_clicked)
        elif action == 'error':
            backend = attempt['proctored_exam']['backend']
            waffle_name = PING_FAILURE_PASSTHROUGH_TEMPLATE.format(backend)
            should_block_user = not (
                backend and waffle.switch_is_active(waffle_name)) and (
                    not attempt['status']
                    == ProctoredExamStudentAttemptStatus.submitted)
            if should_block_user:
                exam_attempt_id = update_attempt_status(
                    attempt['proctored_exam']['id'], request.user.id,
                    ProctoredExamStudentAttemptStatus.error)
            else:
                exam_attempt_id = False
            LOG.warning(
                u'Browser JS reported problem with proctoring desktop '
                u'application. Did block user: %s, for attempt: %s',
                should_block_user, attempt['id'])
        elif action == 'decline':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'], request.user.id,
                ProctoredExamStudentAttemptStatus.declined)
        data = {"exam_attempt_id": exam_attempt_id}
        return Response(data)
Example #5
0
 def post(self, request, external_id):
     """
     Called when 3rd party proctoring service has finished its review of
     an attempt.
     """
     attempt = get_exam_attempt_by_external_id(external_id)
     if attempt is None:
         raise StudentExamAttemptDoesNotExistsException('not found')
     self.make_review(attempt, request.data)
     return Response(data='OK')
Example #6
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        try:
            attempt_id = int(attempt_id)
            attempt = get_exam_attempt_by_id(attempt_id)
        except:
            attempt = get_exam_attempt_by_code(attempt_id)

        try:
            if not attempt:
                err_msg = ('Attempted to access attempt_id {attempt_id} but '
                           'it does not exist.'.format(attempt_id=attempt_id))
                raise StudentExamAttemptDoesNotExistsException(err_msg)

            # make sure the the attempt belongs to the calling user_id
            if attempt['user']['id'] != request.user.id:
                err_msg = ('Attempted to access attempt_id {attempt_id} but '
                           'does not have access to it.'.format(
                               attempt_id=attempt_id))
                raise ProctoredExamPermissionDenied(err_msg)

            action = request.data.get('action')

            if action == 'stop':
                exam_attempt_id = stop_exam_attempt(
                    exam_id=attempt['proctored_exam']['id'],
                    user_id=request.user.id)
            elif action == 'start':
                exam_attempt_id = start_exam_attempt(
                    exam_id=attempt['proctored_exam']['id'],
                    user_id=request.user.id)
            elif action == 'submit':
                exam_attempt_id = update_attempt_status(
                    attempt['proctored_exam']['id'], request.user.id,
                    ProctoredExamStudentAttemptStatus.submitted)
            elif action == 'click_download_software':
                exam_attempt_id = update_attempt_status(
                    attempt['proctored_exam']['id'], request.user.id,
                    ProctoredExamStudentAttemptStatus.download_software_clicked
                )
            elif action == 'decline':
                exam_attempt_id = update_attempt_status(
                    attempt['proctored_exam']['id'], request.user.id,
                    ProctoredExamStudentAttemptStatus.declined)
            return Response({"exam_attempt_id": exam_attempt_id})

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(status=status.HTTP_400_BAD_REQUEST,
                            data={"detail": str(ex)})
Example #7
0
    def delete(self, request, attempt_id):  # pylint: disable=unused-argument
        """
        HTTP DELETE handler. Removes an exam attempt.
        """
        attempt = get_exam_attempt_by_id(attempt_id)

        if not attempt:
            err_msg = (u'Attempted to access attempt_id {attempt_id} but '
                       u'it does not exist.'.format(attempt_id=attempt_id))
            raise StudentExamAttemptDoesNotExistsException(err_msg)

        remove_exam_attempt(attempt_id, request.user)
        return Response()
Example #8
0
 def post(self, request, external_id):
     """
     Called when 3rd party proctoring service has finished its review of
     an attempt.
     """
     attempt = get_exam_attempt_by_external_id(external_id)
     if attempt is None:
         raise StudentExamAttemptDoesNotExistsException('not found')
     if request.user.has_perm('edx_proctoring.can_review_attempt', attempt):
         self.make_review(attempt, request.data)
         resp = Response(data='OK')
     else:
         resp = Response(status=403)
     return resp
Example #9
0
 def post(self, request, external_id):
     """
     Called when 3rd party proctoring service has finished its review of
     an attempt.
     """
     attempt = get_exam_attempt_by_external_id(external_id)
     if attempt is None:
         err_msg = (
             u'Attempted to access external exam id {external_id} but '
             u'it does not exist.'.format(external_id=external_id))
         raise StudentExamAttemptDoesNotExistsException(err_msg)
     if request.user.has_perm('edx_proctoring.can_review_attempt', attempt):
         self.make_review(attempt, request.data)
         resp = Response(data='OK')
     else:
         resp = Response(status=403)
     return resp
Example #10
0
def start_exam_attempt_by_code(attempt_code):
    """
    Signals the beginning of an exam attempt when we only have
    an attempt code
    """

    existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(
        attempt_code)

    if not existing_attempt:
        err_msg = (
            'Cannot start exam attempt for attempt_code = {attempt_code} '
            'because it does not exist!').format(attempt_code=attempt_code)

        raise StudentExamAttemptDoesNotExistsException(err_msg)

    return _start_exam_attempt(existing_attempt)
Example #11
0
    def post(self, request):
        """
        Post callback handler
        """
        provider = get_backend_provider({'backend': 'software_secure'})

        # call down into the underlying provider code
        attempt_code = request.data.get('examMetaData', {}).get('examCode')
        attempt_obj, is_archived = locate_attempt_by_attempt_code(attempt_code)
        if not attempt_obj:
            # still can't find, error out
            err_msg = (u'Could not locate attempt_code: {attempt_code}'.format(
                attempt_code=attempt_code))
            raise StudentExamAttemptDoesNotExistsException(err_msg)
        serialized = ProctoredExamStudentAttemptSerializer(attempt_obj).data
        serialized['is_archived'] = is_archived
        self.make_review(serialized, request.data, backend=provider)
        return Response('OK')
Example #12
0
def start_exam_attempt(exam_id, user_id):
    """
    Signals the beginning of an exam attempt for a given
    exam_id. If one already exists, then an exception should be thrown.

    Returns: exam_attempt_id (PK)
    """

    existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt(
        exam_id, user_id)

    if not existing_attempt:
        err_msg = (
            'Cannot start exam attempt for exam_id = {exam_id} '
            'and user_id = {user_id} because it does not exist!').format(
                exam_id=exam_id, user_id=user_id)

        raise StudentExamAttemptDoesNotExistsException(err_msg)

    return _start_exam_attempt(existing_attempt)
Example #13
0
    def put(self, request, attempt_code):
        """
        HTTP POST handler. To stop an exam.
        """
        try:
            attempt = get_exam_attempt_by_code(attempt_code)

            if not attempt:
                err_msg = (
                    'Attempted to access attempt_code {attempt_code} but '
                    'it does not exist.'.format(attempt_code=attempt_code))
                raise StudentExamAttemptDoesNotExistsException(err_msg)

            action = request.DATA.get('action')
            user_id = request.DATA.get('user_id')
            exam_id = attempt['proctored_exam']['id']

            if action and action == 'submit':
                exam_attempt_id = update_attempt_status(
                    exam_id, user_id,
                    ProctoredExamStudentAttemptStatus.submitted)

            if action and action == 'fail':
                exam_attempt_id = update_attempt_status(
                    exam_id, user_id, ProctoredExamStudentAttemptStatus.error)

            if action and action == 'decline':
                exam_attempt_id = update_attempt_status(
                    exam_id, user_id,
                    ProctoredExamStudentAttemptStatus.timed_out)

            return Response({"exam_attempt_id": exam_attempt_id})

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(status=status.HTTP_400_BAD_REQUEST,
                            data={"detail": str(ex)})
Example #14
0
    def on_review_callback(self, payload):
        """
        Called when the reviewing 3rd party service posts back the results

        Documentation on the data format can be found from SoftwareSecure's
        documentation named "Reviewer Data Transfer"
        """

        # redact the videoReviewLink from the payload
        if 'videoReviewLink' in payload:
            del payload['videoReviewLink']

        log_msg = (
            'Received callback from SoftwareSecure with review data: {payload}'
            .format(payload=payload))
        log.info(log_msg)

        # what we consider the external_id is SoftwareSecure's 'ssiRecordLocator'
        external_id = payload['examMetaData']['ssiRecordLocator']

        # what we consider the attempt_code is SoftwareSecure's 'examCode'
        attempt_code = payload['examMetaData']['examCode']

        # get the SoftwareSecure status on this attempt
        review_status = payload['reviewStatus']

        bad_status = review_status not in self.passing_review_status + self.failing_review_status

        if bad_status:
            err_msg = (
                'Received unexpected reviewStatus field calue from payload. '
                'Was {review_status}.'.format(review_status=review_status))
            raise ProctoredExamBadReviewStatus(err_msg)

        # do a lookup on the attempt by examCode, and compare the
        # passed in ssiRecordLocator and make sure it matches
        # what we recorded as the external_id. We need to look in both
        # the attempt table as well as the archive table

        (attempt_obj,
         is_archived_attempt) = locate_attempt_by_attempt_code(attempt_code)
        if not attempt_obj:
            # still can't find, error out
            err_msg = ('Could not locate attempt_code: {attempt_code}'.format(
                attempt_code=attempt_code))
            raise StudentExamAttemptDoesNotExistsException(err_msg)

        # then make sure we have the right external_id
        # note that SoftwareSecure might send a case insensitive
        # ssiRecordLocator than what it returned when we registered the
        # exam
        match = (attempt_obj.external_id.lower() == external_id.lower()
                 or settings.PROCTORING_SETTINGS.get(
                     'ALLOW_CALLBACK_SIMULATION', False))
        if not match:
            err_msg = (
                'Found attempt_code {attempt_code}, but the recorded external_id did not '
                'match the ssiRecordLocator that had been recorded previously. Has {existing} '
                'but received {received}!'.format(
                    attempt_code=attempt_code,
                    existing=attempt_obj.external_id,
                    received=external_id))
            raise ProctoredExamSuspiciousLookup(err_msg)

        # do we already have a review for this attempt?!? We may not allow updates
        review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(
            attempt_code)

        if review:
            if not constants.ALLOW_REVIEW_UPDATES:
                err_msg = (
                    'We already have a review submitted from SoftwareSecure regarding '
                    'attempt_code {attempt_code}. We do not allow for updates!'
                    .format(attempt_code=attempt_code))
                raise ProctoredExamReviewAlreadyExists(err_msg)

            # we allow updates
            warn_msg = (
                'We already have a review submitted from SoftwareSecure regarding '
                'attempt_code {attempt_code}. We have been configured to allow for '
                'updates and will continue...'.format(
                    attempt_code=attempt_code))
            log.warn(warn_msg)
        else:
            # this is first time we've received this attempt_code, so
            # make a new record in the review table
            review = ProctoredExamSoftwareSecureReview()

        review.attempt_code = attempt_code
        review.raw_data = json.dumps(payload)
        review.review_status = review_status
        review.student = attempt_obj.user
        review.exam = attempt_obj.proctored_exam
        # set reviewed_by to None because it was reviewed by our 3rd party
        # service provider, not a user in our database
        review.reviewed_by = None

        review.save()

        # go through and populate all of the specific comments
        for comment in payload.get('webCamComments', []):
            self._save_review_comment(review, comment)

        for comment in payload.get('desktopComments', []):
            self._save_review_comment(review, comment)

        # we could have gotten a review for an archived attempt
        # this should *not* cause an update in our credit
        # eligibility table
        if not is_archived_attempt:
            # update our attempt status, note we have to import api.py here because
            # api.py imports software_secure.py, so we'll get an import circular reference

            allow_rejects = not constants.REQUIRE_FAILURE_SECOND_REVIEWS

            self.on_review_saved(review, allow_rejects=allow_rejects)

        # emit an event for 'review_received'
        data = {
            'review_attempt_code': review.attempt_code,
            'review_status': review.review_status,
        }

        attempt = ProctoredExamStudentAttemptSerializer(attempt_obj).data
        exam = ProctoredExamSerializer(attempt_obj.proctored_exam).data
        emit_event(exam,
                   'review_received',
                   attempt=attempt,
                   override_data=data)

        self._create_zendesk_ticket(review, exam, attempt)
Example #15
0
def update_attempt_status(exam_id,
                          user_id,
                          to_status,
                          raise_if_not_found=True,
                          cascade_effects=True):
    """
    Internal helper to handle state transitions of attempt status
    """

    exam = get_exam_by_id(exam_id)
    provider_name = get_provider_name_by_course_id(exam['course_id'])
    proctoring_settings = get_proctoring_settings(provider_name)
    exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(
        exam_id, user_id)

    if exam_attempt_obj is None:
        if raise_if_not_found:
            raise StudentExamAttemptDoesNotExistsException(
                'Error. Trying to look up an exam that does not exist.')
        else:
            return
    else:
        log_msg = ('{attempt_code} - {username} ({email}) '
                   'Updating attempt status for exam_id {exam_id} '
                   'for user_id {user_id} to status {to_status}'.format(
                       exam_id=exam_id,
                       user_id=user_id,
                       to_status=to_status,
                       attempt_code=exam_attempt_obj.attempt_code,
                       username=exam_attempt_obj.user.username,
                       email=exam_attempt_obj.user.email))
        log.info(log_msg)

    timed_out_state = False
    if exam_attempt_obj.status == ProctoredExamStudentAttemptStatus.created:
        timed_out_state = True
    # In some configuration we may treat timeouts the same
    # as the user saying he/she wises to submit the exam
    alias_timeout = (to_status == ProctoredExamStudentAttemptStatus.timed_out
                     and not proctoring_settings.get('ALLOW_TIMED_OUT_STATE',
                                                     timed_out_state))
    if alias_timeout:
        to_status = ProctoredExamStudentAttemptStatus.submitted

    #
    # don't allow state transitions from a completed state to an incomplete state
    # if a re-attempt is desired then the current attempt must be deleted
    #
    in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(
        exam_attempt_obj.status)
    to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(
        to_status)

    if in_completed_status and to_incompleted_status:
        err_msg = (
            'A status transition from {from_status} to {to_status} was attempted '
            'on exam_id {exam_id} for user_id {user_id}. This is not '
            'allowed!'.format(from_status=exam_attempt_obj.status,
                              to_status=to_status,
                              exam_id=exam_id,
                              user_id=user_id))
        raise ProctoredExamIllegalStatusTransition(err_msg)

    # special case logic, if we are in a completed status we shouldn't allow
    # for a transition to 'Error' state
    if in_completed_status and to_status == ProctoredExamStudentAttemptStatus.error:
        err_msg = (
            'A status transition from {from_status} to {to_status} was attempted '
            'on exam_id {exam_id} for user_id {user_id}. This is not '
            'allowed!'.format(from_status=exam_attempt_obj.status,
                              to_status=to_status,
                              exam_id=exam_id,
                              user_id=user_id))
        raise ProctoredExamIllegalStatusTransition(err_msg)

    if to_status == exam_attempt_obj.status:
        log_msg = (
            '{attempt_code} - {username} ({email}) '
            'Try to change attempt status for exam_id {exam_id} for user_id '
            '{user_id} to the same status. Rejected'.format(
                exam_id=exam_id,
                user_id=user_id,
                attempt_code=exam_attempt_obj.attempt_code,
                username=exam_attempt_obj.user.username,
                email=exam_attempt_obj.user.email))
        log.info(log_msg)
        return exam_attempt_obj.id
    # OK, state transition is fine, we can proceed
    exam_attempt_obj.status = to_status
    exam_attempt_obj.save()

    # see if the status transition this changes credit requirement status
    if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):

        # trigger credit workflow, as needed
        credit_service = get_runtime_service('credit')

        exam = get_exam_by_id(exam_id)
        if to_status == ProctoredExamStudentAttemptStatus.verified:
            verification = 'satisfied'
        elif to_status == ProctoredExamStudentAttemptStatus.submitted:
            verification = 'submitted'
        else:
            verification = 'failed'

        log_msg = ('{attempt_code} - {username} ({email}) '
                   'Calling set_credit_requirement_status for '
                   'user_id {user_id} on {course_id} for '
                   'content_id {content_id}. Status: {status}'.format(
                       user_id=exam_attempt_obj.user_id,
                       course_id=exam['course_id'],
                       content_id=exam_attempt_obj.proctored_exam.content_id,
                       status=verification,
                       attempt_code=exam_attempt_obj.attempt_code,
                       username=exam_attempt_obj.user.username,
                       email=exam_attempt_obj.user.email))
        log.info(log_msg)

        credit_service.set_credit_requirement_status(
            user_id=exam_attempt_obj.user_id,
            course_key_or_id=exam['course_id'],
            req_namespace='proctored_exam',
            req_name=exam_attempt_obj.proctored_exam.content_id,
            status=verification)

    if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(
            to_status):
        if to_status == ProctoredExamStudentAttemptStatus.declined:
            # if user declines attempt, make sure we clear out the external_id and
            # taking_as_proctored fields
            exam_attempt_obj.taking_as_proctored = False
            exam_attempt_obj.external_id = None
            exam_attempt_obj.save()

        # some state transitions (namely to a rejected or declined status)
        # will mark other exams as declined because once we fail or decline
        # one exam all other (un-completed) proctored exams will be likewise
        # updated to reflect a declined status
        # get all other unattempted exams and mark also as declined
        _exams = ProctoredExam.get_all_exams_for_course(
            exam_attempt_obj.proctored_exam.course_id, active_only=True)

        # we just want other exams which are proctored and are not practice
        exams = [
            exam for exam in _exams
            if (exam.content_id != exam_attempt_obj.proctored_exam.content_id
                and exam.is_proctored and not exam.is_practice_exam)
        ]

        for exam in exams:
            # see if there was an attempt on those other exams already
            attempt = get_exam_attempt(exam.id, user_id)
            if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(
                    attempt['status']):
                # don't touch any completed statuses
                # we won't revoke those
                continue

            if not attempt:
                create_exam_attempt(exam.id,
                                    user_id,
                                    taking_as_proctored=False)

            # update any new or existing status to declined
            update_attempt_status(exam.id,
                                  user_id,
                                  ProctoredExamStudentAttemptStatus.declined,
                                  cascade_effects=False)

    if to_status == ProctoredExamStudentAttemptStatus.submitted:
        # also mark the exam attempt completed_at timestamp
        # after we submit the attempt
        exam_attempt_obj.completed_at = datetime.now(pytz.UTC)
        exam_attempt_obj.save()

    # if we have transitioned to started and haven't set our
    # started_at timestamp, do so now
    add_start_time = (to_status == ProctoredExamStudentAttemptStatus.started
                      and not exam_attempt_obj.started_at)
    if add_start_time:
        exam_attempt_obj.started_at = datetime.now(pytz.UTC)
        exam_attempt_obj.save()

    # email will be send when the exam is proctored and not practice exam
    # and the status is verified, submitted or rejected
    should_send_status_email = (
        exam_attempt_obj.taking_as_proctored
        and not exam_attempt_obj.is_sample_attempt
        and ProctoredExamStudentAttemptStatus.needs_status_change_email(
            exam_attempt_obj.status))
    if should_send_status_email:
        # trigger credit workflow, as needed
        credit_service = get_runtime_service('credit')

        # call service to get course name.
        credit_state = credit_service.get_credit_state(
            exam_attempt_obj.user_id,
            exam_attempt_obj.proctored_exam.course_id,
            #return_course_name=True
        )

        send_proctoring_attempt_status_email(
            exam_attempt_obj, credit_state.get('course_name',
                                               _('your course')))

    return exam_attempt_obj.id