Ejemplo n.º 1
0
    def handle(self, *args, **options):
        """
        Management command entry point, simply call into the signal firiing
        """

        from edx_proctoring.api import (update_attempt_status, get_exam_by_id)

        exam_id = options['exam_id']
        user_id = options['user_id']
        to_status = options['to_status']

        msg = ('Running management command to update user {user_id} '
               'attempt status on exam_id {exam_id} to {to_status}'.format(
                   user_id=user_id, exam_id=exam_id, to_status=to_status))
        print msg

        if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status):
            raise Exception(
                '{to_status} is not a valid attempt status!'.format(
                    to_status=to_status))

        # get exam, this will throw exception if does not exist, so let it bomb out
        get_exam_by_id(exam_id)

        update_attempt_status(exam_id, user_id, to_status)

        print 'Completed!'
Ejemplo n.º 2
0
def _check_for_attempt_timeout(attempt):
    """
    Helper method to see if the status of an
    exam needs to be updated, e.g. timeout
    """

    if not attempt:
        return attempt

    # right now the only adjustment to
    # status is transitioning to timeout
    has_started_exam = (attempt and attempt.get('started_at') and
                        ProctoredExamStudentAttemptStatus.is_incomplete_status(
                            attempt.get('status')))
    if has_started_exam:
        now_utc = datetime.now(pytz.UTC)
        expires_at = attempt['started_at'] + timedelta(
            minutes=attempt['allowed_time_limit_mins'])
        has_time_expired = now_utc > expires_at

        if has_time_expired:
            update_attempt_status(attempt['proctored_exam']['id'],
                                  attempt['user']['id'],
                                  ProctoredExamStudentAttemptStatus.timed_out)
            attempt = get_exam_attempt_by_id(attempt['id'])

    return attempt
Ejemplo n.º 3
0
def _check_for_attempt_timeout(attempt):
    """
    Helper method to see if the status of an
    exam needs to be updated, e.g. timeout
    """

    if not attempt:
        return attempt

    # right now the only adjustment to
    # status is transitioning to timeout
    has_started_exam = (
        attempt and
        attempt.get('started_at') and
        ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt.get('status'))
    )
    if has_started_exam:
        now_utc = datetime.now(pytz.UTC)
        expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins'])
        has_time_expired = now_utc > expires_at

        if has_time_expired:
            update_attempt_status(
                attempt['proctored_exam']['id'],
                attempt['user']['id'],
                ProctoredExamStudentAttemptStatus.timed_out
            )
            attempt = get_exam_attempt_by_id(attempt['id'])

    return attempt
Ejemplo n.º 4
0
    def handle(self, *args, **options):
        """
        Management command entry point, simply call into the signal firiing
        """

        from edx_proctoring.api import (
            update_attempt_status,
            get_exam_by_id
        )

        exam_id = options['exam_id']
        user_id = options['user_id']
        to_status = options['to_status']

        msg = (
            'Running management command to update user {user_id} '
            'attempt status on exam_id {exam_id} to {to_status}'.format(
                user_id=user_id,
                exam_id=exam_id,
                to_status=to_status
            )
        )
        print msg

        if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status):
            raise Exception('{to_status} is not a valid attempt status!'.format(to_status=to_status))

        # get exam, this will throw exception if does not exist, so let it bomb out
        get_exam_by_id(exam_id)

        update_attempt_status(exam_id, user_id, to_status)

        print 'Completed!'
Ejemplo n.º 5
0
def send_proctoring_attempt_status_email(exam_attempt_obj, course_name):
    """
    Sends an email about change in proctoring attempt status.
    """

    course_info_url = ''
    email_template = loader.get_template(
        'emails/proctoring_attempt_status_email.html')
    try:
        course_info_url = reverse(
            'courseware.views.course_info',
            args=[exam_attempt_obj.proctored_exam.course_id])
    except NoReverseMatch:
        # we are allowing a failure here since we can't guarantee
        # that we are running in-proc with the edx-platform LMS
        # (for example unit tests)
        pass

    course_id = exam_attempt_obj.proctored_exam.course_id
    provider_name = get_provider_name_by_course_id(course_id)
    proctor_settings = get_proctoring_settings(provider_name)
    scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
    course_url = '{scheme}://{site_name}{course_info_url}'.format(
        scheme=scheme,
        site_name=get_proctor_settings_param(proctor_settings, 'SITE_NAME'),
        course_info_url=course_info_url)

    body = email_template.render(
        Context({
            'course_url':
            course_url,
            'course_name':
            course_name,
            'exam_name':
            exam_attempt_obj.proctored_exam.exam_name,
            'status':
            ProctoredExamStudentAttemptStatus.get_status_alias(
                exam_attempt_obj.status),
            'platform':
            get_proctor_settings_param(proctor_settings, 'PLATFORM_NAME'),
            'contact_email':
            get_proctor_settings_param(proctor_settings, 'CONTACT_EMAIL'),
        }))

    subject = (
        _('Proctoring Session Results Update for {course_name} {exam_name}'
          ).format(course_name=course_name,
                   exam_name=exam_attempt_obj.proctored_exam.exam_name))

    email = EmailMessage(body=body,
                         from_email=get_proctor_settings_param(
                             proctor_settings, 'FROM_EMAIL'),
                         to=[exam_attempt_obj.user.email],
                         subject=subject)
    email.content_subtype = "html"
    email.send()
Ejemplo n.º 6
0
def send_proctoring_attempt_status_email(exam_attempt_obj, course_name):
    """
    Sends an email about change in proctoring attempt status.
    """

    course_info_url = ''
    email_template = loader.get_template('emails/proctoring_attempt_status_email.html')
    try:
        course_info_url = reverse('courseware.views.course_info', args=[exam_attempt_obj.proctored_exam.course_id])
    except NoReverseMatch:
        # we are allowing a failure here since we can't guarantee
        # that we are running in-proc with the edx-platform LMS
        # (for example unit tests)
        pass

    course_id = exam_attempt_obj.proctored_exam.course_id
    provider_name = get_provider_name_by_course_id(course_id)
    proctor_settings = get_proctoring_settings(provider_name)
    scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
    course_url = '{scheme}://{site_name}{course_info_url}'.format(
        scheme=scheme,
        site_name=get_proctor_settings_param(proctor_settings, 'SITE_NAME'),
        course_info_url=course_info_url
    )

    body = email_template.render(
        Context({
            'course_url': course_url,
            'course_name': course_name,
            'exam_name': exam_attempt_obj.proctored_exam.exam_name,
            'status': ProctoredExamStudentAttemptStatus.get_status_alias(exam_attempt_obj.status),
            'platform': get_proctor_settings_param(proctor_settings, 'PLATFORM_NAME'),
            'contact_email': get_proctor_settings_param(proctor_settings, 'CONTACT_EMAIL'),
        })
    )

    subject = (
        _('Proctoring Session Results Update for {course_name} {exam_name}').format(
            course_name=course_name,
            exam_name=exam_attempt_obj.proctored_exam.exam_name
        )
    )

    email = EmailMessage(
        body=body,
        from_email=get_proctor_settings_param(proctor_settings, 'FROM_EMAIL'),
        to=[exam_attempt_obj.user.email],
        subject=subject
    )
    email.content_subtype = "html"
    email.send()
Ejemplo n.º 7
0
    def test_send_email(self, status):
        """
        Assert that email is sent on the following statuses of proctoring attempt.
        """

        exam_attempt = self._create_started_exam_attempt()
        credit_state = get_runtime_service('credit').get_credit_state(
            self.user_id, self.course_id)
        update_attempt_status(exam_attempt.proctored_exam_id, self.user.id,
                              status)
        self.assertEquals(len(mail.outbox), 1)
        self.assertIn(self.proctored_exam_email_subject,
                      mail.outbox[0].subject)
        self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body)
        self.assertIn(
            ProctoredExamStudentAttemptStatus.get_status_alias(status),
            mail.outbox[0].body)
        self.assertIn(credit_state['course_name'], mail.outbox[0].body)
Ejemplo n.º 8
0
def remove_exam_attempt(attempt_id):
    """
    Removes an exam attempt given the attempt id.
    """

    log_msg = (
        'Removing exam attempt {attempt_id}'.format(attempt_id=attempt_id)
    )
    log.info(log_msg)

    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

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

    if instructor_service:
        instructor_service.delete_student_attempt(username, course_id, content_id)

    # 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')
        credit_service.remove_credit_requirement_status(
            user_id=user_id,
            course_key_or_id=course_id,
            req_namespace=u'proctored_exam',
            req_name=content_id
        )
Ejemplo n.º 9
0
    def _create_exam_attempt(self, exam_id, status='created'):
        """
        Creates the ProctoredExamStudentAttempt object.
        """

        attempt = ProctoredExamStudentAttempt(proctored_exam_id=exam_id,
                                              user_id=self.user_id,
                                              external_id=self.external_id,
                                              allowed_time_limit_mins=10,
                                              status=status)

        if status in (ProctoredExamStudentAttemptStatus.started,
                      ProctoredExamStudentAttemptStatus.ready_to_submit,
                      ProctoredExamStudentAttemptStatus.submitted):
            attempt.started_at = datetime.now(pytz.UTC)

        if ProctoredExamStudentAttemptStatus.is_completed_status(status):
            attempt.completed_at = datetime.now(pytz.UTC)

        attempt.save()

        return attempt
Ejemplo n.º 10
0
    def test_send_email_unicode(self):
        """
        Assert that email can be sent with a unicode course name.
        """

        course_name = u'अआईउऊऋऌ अआईउऊऋऌ'
        set_runtime_service('credit',
                            MockCreditService(course_name=course_name))

        exam_attempt = self._create_started_exam_attempt()
        credit_state = get_runtime_service('credit').get_credit_state(
            self.user_id, self.course_id)
        update_attempt_status(exam_attempt.proctored_exam_id, self.user.id,
                              ProctoredExamStudentAttemptStatus.submitted)
        self.assertEquals(len(mail.outbox), 1)
        self.assertIn(self.proctored_exam_email_subject,
                      mail.outbox[0].subject)
        self.assertIn(course_name, mail.outbox[0].subject)
        self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body)
        self.assertIn(
            ProctoredExamStudentAttemptStatus.get_status_alias(
                ProctoredExamStudentAttemptStatus.submitted),
            mail.outbox[0].body)
        self.assertIn(credit_state['course_name'], mail.outbox[0].body)
Ejemplo n.º 11
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
Ejemplo 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
    """

    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