Esempio n. 1
0
 def wrapped(request, *args, **kwargs):  # pylint: disable=missing-docstring
     instructor_service = get_runtime_service('instructor')
     course_id = kwargs['course_id'] if 'course_id' in kwargs else None
     exam_id = request.data.get('exam_id', None)
     attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None
     if request.user.is_staff:
         return func(request, *args, **kwargs)
     else:
         if course_id is None:
             if exam_id is not None:
                 exam = ProctoredExam.get_exam_by_id(exam_id)
                 course_id = exam.course_id
             elif attempt_id is not None:
                 exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
                 course_id = exam_attempt.proctored_exam.course_id
             else:
                 response_message = _("could not determine the course_id")
                 return Response(
                     status=status.HTTP_403_FORBIDDEN,
                     data={"detail": response_message}
                 )
         if instructor_service.is_course_staff(request.user, course_id):
             return func(request, *args, **kwargs)
         else:
             return Response(
                 status=status.HTTP_403_FORBIDDEN,
                 data={"detail": _("Must be a Staff User to Perform this request.")}
             )
Esempio n. 2
0
 def wrapped(request, *args, **kwargs):  # pylint: disable=missing-docstring
     instructor_service = get_runtime_service('instructor')
     course_id = kwargs['course_id'] if 'course_id' in kwargs else None
     exam_id = request.data.get('exam_id', None)
     attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None
     if request.user.is_staff:
         return func(request, *args, **kwargs)
     else:
         if course_id is None:
             if exam_id is not None:
                 exam = ProctoredExam.get_exam_by_id(exam_id)
                 course_id = exam.course_id
             elif attempt_id is not None:
                 exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(
                     attempt_id)
                 course_id = exam_attempt.proctored_exam.course_id
             else:
                 response_message = _("could not determine the course_id")
                 return Response(status=status.HTTP_403_FORBIDDEN,
                                 data={"detail": response_message})
         if instructor_service.is_course_staff(request.user, course_id):
             return func(request, *args, **kwargs)
         else:
             return Response(
                 status=status.HTTP_403_FORBIDDEN,
                 data={
                     "detail":
                     _("Must be a Staff User to Perform this request.")
                 })
Esempio n. 3
0
def get_all_exams_for_course(course_id):
    """
    This method will return all exams for a course. This will return a list
    of dictionaries, whose schema is the same as what is returned in
    get_exam_by_id
    Returns a list containing dictionary version of the Django ORM object
    e.g.
    [{
        "course_id": "edX/DemoX/Demo_Course",
        "content_id": "123",
        "external_id": "",
        "exam_name": "Midterm",
        "time_limit_mins": 90,
        "is_proctored": true,
        "is_active": true
    },
    {
        ...: ...,
        ...: ...

    },
    ..
    ]
    """
    exams = ProctoredExam.get_all_exams_for_course(course_id)

    return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams]
Esempio n. 4
0
def get_all_exams_for_course(course_id):
    """
    This method will return all exams for a course. This will return a list
    of dictionaries, whose schema is the same as what is returned in
    get_exam_by_id
    Returns a list containing dictionary version of the Django ORM object
    e.g.
    [{
        "course_id": "edX/DemoX/Demo_Course",
        "content_id": "123",
        "external_id": "",
        "exam_name": "Midterm",
        "time_limit_mins": 90,
        "is_proctored": true,
        "is_active": true
    },
    {
        ...: ...,
        ...: ...

    },
    ..
    ]
    """
    exams = ProctoredExam.get_all_exams_for_course(course_id)

    return [
        ProctoredExamSerializer(proctored_exam).data
        for proctored_exam in exams
    ]
Esempio n. 5
0
def update_exam(exam_id,
                exam_name=None,
                time_limit_mins=None,
                is_proctored=None,
                is_practice_exam=None,
                external_id=None,
                is_active=None):
    """
    Given a Django ORM id, update the existing record, otherwise raise exception if not found.
    If an argument is not passed in, then do not change it's current value.

    Returns: id
    """

    log_msg = (
        u'Updating exam_id {exam_id} with parameters '
        u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
        u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
        u'external_id={external_id}, is_active={is_active}'.format(
            exam_id=exam_id,
            exam_name=exam_name,
            time_limit_mins=time_limit_mins,
            is_proctored=is_proctored,
            is_practice_exam=is_practice_exam,
            external_id=external_id,
            is_active=is_active))
    log.info(log_msg)

    proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
    if proctored_exam is None:
        raise ProctoredExamNotFoundException

    if exam_name is not None:
        proctored_exam.exam_name = exam_name
    if time_limit_mins is not None:
        proctored_exam.time_limit_mins = time_limit_mins
    if is_proctored is not None:
        proctored_exam.is_proctored = is_proctored
    if is_practice_exam is not None:
        proctored_exam.is_practice_exam = is_practice_exam
    if external_id is not None:
        proctored_exam.external_id = external_id
    if is_active is not None:
        proctored_exam.is_active = is_active
    proctored_exam.save()
    return proctored_exam.id
Esempio n. 6
0
def create_exam(course_id,
                content_id,
                exam_name,
                time_limit_mins,
                is_proctored=True,
                is_practice_exam=False,
                external_id=None,
                is_active=True):
    """
    Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist.
    If that pair already exists, then raise exception.

    Returns: id (PK)
    """

    if ProctoredExam.get_exam_by_content_id(course_id, content_id) is not None:
        raise ProctoredExamAlreadyExists

    proctored_exam = ProctoredExam.objects.create(
        course_id=course_id,
        content_id=content_id,
        external_id=external_id,
        exam_name=exam_name,
        time_limit_mins=time_limit_mins,
        is_proctored=is_proctored,
        is_practice_exam=is_practice_exam,
        is_active=is_active)

    log_msg = (
        u'Created exam ({exam_id}) with parameters: course_id={course_id}, '
        u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
        u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
        u'external_id={external_id}, is_active={is_active}'.format(
            exam_id=proctored_exam.id,
            course_id=course_id,
            content_id=content_id,
            exam_name=exam_name,
            time_limit_mins=time_limit_mins,
            is_proctored=is_proctored,
            is_practice_exam=is_practice_exam,
            external_id=external_id,
            is_active=is_active))
    log.info(log_msg)

    return proctored_exam.id
Esempio n. 7
0
def get_exam_by_content_id(course_id, content_id):
    """
    Looks up exam by the course_id/content_id pair. Raises exception if not found.

    Returns dictionary version of the Django ORM object
    e.g.
    {
        "course_id": "edX/DemoX/Demo_Course",
        "content_id": "123",
        "external_id": "",
        "exam_name": "Midterm",
        "time_limit_mins": 90,
        "is_proctored": true,
        "is_active": true
    }
    """
    proctored_exam = ProctoredExam.get_exam_by_content_id(course_id, content_id)
    if proctored_exam is None:
        raise ProctoredExamNotFoundException

    serialized_exam_object = ProctoredExamSerializer(proctored_exam)
    return serialized_exam_object.data
Esempio n. 8
0
def update_exam(exam_id, exam_name=None, time_limit_mins=None,
                is_proctored=None, is_practice_exam=None, external_id=None, is_active=None):
    """
    Given a Django ORM id, update the existing record, otherwise raise exception if not found.
    If an argument is not passed in, then do not change it's current value.

    Returns: id
    """

    log_msg = (
        u'Updating exam_id {exam_id} with parameters '
        u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
        u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
        u'external_id={external_id}, is_active={is_active}'.format(
            exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins,
            is_proctored=is_proctored, is_practice_exam=is_practice_exam,
            external_id=external_id, is_active=is_active
        )
    )
    log.info(log_msg)

    proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
    if proctored_exam is None:
        raise ProctoredExamNotFoundException

    if exam_name is not None:
        proctored_exam.exam_name = exam_name
    if time_limit_mins is not None:
        proctored_exam.time_limit_mins = time_limit_mins
    if is_proctored is not None:
        proctored_exam.is_proctored = is_proctored
    if is_practice_exam is not None:
        proctored_exam.is_practice_exam = is_practice_exam
    if external_id is not None:
        proctored_exam.external_id = external_id
    if is_active is not None:
        proctored_exam.is_active = is_active
    proctored_exam.save()
    return proctored_exam.id
Esempio n. 9
0
def get_exam_by_id(exam_id):
    """
    Looks up exam by the Primary Key. Raises exception if not found.

    Returns dictionary version of the Django ORM object
    e.g.
    {
        "course_id": "edX/DemoX/Demo_Course",
        "content_id": "123",
        "external_id": "",
        "exam_name": "Midterm",
        "time_limit_mins": 90,
        "is_proctored": true,
        "is_active": true
    }
    """
    proctored_exam = ProctoredExam.get_exam_by_id(exam_id)
    if proctored_exam is None:
        raise ProctoredExamNotFoundException

    serialized_exam_object = ProctoredExamSerializer(proctored_exam)
    return serialized_exam_object.data
Esempio n. 10
0
    def test_get_practice_proctored_exams_for_course(self):
        """
        Test get_practice_proctored_exams_for_course method returns only active
        practice proctored exams.
        """
        course_id = 'test_course'
        # create proctored exam
        ProctoredExam.objects.create(
            course_id=course_id,
            content_id='test_content_1',
            exam_name='Test Exam',
            external_id='123aXqe3',
            time_limit_mins=90,
            is_active=True,
            is_proctored=True,
        )
        # create practice proctored exam
        ProctoredExam.objects.create(
            course_id=course_id,
            content_id='test_content_2',
            exam_name='Test Exam',
            external_id='123aXqe3',
            time_limit_mins=90,
            is_active=True,
            is_proctored=True,
            is_practice_exam=True,
        )
        practice_proctored_exams = ProctoredExam.objects.filter(
            course_id=course_id,
            is_active=True,
            is_proctored=True,
            is_practice_exam=True
        )

        self.assertQuerysetEqual(
            ProctoredExam.get_practice_proctored_exams_for_course(course_id),
            [repr(exam) for exam in practice_proctored_exams]
        )
Esempio n. 11
0
def create_exam(course_id, content_id, exam_name, time_limit_mins,
                is_proctored=True, is_practice_exam=False, external_id=None, is_active=True):
    """
    Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist.
    If that pair already exists, then raise exception.

    Returns: id (PK)
    """

    if ProctoredExam.get_exam_by_content_id(course_id, content_id) is not None:
        raise ProctoredExamAlreadyExists

    proctored_exam = ProctoredExam.objects.create(
        course_id=course_id,
        content_id=content_id,
        external_id=external_id,
        exam_name=exam_name,
        time_limit_mins=time_limit_mins,
        is_proctored=is_proctored,
        is_practice_exam=is_practice_exam,
        is_active=is_active
    )

    log_msg = (
        u'Created exam ({exam_id}) with parameters: course_id={course_id}, '
        u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, '
        u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, '
        u'external_id={external_id}, is_active={is_active}'.format(
            exam_id=proctored_exam.id,
            course_id=course_id, content_id=content_id,
            exam_name=exam_name, time_limit_mins=time_limit_mins,
            is_proctored=is_proctored, is_practice_exam=is_practice_exam,
            external_id=external_id, is_active=is_active
        )
    )
    log.info(log_msg)

    return proctored_exam.id
Esempio n. 12
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
    """

    log_msg = (
        '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
        )
    )
    log.info(log_msg)

    # 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 settings.PROCTORING_SETTINGS.get('ALLOW_TIMED_OUT_STATE', False)
    )
    if alias_timeout:
        to_status = ProctoredExamStudentAttemptStatus.submitted

    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

    #
    # 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)

    # 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 = (
            '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
            )
        )
        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
Esempio n. 13
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