Exemple #1
0
class ExamReviewCallback(APIView):
    """
    This endpoint is called by a 3rd party proctoring review service when
    there are results available for us to record

    IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending
    this endpoint
    """

    content_negotiation_class = IgnoreClientContentNegotiation

    def post(self, request):
        """
        Post callback handler
        """
        try:
            attempt_code = request.DATA['examMetaData']['examCode']
        except KeyError, ex:
            log.exception(ex)
            return Response(data={'reason': unicode(ex)}, status=400)
        attempt_obj, is_archived_attempt = locate_attempt_by_attempt_code(
            attempt_code)
        course_id = attempt_obj.proctored_exam.course_id
        provider_name = get_provider_name_by_course_id(course_id)
        provider = get_backend_provider(provider_name)

        # call down into the underlying provider code
        try:
            provider.on_review_callback(request.DATA)
        except ProctoredBaseException, ex:
            log.exception(ex)
            return Response(data={'reason': unicode(ex)}, status=400)
Exemple #2
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()
Exemple #3
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()
Exemple #4
0
def bulk_start_exams_callback(request, attempt_codes):
    """
    A callback endpoint which is called when SoftwareSecure completes
    the proctoring setup and the exams should be started.

    NOTE: This returns HTML as it will be displayed in an embedded browser

    This is an authenticated endpoint and comaseparated attempt codes is passed
    in as part of the URL path

    IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending
    this endpoint
    """
    code_list = attempt_codes.split(',')

    attempts = ProctoredExamStudentAttempt.objects.filter(
         attempt_code__in=code_list
    )
    if len(attempts):
        return HttpResponse(
            content='You have entered an exam codes that are not valid.',
            status=404
        )


    for attempt_obj in attempts:
        attempt = _get_exam_attempt(attempt_obj)
        mark_exam_attempt_as_ready(attempt['proctored_exam']['id'], attempt['user']['id'])
        provider_name = get_provider_name_by_course_id(attempt['proctored_exam']['course_id'])
        proctoring_settings = get_proctoring_settings(provider_name)

    template = loader.get_template(
        'proctoring/proctoring_launch_callback.html'
    )
    return HttpResponse(
        template.render(
            Context({
                'exam_attempt_status_url': '',
                'platform_name': settings.PLATFORM_NAME,
                'link_urls': proctoring_settings.get('LINK_URLS', {})
            })
        )
    )
Exemple #5
0
def start_exam_callback(request, attempt_code):  # pylint: disable=unused-argument
    """
    A callback endpoint which is called when SoftwareSecure completes
    the proctoring setup and the exam should be started.

    NOTE: This returns HTML as it will be displayed in an embedded browser

    This is an authenticated endpoint and the attempt_code is passed in
    as part of the URL path

    IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending
    this endpoint
    """

    attempt = get_exam_attempt_by_code(attempt_code)
    if not attempt:
        return HttpResponse(
            content='You have entered an exam code that is not valid.',
            status=404
        )

    mark_exam_attempt_as_ready(attempt['proctored_exam']['id'], attempt['user']['id'])

    template = loader.get_template('proctoring/proctoring_launch_callback.html')

    poll_url = reverse(
        'edx_proctoring.anonymous.proctoring_poll_status',
        args=[attempt_code]
    )

    provider_name = get_provider_name_by_course_id(attempt['proctored_exam']['course_id'])
    proctoring_settings = get_proctoring_settings(provider_name)
    return HttpResponse(
        template.render(
            Context({
                'exam_attempt_status_url': poll_url,
                'platform_name': settings.PLATFORM_NAME,
                'link_urls': proctoring_settings.get('LINK_URLS', {})
            })
        )
    )
Exemple #6
0
def bulk_start_exams_callback(request, attempt_codes):
    """
    A callback endpoint which is called when SoftwareSecure completes
    the proctoring setup and the exams should be started.

    NOTE: This returns HTML as it will be displayed in an embedded browser

    This is an authenticated endpoint and comaseparated attempt codes is passed
    in as part of the URL path

    IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending
    this endpoint
    """
    code_list = attempt_codes.split(',')

    attempts = ProctoredExamStudentAttempt.objects.filter(
        attempt_code__in=code_list)
    if len(attempts):
        return HttpResponse(
            content='You have entered an exam codes that are not valid.',
            status=404)

    for attempt_obj in attempts:
        attempt = _get_exam_attempt(attempt_obj)
        mark_exam_attempt_as_ready(attempt['proctored_exam']['id'],
                                   attempt['user']['id'])
        provider_name = get_provider_name_by_course_id(
            attempt['proctored_exam']['course_id'])
        proctoring_settings = get_proctoring_settings(provider_name)

    template = loader.get_template(
        'proctoring/proctoring_launch_callback.html')
    return HttpResponse(
        template.render(
            Context({
                'exam_attempt_status_url': '',
                'platform_name': settings.PLATFORM_NAME,
                'link_urls': proctoring_settings.get('LINK_URLS', {})
            })))
Exemple #7
0
def start_exam_callback(request, attempt_code):  # pylint: disable=unused-argument
    """
    A callback endpoint which is called when SoftwareSecure completes
    the proctoring setup and the exam should be started.

    NOTE: This returns HTML as it will be displayed in an embedded browser

    This is an authenticated endpoint and the attempt_code is passed in
    as part of the URL path

    IMPORTANT: This is an unauthenticated endpoint, so be VERY CAREFUL about extending
    this endpoint
    """

    attempt = get_exam_attempt_by_code(attempt_code)
    if not attempt:
        return HttpResponse(
            content='You have entered an exam code that is not valid.',
            status=404)

    mark_exam_attempt_as_ready(attempt['proctored_exam']['id'],
                               attempt['user']['id'])

    template = loader.get_template(
        'proctoring/proctoring_launch_callback.html')

    poll_url = reverse('edx_proctoring.anonymous.proctoring_poll_status',
                       args=[attempt_code])

    provider_name = get_provider_name_by_course_id(
        attempt['proctored_exam']['course_id'])
    proctoring_settings = get_proctoring_settings(provider_name)
    return HttpResponse(
        template.render(
            Context({
                'exam_attempt_status_url': poll_url,
                'platform_name': settings.PLATFORM_NAME,
                'link_urls': proctoring_settings.get('LINK_URLS', {})
            })))
Exemple #8
0
    def post(self, request):
        """
        Post callback handler
        """
        data = request.DATA
        course_id = ""
        for review in data:
            try:
                attempt_code = review['examMetaData']['examCode']
            except KeyError, ex:
                continue
            attempt_obj, is_archived_attempt = locate_attempt_by_attempt_code(attempt_code)
            if course_id != attempt_obj.proctored_exam.course_id:
                course_id = attempt_obj.proctored_exam.course_id
                provider_name = get_provider_name_by_course_id(course_id)
                provider = get_backend_provider(provider_name)

            # call down into the underlying provider code
            try:
                provider.on_review_callback(review)
            except ProctoredBaseException, ex:
                log.exception(ex)
Exemple #9
0
    def post(self, request):
        """
        Post callback handler
        """
        data = request.DATA
        course_id = ""
        for review in data:
            try:
                attempt_code = review['examMetaData']['examCode']
            except KeyError, ex:
                continue
            attempt_obj, is_archived_attempt = locate_attempt_by_attempt_code(
                attempt_code)
            if course_id != attempt_obj.proctored_exam.course_id:
                course_id = attempt_obj.proctored_exam.course_id
                provider_name = get_provider_name_by_course_id(course_id)
                provider = get_backend_provider(provider_name)

            # call down into the underlying provider code
            try:
                provider.on_review_callback(review)
            except ProctoredBaseException, ex:
                log.exception(ex)
Exemple #10
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
Exemple #11
0
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
    """
    Creates an exam attempt for user_id against exam_id. There should only be
    one exam_attempt per user per exam. Multiple attempts by user will be archived
    in a separate table
    """
    # for now the student is allowed the exam default

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

    if existing_attempt:
        log_msg = (
            'Creating exam attempt for exam_id {exam_id} for '
            'user_id {user_id} with taking as proctored = {taking_as_proctored}'
            .format(exam_id=exam_id,
                    user_id=user_id,
                    taking_as_proctored=taking_as_proctored))
        log.info(log_msg)

        if existing_attempt.is_sample_attempt:
            # Archive the existing attempt by deleting it.
            existing_attempt.delete_exam_attempt()
        else:
            err_msg = (
                'Cannot create new exam attempt for exam_id = {exam_id} and '
                'user_id = {user_id} because it already exists!').format(
                    exam_id=exam_id, user_id=user_id)

            raise StudentExamAttemptAlreadyExistsException(err_msg)

    allowed_time_limit_mins = exam['time_limit_mins']

    # add in the allowed additional time
    allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(
        exam_id, user_id)
    if allowance_extra_mins:
        allowed_time_limit_mins += allowance_extra_mins

    attempt_code = unicode(uuid.uuid4()).upper()

    external_id = None
    review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(
        exam_id)
    review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(
        exam_id, user_id)

    if taking_as_proctored:
        content_id = exam['content_id'].split('@')[-1]  # get hash
        scheme = 'https' if getattr(settings, 'HTTPS',
                                    'on') == 'on' else 'http'
        callback_url = '{scheme}://{hostname}{path}'.format(
            scheme=scheme,
            hostname=settings.SITE_NAME,
            path=reverse('jump_to_id',
                         kwargs={
                             'course_id': exam['course_id'],
                             'module_id': content_id
                         }))

        # get the name of the user, if the service is available
        full_name = None

        credit_service = get_runtime_service('credit')
        user = User.objects.get(pk=user_id)

        context = {
            'time_limit_mins': allowed_time_limit_mins,
            'attempt_code': attempt_code,
            'is_sample_attempt': exam['is_practice_exam'],
            'callback_url': callback_url,
            'user_id': user_id,
            'full_name': " ".join((user.first_name, user.last_name)),
            'username': user.username,
            'email': user.email
        }

        # see if there is an exam review policy for this exam
        # if so, then pass it into the provider
        if review_policy:
            context.update({'review_policy': review_policy.review_policy})

        # see if there is a review policy exception for this *user*
        # exceptions are granted on a individual basis as an
        # allowance
        if review_policy_exception:
            context.update(
                {'review_policy_exception': review_policy_exception})

        # now call into the backend provider to register exam attempt
        provider_name = get_provider_name_by_course_id(exam['course_id'])
        external_id = get_backend_provider(
            provider_name).register_exam_attempt(
                exam,
                context=context,
            )

    attempt = ProctoredExamStudentAttempt.create_exam_attempt(
        exam_id,
        user_id,
        '',  # student name is TBD
        allowed_time_limit_mins,
        attempt_code,
        taking_as_proctored,
        exam['is_practice_exam'],
        external_id,
        review_policy_id=review_policy.id if review_policy else None,
    )

    log_msg = (
        '{attempt_code} - {username} ({email}) '
        'Created exam attempt ({attempt_id}) for exam_id {exam_id} for '
        'user_id {user_id} with taking as proctored = {taking_as_proctored} '
        'with allowed time limit minutes of {allowed_time_limit_mins}. '
        'external_id of {external_id}'.format(
            attempt_id=attempt.id,
            exam_id=exam_id,
            user_id=user_id,
            taking_as_proctored=taking_as_proctored,
            allowed_time_limit_mins=allowed_time_limit_mins,
            attempt_code=attempt_code,
            external_id=external_id,
            username=attempt.user.username,
            email=attempt.user.email))
    log.info(log_msg)

    return attempt.id
Exemple #12
0
def get_student_view(user_id,
                     course_id,
                     content_id,
                     context,
                     user_role='student'):
    """
    Helper method that will return the view HTML related to the exam control
    flow (i.e. entering, expired, completed, etc.) If there is no specific
    content to display, then None will be returned and the caller should
    render it's own view
    """

    # non-student roles should never see any proctoring related
    # screens
    if user_role != 'student':
        return None

    student_view_template = None

    exam_id = None
    try:
        exam = get_exam_by_content_id(course_id, content_id)
        if not exam['is_active']:
            # Exam is no longer active
            # Note, we don't hard delete exams since we need to retain
            # data
            return None

        exam_id = exam['id']
    except ProctoredExamNotFoundException:
        # This really shouldn't happen
        # as Studio will be setting this up
        exam_id = create_exam(
            course_id=course_id,
            content_id=unicode(content_id),
            exam_name=context['display_name'],
            time_limit_mins=context['default_time_limit_mins'],
            is_proctored=context.get('is_proctored', False),
            is_practice_exam=context.get('is_practice_exam', False))
        exam = get_exam_by_content_id(course_id, content_id)

    is_proctored = exam['is_proctored']

    provider_name = get_provider_name_by_course_id(exam['course_id'])
    proctoring_settings = get_proctoring_settings(provider_name)
    # see if only 'verified' track students should see this *except* if it is a practice exam
    check_mode_and_eligibility = (proctoring_settings.get(
        'MUST_BE_VERIFIED_TRACK', True) and 'credit_state' in context
                                  and context['credit_state']
                                  and not exam['is_practice_exam'])

    attempt = None
    if check_mode_and_eligibility:
        credit_state = context['credit_state']

        has_mode = _check_eligibility_of_enrollment_mode(credit_state)
        has_prerequisites = False
        if has_mode:
            has_prerequisites = _check_eligibility_of_prerequisites(
                credit_state)

        # see if the user has passed all pre-requisite credit eligibility
        # checks, otherwise just show the user the exam unproctored
        if not has_mode or not has_prerequisites:
            # if we are in the right mode and if we don't have
            # pre-requisites, then we implicitly decline the exam
            if has_mode:
                attempt = get_exam_attempt(exam_id, user_id)
                if not attempt:
                    # user hasn't a record of attempt, create one now
                    # so we can mark it as declined
                    create_exam_attempt(exam_id, user_id)

                    update_attempt_status(
                        exam_id,
                        user_id,
                        ProctoredExamStudentAttemptStatus.declined,
                        raise_if_not_found=False)

            # don't override context, let the courseware show
            return None

    attempt = get_exam_attempt(exam_id, user_id)

    # if user has declined the attempt, then we don't show the
    # proctored exam
    if attempt and attempt[
            'status'] == ProctoredExamStudentAttemptStatus.declined:
        return None

    does_time_remain = False
    has_started_exam = attempt and attempt.get('started_at')
    if has_started_exam:
        expires_at = attempt['started_at'] + timedelta(
            minutes=attempt['allowed_time_limit_mins'])
        does_time_remain = datetime.now(pytz.UTC) < expires_at

    if not attempt:
        # determine whether to show a timed exam only entrance screen
        # or a screen regarding proctoring

        if is_proctored:
            if exam['is_practice_exam']:
                student_view_template = 'proctoring/seq_proctored_practice_exam_entrance.html'
            else:
                student_view_template = 'proctoring/seq_proctored_exam_entrance.html'
        else:
            student_view_template = 'proctoring/seq_timed_exam_entrance.html'

    elif attempt['status'] == ProctoredExamStudentAttemptStatus.created:
        provider_name = get_provider_name_by_course_id(exam['course_id'])
        provider = get_backend_provider(provider_name)
        student_view_template = 'proctoring/seq_proctored_exam_instructions.html'
        context.update({
            'exam_code':
            attempt['attempt_code'],
            'software_download_url':
            provider.get_software_download_url(),
        })
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.ready_to_start:
        student_view_template = 'proctoring/seq_proctored_exam_ready_to_start.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.error:
        if attempt['is_sample_attempt']:
            student_view_template = 'proctoring/seq_proctored_practice_exam_error.html'
        else:
            student_view_template = 'proctoring/seq_proctored_exam_error.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.timed_out:
        student_view_template = 'proctoring/seq_timed_exam_expired.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.submitted:
        if attempt['is_sample_attempt']:
            student_view_template = 'proctoring/seq_proctored_practice_exam_submitted.html'
        else:
            student_view_template = 'proctoring/seq_proctored_exam_submitted.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.verified:
        student_view_template = 'proctoring/seq_proctored_exam_verified.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.rejected:
        student_view_template = 'proctoring/seq_proctored_exam_rejected.html'
    elif attempt[
            'status'] == ProctoredExamStudentAttemptStatus.ready_to_submit:
        if is_proctored:
            student_view_template = 'proctoring/seq_proctored_exam_ready_to_submit.html'
        else:
            student_view_template = 'proctoring/seq_timed_exam_ready_to_submit.html'

    if student_view_template:
        template = loader.get_template(student_view_template)
        django_context = Context(context)
        attempt_time = attempt['allowed_time_limit_mins'] if attempt else exam[
            'time_limit_mins']
        total_time = humanized_time(attempt_time)
        progress_page_url = ''
        try:
            progress_page_url = reverse('courseware.views.progress',
                                        args=[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

        provider_name = get_provider_name_by_course_id(exam['course_id'])
        proctoring_settings = get_proctoring_settings(provider_name)

        django_context.update({
            'platform_name':
            settings.PLATFORM_NAME,
            'total_time':
            total_time,
            'exam_id':
            exam_id,
            'progress_page_url':
            progress_page_url,
            'is_sample_attempt':
            attempt['is_sample_attempt'] if attempt else False,
            'does_time_remain':
            does_time_remain,
            'enter_exam_endpoint':
            reverse('edx_proctoring.proctored_exam.attempt.collection'),
            'exam_started_poll_url':
            reverse('edx_proctoring.proctored_exam.attempt',
                    args=[attempt['id']]) if attempt else '',
            'change_state_url':
            reverse('edx_proctoring.proctored_exam.attempt',
                    args=[attempt['id']]) if attempt else '',
            'link_urls':
            proctoring_settings.get('LINK_URLS', {}),
        })
        return template.render(django_context)

    return None
Exemple #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
Exemple #14
0
def create_exam_attempt(exam_id, user_id, taking_as_proctored=False):
    """
    Creates an exam attempt for user_id against exam_id. There should only be
    one exam_attempt per user per exam. Multiple attempts by user will be archived
    in a separate table
    """
    # for now the student is allowed the exam default

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

    if existing_attempt:
        log_msg = (
            'Creating exam attempt for exam_id {exam_id} for '
            'user_id {user_id} with taking as proctored = {taking_as_proctored}'.format(
                exam_id=exam_id, user_id=user_id, taking_as_proctored=taking_as_proctored
            )
        )
        log.info(log_msg)

        if existing_attempt.is_sample_attempt:
            # Archive the existing attempt by deleting it.
            existing_attempt.delete_exam_attempt()
        else:
            err_msg = (
                'Cannot create new exam attempt for exam_id = {exam_id} and '
                'user_id = {user_id} because it already exists!'
            ).format(exam_id=exam_id, user_id=user_id)

            raise StudentExamAttemptAlreadyExistsException(err_msg)

    allowed_time_limit_mins = exam['time_limit_mins']

    # add in the allowed additional time
    allowance_extra_mins = ProctoredExamStudentAllowance.get_additional_time_granted(exam_id, user_id)
    if allowance_extra_mins:
        allowed_time_limit_mins += allowance_extra_mins

    attempt_code = unicode(uuid.uuid4()).upper()

    external_id = None
    review_policy = ProctoredExamReviewPolicy.get_review_policy_for_exam(exam_id)
    review_policy_exception = ProctoredExamStudentAllowance.get_review_policy_exception(exam_id, user_id)

    if taking_as_proctored:
        content_id = exam['content_id'].split('@')[-1]  # get hash
        scheme = 'https' if getattr(settings, 'HTTPS', 'on') == 'on' else 'http'
        callback_url = '{scheme}://{hostname}{path}'.format(
            scheme=scheme,
            hostname=settings.SITE_NAME,
            path=reverse(
                'jump_to_id', 
                kwargs={'course_id': exam['course_id'], 'module_id': content_id}
            )
        )

        # get the name of the user, if the service is available
        full_name = None

        credit_service = get_runtime_service('credit')
        user = User.objects.get(pk=user_id)


        context = {
            'time_limit_mins': allowed_time_limit_mins,
            'attempt_code': attempt_code,
            'is_sample_attempt': exam['is_practice_exam'],
            'callback_url': callback_url,
            'user_id': user_id,
            'full_name': " ".join((user.first_name,user.last_name)),
            'username': user.username,
            'email': user.email
        }

        # see if there is an exam review policy for this exam
        # if so, then pass it into the provider
        if review_policy:
            context.update({
                'review_policy': review_policy.review_policy
            })

        # see if there is a review policy exception for this *user*
        # exceptions are granted on a individual basis as an
        # allowance
        if review_policy_exception:
            context.update({
                'review_policy_exception': review_policy_exception
            })

        # now call into the backend provider to register exam attempt
        provider_name = get_provider_name_by_course_id(exam['course_id'])
        external_id = get_backend_provider(provider_name).register_exam_attempt(
            exam,
            context=context,
        )

    attempt = ProctoredExamStudentAttempt.create_exam_attempt(
        exam_id,
        user_id,
        '',  # student name is TBD
        allowed_time_limit_mins,
        attempt_code,
        taking_as_proctored,
        exam['is_practice_exam'],
        external_id,
        review_policy_id=review_policy.id if review_policy else None,
    )

    log_msg = (
        '{attempt_code} - {username} ({email}) '
        'Created exam attempt ({attempt_id}) for exam_id {exam_id} for '
        'user_id {user_id} with taking as proctored = {taking_as_proctored} '
        'with allowed time limit minutes of {allowed_time_limit_mins}. '
        'external_id of {external_id}'.format(
            attempt_id=attempt.id, exam_id=exam_id, user_id=user_id,
            taking_as_proctored=taking_as_proctored,
            allowed_time_limit_mins=allowed_time_limit_mins,
            attempt_code=attempt_code,
            external_id=external_id,
            username=attempt.user.username,
            email=attempt.user.email
        )
    )
    log.info(log_msg)

    return attempt.id
Exemple #15
0
def get_student_view(user_id, course_id, content_id,
                     context, user_role='student'):
    """
    Helper method that will return the view HTML related to the exam control
    flow (i.e. entering, expired, completed, etc.) If there is no specific
    content to display, then None will be returned and the caller should
    render it's own view
    """

    # non-student roles should never see any proctoring related
    # screens
    if user_role != 'student':
        return None

    student_view_template = None

    exam_id = None
    try:
        exam = get_exam_by_content_id(course_id, content_id)
        if not exam['is_active']:
            # Exam is no longer active
            # Note, we don't hard delete exams since we need to retain
            # data
            return None

        exam_id = exam['id']
    except ProctoredExamNotFoundException:
        # This really shouldn't happen
        # as Studio will be setting this up
        exam_id = create_exam(
            course_id=course_id,
            content_id=unicode(content_id),
            exam_name=context['display_name'],
            time_limit_mins=context['default_time_limit_mins'],
            is_proctored=context.get('is_proctored', False),
            is_practice_exam=context.get('is_practice_exam', False)
        )
        exam = get_exam_by_content_id(course_id, content_id)

    is_proctored = exam['is_proctored']

    provider_name = get_provider_name_by_course_id(exam['course_id'])
    proctoring_settings = get_proctoring_settings(provider_name)
    # see if only 'verified' track students should see this *except* if it is a practice exam
    check_mode_and_eligibility = (
        proctoring_settings.get('MUST_BE_VERIFIED_TRACK', True) and
        'credit_state' in context and
        context['credit_state'] and not
        exam['is_practice_exam']
    )

    attempt = None
    if check_mode_and_eligibility:
        credit_state = context['credit_state']

        has_mode = _check_eligibility_of_enrollment_mode(credit_state)
        has_prerequisites = False
        if has_mode:
            has_prerequisites = _check_eligibility_of_prerequisites(credit_state)

        # see if the user has passed all pre-requisite credit eligibility
        # checks, otherwise just show the user the exam unproctored
        if not has_mode or not has_prerequisites:
            # if we are in the right mode and if we don't have
            # pre-requisites, then we implicitly decline the exam
            if has_mode:
                attempt = get_exam_attempt(exam_id, user_id)
                if not attempt:
                    # user hasn't a record of attempt, create one now
                    # so we can mark it as declined
                    create_exam_attempt(exam_id, user_id)

                    update_attempt_status(
                        exam_id,
                        user_id,
                        ProctoredExamStudentAttemptStatus.declined,
                        raise_if_not_found=False
                    )

            # don't override context, let the courseware show
            return None

    attempt = get_exam_attempt(exam_id, user_id)

    # if user has declined the attempt, then we don't show the
    # proctored exam
    if attempt and attempt['status'] == ProctoredExamStudentAttemptStatus.declined:
        return None

    does_time_remain = False
    has_started_exam = attempt and attempt.get('started_at')
    if has_started_exam:
        expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins'])
        does_time_remain = datetime.now(pytz.UTC) < expires_at

    if not attempt:
        # determine whether to show a timed exam only entrance screen
        # or a screen regarding proctoring

        if is_proctored:
            if exam['is_practice_exam']:
                student_view_template = 'proctoring/seq_proctored_practice_exam_entrance.html'
            else:
                student_view_template = 'proctoring/seq_proctored_exam_entrance.html'
        else:
            student_view_template = 'proctoring/seq_timed_exam_entrance.html'

    elif attempt['status'] == ProctoredExamStudentAttemptStatus.created:
        provider_name = get_provider_name_by_course_id(exam['course_id'])
        provider = get_backend_provider(provider_name)
        student_view_template = 'proctoring/seq_proctored_exam_instructions.html'
        context.update({
            'exam_code': attempt['attempt_code'],
            'software_download_url': provider.get_software_download_url(),
        })
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.ready_to_start:
        student_view_template = 'proctoring/seq_proctored_exam_ready_to_start.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.error:
        if attempt['is_sample_attempt']:
            student_view_template = 'proctoring/seq_proctored_practice_exam_error.html'
        else:
            student_view_template = 'proctoring/seq_proctored_exam_error.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.timed_out:
        student_view_template = 'proctoring/seq_timed_exam_expired.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.submitted:
        if attempt['is_sample_attempt']:
            student_view_template = 'proctoring/seq_proctored_practice_exam_submitted.html'
        else:
            student_view_template = 'proctoring/seq_proctored_exam_submitted.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.verified:
        student_view_template = 'proctoring/seq_proctored_exam_verified.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.rejected:
        student_view_template = 'proctoring/seq_proctored_exam_rejected.html'
    elif attempt['status'] == ProctoredExamStudentAttemptStatus.ready_to_submit:
        if is_proctored:
            student_view_template = 'proctoring/seq_proctored_exam_ready_to_submit.html'
        else:
            student_view_template = 'proctoring/seq_timed_exam_ready_to_submit.html'

    if student_view_template:
        template = loader.get_template(student_view_template)
        django_context = Context(context)
        attempt_time = attempt['allowed_time_limit_mins'] if attempt else exam['time_limit_mins']
        total_time = humanized_time(attempt_time)
        progress_page_url = ''
        try:
            progress_page_url = reverse(
                'courseware.views.progress',
                args=[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

        provider_name = get_provider_name_by_course_id(exam['course_id'])
        proctoring_settings = get_proctoring_settings(provider_name)

        django_context.update({
            'platform_name': settings.PLATFORM_NAME,
            'total_time': total_time,
            'exam_id': exam_id,
            'progress_page_url': progress_page_url,
            'is_sample_attempt': attempt['is_sample_attempt'] if attempt else False,
            'does_time_remain': does_time_remain,
            'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt.collection'),
            'exam_started_poll_url': reverse(
                'edx_proctoring.proctored_exam.attempt',
                args=[attempt['id']]
            ) if attempt else '',
            'change_state_url': reverse(
                'edx_proctoring.proctored_exam.attempt',
                args=[attempt['id']]
            ) if attempt else '',
            'link_urls': proctoring_settings.get('LINK_URLS', {}),
        })
        return template.render(django_context)

    return None