Esempio n. 1
0
    def post(self, request):
        """
        HTTP POST handler. To create an exam attempt.
        """
        start_immediately = request.data.get('start_clock', 'false').lower() == 'true'
        exam_id = request.data.get('exam_id', None)
        attempt_proctored = request.data.get('attempt_proctored', 'false').lower() == 'true'
        exam = get_exam_by_id(exam_id)

        # Bypassing the due date check for practice exam
        # because student can attempt the practice after the due date
        if not exam.get("is_practice_exam") and is_exam_passed_due(exam, request.user):
            raise ProctoredExamPermissionDenied(
                'Attempted to access expired exam with exam_id {exam_id}'.format(exam_id=exam_id)
            )

        exam_attempt_id = create_exam_attempt(
            exam_id=exam_id,
            user_id=request.user.id,
            taking_as_proctored=attempt_proctored
        )

        # if use elected not to take as proctored exam, then
        # use must take as open book, and loose credit eligibility
        if exam['is_proctored'] and not attempt_proctored:
            update_attempt_status(
                exam_id,
                request.user.id,
                ProctoredExamStudentAttemptStatus.declined
            )
        elif start_immediately:
            start_exam_attempt(exam_id, request.user.id)

        data = {'exam_attempt_id': exam_attempt_id}
        return Response(data)
Esempio n. 2
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.assertEqual(len(mail.outbox), 1)

        # Verify the subject
        actual_subject = self._normalize_whitespace(mail.outbox[0].subject)
        self.assertIn('Proctoring Review In Progress', actual_subject)
        self.assertIn(course_name, actual_subject)

        # Verify the body
        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('was submitted successfully', actual_body)
        self.assertIn(credit_state['course_name'], actual_body)
Esempio n. 3
0
    def test_get_studentview_unstarted_exam(self, allow_proctoring_opt_out):
        """
        Test for get_student_view proctored exam which has not started yet.
        """

        self._create_unstarted_exam_attempt()

        # Verify that the option to skip proctoring is shown if allowed
        rendered_response = self.render_proctored_exam({
            'allow_proctoring_opt_out': allow_proctoring_opt_out,
        })
        self.assertIn(self.chose_proctored_exam_msg, rendered_response)
        if allow_proctoring_opt_out:
            self.assertIn(self.proctored_exam_optout_msg, rendered_response)
        else:
            self.assertNotIn(self.proctored_exam_optout_msg, rendered_response)

        # Now make sure content remains the same if the status transitions
        # to 'download_software_clicked'.
        update_attempt_status(
            self.proctored_exam_id,
            self.user_id,
            ProctoredExamStudentAttemptStatus.download_software_clicked
        )
        rendered_response = self.render_proctored_exam()
        self.assertIn(self.chose_proctored_exam_msg, rendered_response)
        self.assertIn(self.proctored_exam_optout_msg, rendered_response)
Esempio 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
            )
        )
        self.stdout.write(msg)

        if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status):
            raise CommandError('{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)

        self.stdout.write('Completed!')
Esempio n. 5
0
    def test_send_email(self, status, expected_subject, expected_message_string):
        """
        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.assertEqual(len(mail.outbox), 1)

        # Verify the subject
        actual_subject = self._normalize_whitespace(mail.outbox[0].subject)
        self.assertIn(expected_subject, actual_subject)
        self.assertIn(self.exam_name, actual_subject)

        # Verify the body
        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('Hi tester,', actual_body)
        self.assertIn('Your proctored exam "Test Exam"', actual_body)
        self.assertIn(credit_state['course_name'], actual_body)
        self.assertIn(expected_message_string, actual_body)
Esempio n. 6
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!'
Esempio n. 7
0
    def post(self, request):
        """
        HTTP POST handler. To create an exam attempt.
        """
        start_immediately = request.data.get('start_clock',
                                             'false').lower() == 'true'
        exam_id = request.data.get('exam_id', None)
        attempt_proctored = request.data.get('attempt_proctored',
                                             'false').lower() == 'true'
        try:
            exam_attempt_id = create_exam_attempt(
                exam_id=exam_id,
                user_id=request.user.id,
                taking_as_proctored=attempt_proctored)

            exam = get_exam_by_id(exam_id)

            # if use elected not to take as proctored exam, then
            # use must take as open book, and loose credit eligibility
            if exam['is_proctored'] and not attempt_proctored:
                update_attempt_status(
                    exam_id, request.user.id,
                    ProctoredExamStudentAttemptStatus.declined)
            elif start_immediately:
                start_exam_attempt(exam_id, request.user.id)

            return Response({'exam_attempt_id': exam_attempt_id})

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(status=status.HTTP_400_BAD_REQUEST,
                            data={"detail": unicode(ex)})
Esempio n. 8
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.assertEqual(len(mail.outbox), 1)

        # Verify the subject
        actual_subject = self._normalize_whitespace(mail.outbox[0].subject)
        self.assertIn('Proctoring Review In Progress', actual_subject)
        self.assertIn(course_name, actual_subject)

        # Verify the body
        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('was submitted successfully', actual_body)
        self.assertIn(credit_state['course_name'], actual_body)
Esempio n. 9
0
 def test_email_not_sent(self, to_status):
     """
     Assert that an email is not sent for the following attempt status codes.
     """
     exam_attempt = self._create_exam_attempt(self.proctored_exam_id)
     update_attempt_status(exam_attempt.id, to_status)
     self.assertEqual(len(mail.outbox), 0)
Esempio n. 10
0
    def test_correct_edx_support_url(self, status, override_email):
        """
        Test that the correct edX support URL is used in emails. The email should use either the backend specific
        contact URL, if one is specified, or fall back to the edX contact us support page.
        """
        contact_url = 'http://{site_name}/support/contact_us'.format(
            site_name=SITE_NAME)
        backend_settings = settings.PROCTORING_BACKENDS

        if override_email:
            contact_url = 'www.example.com'
            backend_settings = deepcopy(backend_settings)
            backend_settings['test'] = {
                'LINK_URLS': {
                    'contact': contact_url,
                }
            }

        with self.settings(PROCTORING_BACKENDS=backend_settings):
            exam_attempt = self._create_started_exam_attempt()
            update_attempt_status(exam_attempt.id, status)

            # Verify the edX support URL
            actual_body = self._normalize_whitespace(mail.outbox[0].body)
            self.assertIn(
                u'<a href="{contact_url}"> '
                u'{contact_url} </a>'.format(contact_url=contact_url),
                actual_body)
Esempio n. 11
0
    def post(self, request):
        """
        HTTP POST handler. To create an exam attempt.
        """
        start_immediately = request.data.get('start_clock', 'false').lower() == 'true'
        exam_id = request.data.get('exam_id', None)
        attempt_proctored = request.data.get('attempt_proctored', 'false').lower() == 'true'
        exam = get_exam_by_id(exam_id)

        # Bypassing the due date check for practice exam
        # because student can attempt the practice after the due date
        if not exam.get("is_practice_exam") and is_exam_passed_due(exam, request.user):
            raise ProctoredExamPermissionDenied(
                u'Attempted to access expired exam with exam_id {exam_id}'.format(exam_id=exam_id)
            )

        exam_attempt_id = create_exam_attempt(
            exam_id=exam_id,
            user_id=request.user.id,
            taking_as_proctored=attempt_proctored
        )

        # if use elected not to take as proctored exam, then
        # use must take as open book, and loose credit eligibility
        if exam['is_proctored'] and not attempt_proctored:
            update_attempt_status(
                exam_id,
                request.user.id,
                ProctoredExamStudentAttemptStatus.declined
            )
        elif start_immediately:
            start_exam_attempt(exam_id, request.user.id)

        data = {'exam_attempt_id': exam_attempt_id}
        return Response(data)
    def test_get_studentview_unstarted_exam(self, allow_proctoring_opt_out):
        """
        Test for get_student_view proctored exam which has not started yet.
        """

        self._create_unstarted_exam_attempt()

        # Verify that the option to skip proctoring is shown if allowed
        rendered_response = self.render_proctored_exam({
            'allow_proctoring_opt_out':
            allow_proctoring_opt_out,
        })
        self.assertIn(self.chose_proctored_exam_msg, rendered_response)
        if allow_proctoring_opt_out:
            self.assertIn(self.proctored_exam_optout_msg, rendered_response)
        else:
            self.assertNotIn(self.proctored_exam_optout_msg, rendered_response)

        # Now make sure content remains the same if the status transitions
        # to 'download_software_clicked'.
        update_attempt_status(
            self.proctored_exam_id, self.user_id,
            ProctoredExamStudentAttemptStatus.download_software_clicked)
        rendered_response = self.render_proctored_exam()
        self.assertIn(self.chose_proctored_exam_msg, rendered_response)
        self.assertIn(self.proctored_exam_optout_msg, rendered_response)
Esempio n. 13
0
    def post(self, request):
        """
        HTTP POST handler. To create an exam attempt.
        """
        start_immediately = request.DATA.get('start_clock', 'false').lower() == 'true'
        exam_id = request.DATA.get('exam_id', None)
        attempt_proctored = request.DATA.get('attempt_proctored', 'false').lower() == 'true'
        try:
            exam_attempt_id = create_exam_attempt(
                exam_id=exam_id,
                user_id=request.user.id,
                taking_as_proctored=attempt_proctored
            )

            exam = get_exam_by_id(exam_id)

            # if use elected not to take as proctored exam, then
            # use must take as open book, and loose credit eligibility
            if exam['is_proctored'] and not attempt_proctored:
                update_attempt_status(
                    exam_id,
                    request.user.id,
                    ProctoredExamStudentAttemptStatus.declined
                )
            elif start_immediately:
                start_exam_attempt(exam_id, request.user.id)

            return Response({'exam_attempt_id': exam_attempt_id})

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"detail": unicode(ex)}
            )
    def handle(self, *args, **options):
        """
        Management command entry point, simply call into the signal firiing
        """
        # pylint: disable=import-outside-toplevel
        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 = (u'Running management command to update user {user_id} '
               u'attempt status on exam_id {exam_id} to {to_status}'.format(
                   user_id=user_id, exam_id=exam_id, to_status=to_status))
        self.stdout.write(msg)

        if not ProctoredExamStudentAttemptStatus.is_valid_status(to_status):
            raise CommandError(
                u'{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)

        self.stdout.write('Completed!')
Esempio n. 15
0
    def test_send_email(self, status, expected_subject, expected_message_string):
        """
        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.assertEqual(len(mail.outbox), 1)

        # Verify the subject
        actual_subject = self._normalize_whitespace(mail.outbox[0].subject)
        self.assertIn(expected_subject, actual_subject)
        self.assertIn(self.exam_name, actual_subject)

        # Verify the body
        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('Hello tester,', actual_body)
        self.assertIn('Your proctored exam "Test Exam"', actual_body)
        self.assertIn(credit_state['course_name'], actual_body)
        self.assertIn(expected_message_string, actual_body)
Esempio n. 16
0
def finish_review_workflow(sender, instance, signal, **kwargs):  # pylint: disable=unused-argument
    """
    Updates the attempt status based on the review status
    Also notifies support about suspicious reviews.
    """
    review = instance
    attempt_obj, is_archived = locate_attempt_by_attempt_code(review.attempt_code)
    attempt = api.ProctoredExamStudentAttemptSerializer(attempt_obj).data

    # we could have gotten a review for an archived attempt
    # this should *not* cause an update in our credit
    # eligibility table
    if review.review_status in SoftwareSecureReviewStatus.passing_statuses:
        attempt_status = ProctoredExamStudentAttemptStatus.verified
    elif review.reviewed_by or not constants.REQUIRE_FAILURE_SECOND_REVIEWS:
        # reviews from the django admin have a reviewer set. They should be allowed to
        # reject an attempt
        attempt_status = ProctoredExamStudentAttemptStatus.rejected
    else:
        # if we are not allowed to store 'rejected' on this
        # code path, then put status into 'second_review_required'
        attempt_status = ProctoredExamStudentAttemptStatus.second_review_required

    if review.review_status in SoftwareSecureReviewStatus.notify_support_for_status:
        instructor_service = api.get_runtime_service('instructor')
        request = get_current_request()
        if instructor_service and request:
            course_id = attempt['proctored_exam']['course_id']
            exam_id = attempt['proctored_exam']['id']
            review_url = request.build_absolute_uri(
                u'{}?attempt={}'.format(
                    reverse('edx_proctoring:instructor_dashboard_exam', args=[course_id, exam_id]),
                    attempt['external_id']
                ))
            instructor_service.send_support_notification(
                course_id=attempt['proctored_exam']['course_id'],
                exam_name=attempt['proctored_exam']['exam_name'],
                student_username=attempt['user']['username'],
                review_status=review.review_status,
                review_url=review_url,
            )

    if not is_archived:
        # updating attempt status will trigger workflow
        # (i.e. updating credit eligibility table)
        # archived attempts should not trigger the workflow
        api.update_attempt_status(
            attempt['proctored_exam']['id'],
            attempt['user']['id'],
            attempt_status,
            raise_if_not_found=False
        )

    # emit an event for 'review_received'
    data = {
        'review_attempt_code': review.attempt_code,
        'review_status': review.review_status,
    }
    emit_event(attempt['proctored_exam'], 'review_received', attempt=attempt, override_data=data)
Esempio n. 17
0
    def get(self, request, attempt_id):
        """
        HTTP GET Handler. Returns the status of the exam attempt.
        """

        try:
            attempt = get_exam_attempt_by_id(attempt_id)

            if not attempt:
                err_msg = ('Attempted to access attempt_id {attempt_id} but '
                           'it does not exist.'.format(attempt_id=attempt_id))
                return Response(status=status.HTTP_400_BAD_REQUEST)

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

            # check if the last_poll_timestamp is not None
            # and if it is older than CLIENT_TIMEOUT
            # then attempt status should be marked as error.
            last_poll_timestamp = attempt['last_poll_timestamp']
            if last_poll_timestamp is not None \
                    and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > CLIENT_TIMEOUT:
                try:
                    update_attempt_status(
                        attempt['proctored_exam']['id'], attempt['user']['id'],
                        ProctoredExamStudentAttemptStatus.error)
                    attempt['status'] = ProctoredExamStudentAttemptStatus.error
                except ProctoredExamIllegalStatusTransition:
                    # don't transition a completed state to an error state
                    pass

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

            attempt['time_remaining_seconds'] = time_remaining_seconds

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

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

            attempt['accessibility_time_string'] = accessibility_time_string

            return Response(data=attempt, status=status.HTTP_200_OK)

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(status=status.HTTP_400_BAD_REQUEST,
                            data={"detail": str(ex)})
Esempio n. 18
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        attempt = get_exam_attempt_by_id(attempt_id)

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

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

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

        if action == 'stop':
            exam_attempt_id = stop_exam_attempt(
                exam_id=attempt['proctored_exam']['id'],
                user_id=request.user.id)
        elif action == 'start':
            exam_attempt_id = start_exam_attempt(
                exam_id=attempt['proctored_exam']['id'],
                user_id=request.user.id)
        elif action == 'submit':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'], request.user.id,
                ProctoredExamStudentAttemptStatus.submitted)
        elif action == 'click_download_software':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'], request.user.id,
                ProctoredExamStudentAttemptStatus.download_software_clicked)
        elif action == 'error':
            backend = attempt['proctored_exam']['backend']
            waffle_name = PING_FAILURE_PASSTHROUGH_TEMPLATE.format(backend)
            should_block_user = not (
                backend and waffle.switch_is_active(waffle_name)) and (
                    not attempt['status']
                    == ProctoredExamStudentAttemptStatus.submitted)
            if should_block_user:
                exam_attempt_id = update_attempt_status(
                    attempt['proctored_exam']['id'], request.user.id,
                    ProctoredExamStudentAttemptStatus.error)
            else:
                exam_attempt_id = False
            LOG.warning(
                u'Browser JS reported problem with proctoring desktop '
                u'application. Did block user: %s, for attempt: %s',
                should_block_user, attempt['id'])
        elif action == 'decline':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'], request.user.id,
                ProctoredExamStudentAttemptStatus.declined)
        data = {"exam_attempt_id": exam_attempt_id}
        return Response(data)
Esempio n. 19
0
    def test_not_send_email_timed_exam(self, status):
        """
        Assert that email is not sent when exam is timed/not-proctoring
        """

        exam_attempt = self._create_started_exam_attempt(is_proctored=False)
        update_attempt_status(exam_attempt.id, status)
        self.assertEqual(len(mail.outbox), 0)
Esempio n. 20
0
    def get(self, request, attempt_id):
        """
        HTTP GET Handler. Returns the status of the exam attempt.
        """

        try:
            attempt = get_exam_attempt_by_id(attempt_id)

            if not attempt:
                err_msg = (
                    'Attempted to access attempt_id {attempt_id} but '
                    'it does not exist.'.format(
                        attempt_id=attempt_id
                    )
                )
                return Response(
                    status=status.HTTP_400_BAD_REQUEST
                )

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

            # check if the last_poll_timestamp is not None
            # and if it is older than SOFTWARE_SECURE_CLIENT_TIMEOUT
            # then attempt status should be marked as error.
            last_poll_timestamp = attempt['last_poll_timestamp']
            if last_poll_timestamp is not None \
                    and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > SOFTWARE_SECURE_CLIENT_TIMEOUT:
                attempt['status'] = 'error'
                update_attempt_status(
                    attempt['proctored_exam']['id'],
                    attempt['user']['id'],
                    ProctoredExamStudentAttemptStatus.error
                )

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

            attempt['time_remaining_seconds'] = time_remaining_seconds

            return Response(
                data=attempt,
                status=status.HTTP_200_OK
            )

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"detail": str(ex)}
            )
Esempio n. 21
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        try:
            attempt = get_exam_attempt_by_id(attempt_id)

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

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

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

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

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"detail": str(ex)}
            )
Esempio n. 22
0
 def save_model(self, request, obj, form, change):
     """
     Override callback so that we can change the status by "update_attempt_status" function
     """
     try:
         if change:
             update_attempt_status(obj.proctored_exam.id, obj.user.id, form.cleaned_data['status'])
     except (ProctoredExamIllegalStatusTransition, StudentExamAttemptDoesNotExistsException) as ex:
         messages.error(request, ex.message)
Esempio n. 23
0
 def save_model(self, request, obj, form, change):
     """
     Override callback so that we can change the status by "update_attempt_status" function
     """
     try:
         if change:
             update_attempt_status(obj.proctored_exam.id, obj.user.id, form.cleaned_data['status'])
     except (ProctoredExamIllegalStatusTransition, StudentExamAttemptDoesNotExistsException) as ex:
         messages.error(request, ex.message)
Esempio n. 24
0
    def test_not_send_email_sample_exam(self, status):
        """
        Assert that email is not sent when there is practice/sample exam
        """

        exam_attempt = self._create_started_exam_attempt(
            is_sample_attempt=True)
        update_attempt_status(exam_attempt.id, status)
        self.assertEqual(len(mail.outbox), 0)
Esempio n. 25
0
    def test_email_not_sent(self, status):
        """
        Assert than email is not sent on the following statuses of proctoring attempt
        """

        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(exam_attempt.proctored_exam_id, self.user.id,
                              status)
        self.assertEquals(len(mail.outbox), 0)
Esempio n. 26
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.

    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:
        log.warning(u"Attempt code %r cannot be found.", attempt_code)
        return HttpResponse(
            content='You have entered an exam code that is not valid.',
            status=404
        )
    proctored_exam_id = attempt['proctored_exam']['id']
    attempt_status = attempt['status']
    user_id = attempt['user']['id']
    if attempt_status in [ProctoredExamStudentAttemptStatus.created,
                          ProctoredExamStudentAttemptStatus.download_software_clicked]:
        mark_exam_attempt_as_ready(proctored_exam_id, user_id)

    # if a user attempts to re-enter an exam that has not yet been submitted, submit the exam
    if ProctoredExamStudentAttemptStatus.is_in_progress_status(attempt_status):
        update_attempt_status(proctored_exam_id, user_id, ProctoredExamStudentAttemptStatus.submitted)
    else:
        log.warning(u"Attempted to enter proctored exam attempt {attempt_id} when status was {attempt_status}"
                    .format(
                        attempt_id=attempt['id'],
                        attempt_status=attempt_status,
                    ))

    if switch_is_active(RPNOWV4_WAFFLE_NAME):  # pylint: disable=illegal-waffle-usage
        course_id = attempt['proctored_exam']['course_id']
        content_id = attempt['proctored_exam']['content_id']

        exam_url = ''
        try:
            exam_url = reverse('jump_to', args=[course_id, content_id])
        except NoReverseMatch:
            log.exception(u"BLOCKING ERROR: Can't find course info url for course %s", course_id)
        response = HttpResponseRedirect(exam_url)
        response.set_signed_cookie('exam', attempt['attempt_code'])
        return response

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

    return HttpResponse(
        template.render({
            'platform_name': settings.PLATFORM_NAME,
            'link_urls': settings.PROCTORING_SETTINGS.get('LINK_URLS', {})
        })
    )
Esempio n. 27
0
    def test_not_send_email_timed_exam(self, status):
        """
        Assert that email is not sent when exam is timed/not-proctoring
        """

        exam_attempt = self._create_started_exam_attempt(is_proctored=False)
        update_attempt_status(
            exam_attempt.proctored_exam_id,
            self.user.id,
            status
        )
        self.assertEqual(len(mail.outbox), 0)
Esempio n. 28
0
    def test_not_send_email_sample_exam(self, status):
        """
        Assert that email is not sent when there is practice/sample exam
        """

        exam_attempt = self._create_started_exam_attempt(is_sample_attempt=True)
        update_attempt_status(
            exam_attempt.proctored_exam_id,
            self.user.id,
            status
        )
        self.assertEqual(len(mail.outbox), 0)
Esempio n. 29
0
    def test_email_not_sent(self, status):
        """
        Assert that an email is not sent for the following attempt status codes.
        """

        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(
            exam_attempt.proctored_exam_id,
            self.user.id,
            status
        )
        self.assertEqual(len(mail.outbox), 0)
Esempio n. 30
0
    def test_escalation_email_not_included(self, status):
        """
        Test that verified and rejected emails link to support if an escalation email is
        not given.
        """
        set_runtime_service('instructor', None)

        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(exam_attempt.id, status)

        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('support/contact_us', actual_body)
Esempio n. 31
0
 def save_model(self, request, obj, form, change):
     """
     Override callback so that we can change the status by "update_attempt_status" function
     """
     try:
         if change:
             update_attempt_status(obj.id, form.cleaned_data['status'])
     except (ProctoredExamIllegalStatusTransition,
             StudentExamAttemptDoesNotExistsException) as ex:
         # prevent showing success message inappropriately
         messages.set_level(request, messages.ERROR)
         messages.error(request, str(ex))
Esempio n. 32
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        try:
            attempt_id = int(attempt_id)
            attempt = get_exam_attempt_by_id(attempt_id)
        except:
            attempt = get_exam_attempt_by_code(attempt_id)

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

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

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

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

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(status=status.HTTP_400_BAD_REQUEST,
                            data={"detail": str(ex)})
Esempio n. 33
0
    def test_correct_edx_support_url(self, status):
        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(exam_attempt.proctored_exam_id, self.user.id,
                              status)

        # Verify the edX support URL
        contact_url = 'http://{site_name}/support/contact_us'.format(
            site_name=SITE_NAME)
        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn(
            u'You can also reach Open edX Support at '
            u'<a href="{contact_url}"> '
            u'{contact_url} </a>'.format(contact_url=contact_url), actual_body)
Esempio n. 34
0
    def test_proctoring_escalation_email_exceptions(self, error):
        """
        Test that when an error is raised when trying to retrieve a proctoring escalation
        email, it sets `proctoring_escalation_email` to None and link to support is used instead
        """
        instructor_service = get_runtime_service('instructor')
        instructor_service.mock_proctoring_escalation_email_error(error)

        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(exam_attempt.id,
                              ProctoredExamStudentAttemptStatus.verified)

        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('support/contact_us', actual_body)
Esempio n. 35
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.

    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:
        log.warning('attempt_code={attempt_code} cannot be found.'.format(
            attempt_code=attempt_code))
        return HttpResponse(
            content='You have entered an exam code that is not valid.',
            status=404)
    attempt_status = attempt['status']
    if attempt_status in [
            ProctoredExamStudentAttemptStatus.created,
            ProctoredExamStudentAttemptStatus.download_software_clicked
    ]:
        mark_exam_attempt_as_ready(attempt['id'])

    # if a user attempts to re-enter an exam that has not yet been submitted, submit the exam
    if ProctoredExamStudentAttemptStatus.is_in_progress_status(attempt_status):
        update_attempt_status(attempt['id'],
                              ProctoredExamStudentAttemptStatus.submitted)
    else:
        log.warning(
            'Attempted to enter proctored exam attempt_id={attempt_id} when status={attempt_status}'
            .format(
                attempt_id=attempt['id'],
                attempt_status=attempt_status,
            ))

    course_id = attempt['proctored_exam']['course_id']
    content_id = attempt['proctored_exam']['content_id']

    exam_url = ''
    try:
        exam_url = reverse('jump_to', args=[course_id, content_id])
    except NoReverseMatch:
        log.exception(
            "BLOCKING ERROR: Can't find course info url for course_id=%s",
            course_id)
    response = HttpResponseRedirect(exam_url)
    response.set_signed_cookie('exam', attempt['attempt_code'])
    return response
Esempio n. 36
0
def finish_review_workflow(sender, instance, signal, **kwargs):  # pylint: disable=unused-argument
    """
    Updates the attempt status based on the review status
    """
    review = instance
    attempt_obj, is_archived = locate_attempt_by_attempt_code(
        review.attempt_code)
    attempt = api.ProctoredExamStudentAttemptSerializer(attempt_obj).data
    backend = get_backend_provider(attempt['proctored_exam'])

    # we could have gotten a review for an archived attempt
    # this should *not* cause an update in our credit
    # eligibility table
    if review.is_passing:
        attempt_status = ProctoredExamStudentAttemptStatus.verified
    elif review.review_status == SoftwareSecureReviewStatus.not_reviewed:
        attempt_status = ProctoredExamStudentAttemptStatus.error
    elif review.reviewed_by or not constants.REQUIRE_FAILURE_SECOND_REVIEWS:
        # reviews from the django admin have a reviewer set. They should be allowed to
        # reject an attempt
        attempt_status = ProctoredExamStudentAttemptStatus.rejected
    elif backend and backend.supports_onboarding and attempt[
            'is_sample_attempt']:
        attempt_status = ProctoredExamStudentAttemptStatus.rejected
    else:
        # if we are not allowed to store 'rejected' on this
        # code path, then put status into 'second_review_required'
        attempt_status = ProctoredExamStudentAttemptStatus.second_review_required

    if not is_archived:
        # updating attempt status will trigger workflow
        # (i.e. updating credit eligibility table)
        # archived attempts should not trigger the workflow
        api.update_attempt_status(attempt['proctored_exam']['id'],
                                  attempt['user']['id'],
                                  attempt_status,
                                  raise_if_not_found=False,
                                  update_attributable_to=review.reviewed_by
                                  or None)

    # emit an event for 'review_received'
    data = {
        'review_attempt_code': review.attempt_code,
        'review_status': review.review_status,
    }
    emit_event(attempt['proctored_exam'],
               'review_received',
               attempt=attempt,
               override_data=data)
Esempio n. 37
0
    def put(self, request, attempt_code):
        """
        HTTP POST handler. To stop an exam.
        """
        try:
            attempt = get_exam_attempt_by_code(attempt_code)

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

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

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

            return Response({"exam_attempt_id": exam_attempt_id})

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"detail": str(ex)}
            )
Esempio n. 38
0
    def test_escalation_email_included(self, status):
        """
        Test that verified and rejected emails include a proctoring escalation email if given.
        """
        instructor_service = get_runtime_service('instructor')
        mock_escalation_email = '*****@*****.**'
        instructor_service.mock_proctoring_escalation_email(
            mock_escalation_email)

        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(exam_attempt.id, status)

        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn(mock_escalation_email, actual_body)
        self.assertNotIn('support/contact_us', actual_body)
Esempio n. 39
0
    def test_email_template_select(self, status, template_name,
                                   select_template_mock):
        """
        Assert that we search for the correct email templates, including any backend specific overriding templates
        and the base template.
        """
        exam_attempt = self._create_started_exam_attempt()
        update_attempt_status(exam_attempt.id, status)

        expected_args = [
            'emails/proctoring/{backend}/{template_name}'.format(
                backend=exam_attempt.proctored_exam.backend,
                template_name=template_name),
            'emails/{template_name}'.format(template_name=template_name)
        ]
        select_template_mock.assert_called_once_with(expected_args)
Esempio n. 40
0
    def on_review_saved(self, review, allow_rejects=False):  # pylint: disable=arguments-differ
        """
        called when a review has been save - either through API (on_review_callback) or via Django Admin panel
        in order to trigger any workflow associated with proctoring review results
        """

        (attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(review.attempt_code)

        if not attempt_obj:
            # This should not happen, but it is logged in the help
            # method
            return

        if is_archived_attempt:
            # we don't trigger workflow on reviews on archived attempts
            err_msg = (
                'Got on_review_save() callback for an archived attempt with '
                'attempt_code {attempt_code}. Will not trigger workflow...'.format(
                    attempt_code=review.attempt_code
                )
            )
            log.warn(err_msg)
            return

        # only 'Clean' and 'Rules Violation' count as passing
        status = (
            ProctoredExamStudentAttemptStatus.verified
            if review.review_status in self.passing_review_status
            else (
                # if we are not allowed to store 'rejected' on this
                # code path, then put status into 'second_review_required'
                ProctoredExamStudentAttemptStatus.rejected if allow_rejects else
                ProctoredExamStudentAttemptStatus.second_review_required
            )
        )

        # updating attempt status will trigger workflow
        # (i.e. updating credit eligibility table)
        from edx_proctoring.api import update_attempt_status

        update_attempt_status(
            attempt_obj.proctored_exam_id,
            attempt_obj.user_id,
            status
        )
Esempio n. 41
0
    def on_review_saved(self, review, allow_rejects=False):  # pylint: disable=arguments-differ
        """
        called when a review has been save - either through API (on_review_callback) or via Django Admin panel
        in order to trigger any workflow associated with proctoring review results
        """

        (attempt_obj, is_archived_attempt) = locate_attempt_by_attempt_code(review.attempt_code)

        if not attempt_obj:
            # This should not happen, but it is logged in the help
            # method
            return

        if is_archived_attempt:
            # we don't trigger workflow on reviews on archived attempts
            err_msg = (
                'Got on_review_save() callback for an archived attempt with '
                'attempt_code {attempt_code}. Will not trigger workflow...'.format(
                    attempt_code=review.attempt_code
                )
            )
            log.warn(err_msg)
            return

        # only 'Clean' and 'Rules Violation' count as passing
        status = (
            ProctoredExamStudentAttemptStatus.verified
            if review.review_status in self.passing_review_status
            else (
                # if we are not allowed to store 'rejected' on this
                # code path, then put status into 'second_review_required'
                ProctoredExamStudentAttemptStatus.rejected if allow_rejects else
                ProctoredExamStudentAttemptStatus.second_review_required
            )
        )

        # updating attempt status will trigger workflow
        # (i.e. updating credit eligibility table)
        from edx_proctoring.api import update_attempt_status

        update_attempt_status(
            attempt_obj.proctored_exam_id,
            attempt_obj.user_id,
            status
        )
Esempio n. 42
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        try:
            attempt = get_exam_attempt_by_id(attempt_id)

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

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

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

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

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ex)})
Esempio n. 43
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)
    def setup_proctored_exam(self, block, attempt_status, user_id):
        """
        Test helper to configure the given block as a proctored exam.
        """
        exam_id = create_exam(
            course_id=unicode(block.location.course_key),
            content_id=unicode(block.location),
            exam_name='foo',
            time_limit_mins=10,
            is_proctored=True,
            is_practice_exam=block.is_practice_exam,
        )

        set_runtime_service('credit',
                            MockCreditService(enrollment_mode='verified'))

        create_exam_attempt(exam_id, user_id, taking_as_proctored=True)
        update_attempt_status(exam_id, user_id, attempt_status)
    def test_get_studentview_submitted_status(self):
        """
        Test for get_student_view proctored exam which has been submitted.
        """
        exam_attempt = self._create_started_exam_attempt()
        exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
        exam_attempt.save()

        rendered_response = self.render_proctored_exam()
        self.assertIn(self.proctored_exam_submitted_msg, rendered_response)

        # now make sure if this status transitions to 'second_review_required'
        # the student will still see a 'submitted' message
        update_attempt_status(
            exam_attempt.proctored_exam_id, exam_attempt.user_id,
            ProctoredExamStudentAttemptStatus.second_review_required)
        rendered_response = self.render_proctored_exam()
        self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
Esempio n. 46
0
def finish_review_workflow(sender, instance, signal, **kwargs):  # pylint: disable=unused-argument
    """
    Updates the attempt status based on the review status
    """
    review = instance
    attempt_obj, is_archived = locate_attempt_by_attempt_code(review.attempt_code)
    attempt = api.ProctoredExamStudentAttemptSerializer(attempt_obj).data
    backend = get_backend_provider(attempt['proctored_exam'])

    # we could have gotten a review for an archived attempt
    # this should *not* cause an update in our credit
    # eligibility table
    if review.is_passing:
        attempt_status = ProctoredExamStudentAttemptStatus.verified
    elif review.review_status == SoftwareSecureReviewStatus.not_reviewed:
        attempt_status = ProctoredExamStudentAttemptStatus.error
    elif review.reviewed_by or not constants.REQUIRE_FAILURE_SECOND_REVIEWS:
        # reviews from the django admin have a reviewer set. They should be allowed to
        # reject an attempt
        attempt_status = ProctoredExamStudentAttemptStatus.rejected
    elif backend and backend.supports_onboarding and attempt['is_sample_attempt']:
        attempt_status = ProctoredExamStudentAttemptStatus.rejected
    else:
        # if we are not allowed to store 'rejected' on this
        # code path, then put status into 'second_review_required'
        attempt_status = ProctoredExamStudentAttemptStatus.second_review_required

    if not is_archived:
        # updating attempt status will trigger workflow
        # (i.e. updating credit eligibility table)
        # archived attempts should not trigger the workflow
        api.update_attempt_status(
            attempt['proctored_exam']['id'],
            attempt['user']['id'],
            attempt_status,
            raise_if_not_found=False
        )

    # emit an event for 'review_received'
    data = {
        'review_attempt_code': review.attempt_code,
        'review_status': review.review_status,
    }
    emit_event(attempt['proctored_exam'], 'review_received', attempt=attempt, override_data=data)
    def setup_proctored_exam(self, block, attempt_status, user_id):
        """
        Test helper to configure the given block as a proctored exam.
        """
        exam_id = create_exam(
            course_id=unicode(block.location.course_key),
            content_id=unicode(block.location),
            exam_name='foo',
            time_limit_mins=10,
            is_proctored=True,
            is_practice_exam=block.is_practice_exam,
        )

        set_runtime_service(
            'credit',
            MockCreditService(enrollment_mode='verified')
        )

        create_exam_attempt(exam_id, user_id, taking_as_proctored=True)
        update_attempt_status(exam_id, user_id, attempt_status)
Esempio n. 48
0
    def test_get_studentview_submitted_status(self):
        """
        Test for get_student_view proctored exam which has been submitted.
        """
        exam_attempt = self._create_started_exam_attempt()
        exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
        exam_attempt.save()

        rendered_response = self.render_proctored_exam()
        self.assertIn(self.proctored_exam_submitted_msg, rendered_response)

        # now make sure if this status transitions to 'second_review_required'
        # the student will still see a 'submitted' message
        update_attempt_status(
            exam_attempt.proctored_exam_id,
            exam_attempt.user_id,
            ProctoredExamStudentAttemptStatus.second_review_required
        )
        rendered_response = self.render_proctored_exam()
        self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
Esempio n. 49
0
    def test_correct_edx_email(self, status, integration_specific_email,):
        exam_attempt = self._create_started_exam_attempt()

        test_backend = get_backend_provider(name='test')

        test_backend.integration_specific_email = integration_specific_email

        update_attempt_status(
            exam_attempt.proctored_exam_id,
            self.user.id,
            status
        )

        # Verify the edX email
        expected_email = get_integration_specific_email(test_backend)
        actual_body = self._normalize_whitespace(mail.outbox[0].body)
        self.assertIn('contact Open edX support at '
                      '<a href="mailto:{email}?Subject=Proctored exam Test Exam in edx demo for user tester"> '
                      '{email} </a>'.format(email=expected_email),
                      actual_body)
Esempio n. 50
0
    def get(self, request, attempt_id):
        """
        HTTP GET Handler. Returns the status of the exam attempt.
        """

        try:
            attempt = get_exam_attempt_by_id(attempt_id)

            if not attempt:
                err_msg = (
                    'Attempted to access attempt_id {attempt_id} but '
                    'it does not exist.'.format(
                        attempt_id=attempt_id
                    )
                )
                return Response(
                    status=status.HTTP_400_BAD_REQUEST
                )

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

            # check if the last_poll_timestamp is not None
            # and if it is older than CLIENT_TIMEOUT
            # then attempt status should be marked as error.
            last_poll_timestamp = attempt['last_poll_timestamp']
            if last_poll_timestamp is not None \
                    and (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds() > CLIENT_TIMEOUT:
                try:
                    update_attempt_status(
                        attempt['proctored_exam']['id'],
                        attempt['user']['id'],
                        ProctoredExamStudentAttemptStatus.error
                    )
                    attempt['status'] = ProctoredExamStudentAttemptStatus.error
                except ProctoredExamIllegalStatusTransition:
                    # don't transition a completed state to an error state
                    pass

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

            attempt['time_remaining_seconds'] = time_remaining_seconds

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

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

            attempt['accessibility_time_string'] = accessibility_time_string

            return Response(
                data=attempt,
                status=status.HTTP_200_OK
            )

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

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

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

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

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

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

        bad_status = review_status not in [
            'Not Reviewed', 'Suspicious', 'Rules Violation', 'Clean'
        ]

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

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

        attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_code(attempt_code)

        is_archived_attempt = False
        if not attempt_obj:
            # try archive table
            attempt_obj = ProctoredExamStudentAttemptHistory.get_exam_attempt_by_code(attempt_code)
            is_archived_attempt = True

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

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

        # do we already have a review for this attempt?!? It should not be updated!
        review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code)

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

        # do some limited parsing of the JSON payload
        review_status = payload['reviewStatus']
        video_review_link = payload['videoReviewLink']

        # make a new record in the review table
        review = ProctoredExamSoftwareSecureReview(
            attempt_code=attempt_code,
            raw_data=json.dumps(payload),
            review_status=review_status,
            video_url=video_review_link,
        )
        review.save()

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

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

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

            from edx_proctoring.api import update_attempt_status

            # only 'Clean' and 'Rules Violation' could as passing
            status = (
                ProctoredExamStudentAttemptStatus.verified
                if review_status in ['Clean', 'Suspicious']
                else ProctoredExamStudentAttemptStatus.rejected
            )

            update_attempt_status(
                attempt_obj.proctored_exam_id,
                attempt_obj.user_id,
                status
            )
Esempio n. 52
0
    def get(self, request, attempt_id):
        """
        HTTP GET Handler. Returns the status of the exam attempt.
        """

        try:
            attempt = get_exam_attempt_by_id(attempt_id)

            if not attempt:
                err_msg = (
                    'Attempted to access attempt_id {attempt_id} but '
                    'it does not exist.'.format(
                        attempt_id=attempt_id
                    )
                )
                return Response(
                    status=status.HTTP_400_BAD_REQUEST
                )

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

            # check if the last_poll_timestamp is not None
            # and if it is older than SOFTWARE_SECURE_CLIENT_TIMEOUT
            # then attempt status should be marked as error.
            last_poll_timestamp = attempt['last_poll_timestamp']

            # if we never heard from the client, then we assume it is shut down
            attempt['client_has_shutdown'] = last_poll_timestamp is None

            if last_poll_timestamp is not None:
                # Let's pass along information if we think the SoftwareSecure has completed
                # a healthy shutdown which is when our attempt is in a 'submitted' status
                if attempt['status'] == ProctoredExamStudentAttemptStatus.submitted:
                    attempt['client_has_shutdown'] = has_client_app_shutdown(attempt)
                else:
                    # otherwise, let's see if the shutdown happened in error
                    # e.g. a crash
                    time_passed_since_last_poll = (datetime.now(pytz.UTC) - last_poll_timestamp).total_seconds()
                    if time_passed_since_last_poll > constants.SOFTWARE_SECURE_CLIENT_TIMEOUT:
                        try:
                            update_attempt_status(
                                attempt['proctored_exam']['id'],
                                attempt['user']['id'],
                                ProctoredExamStudentAttemptStatus.error
                            )
                            attempt['status'] = ProctoredExamStudentAttemptStatus.error
                        except ProctoredExamIllegalStatusTransition:
                            # don't transition a completed state to an error state
                            pass

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

            attempt['time_remaining_seconds'] = time_remaining_seconds

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

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

            attempt['accessibility_time_string'] = accessibility_time_string

            return Response(
                data=attempt,
                status=status.HTTP_200_OK
            )

        except ProctoredBaseException, ex:
            LOG.exception(ex)
            return Response(
                status=status.HTTP_400_BAD_REQUEST,
                data={"detail": str(ex)}
            )
Esempio n. 53
0
    def put(self, request, attempt_id):
        """
        HTTP POST handler. To stop an exam.
        """
        attempt = get_exam_attempt_by_id(attempt_id)

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

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

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

        if action == 'stop':
            exam_attempt_id = stop_exam_attempt(
                exam_id=attempt['proctored_exam']['id'],
                user_id=request.user.id
            )
        elif action == 'start':
            exam_attempt_id = start_exam_attempt(
                exam_id=attempt['proctored_exam']['id'],
                user_id=request.user.id
            )
        elif action == 'submit':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'],
                request.user.id,
                ProctoredExamStudentAttemptStatus.submitted
            )
        elif action == 'click_download_software':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'],
                request.user.id,
                ProctoredExamStudentAttemptStatus.download_software_clicked
            )
        elif action == 'error':
            backend = attempt['proctored_exam']['backend']
            waffle_name = PING_FAILURE_PASSTHROUGH_TEMPLATE.format(backend)
            should_block_user = not (backend and waffle.switch_is_active(waffle_name)) and (
                not attempt['status'] == ProctoredExamStudentAttemptStatus.submitted
            )
            if should_block_user:
                exam_attempt_id = update_attempt_status(
                    attempt['proctored_exam']['id'],
                    request.user.id,
                    ProctoredExamStudentAttemptStatus.error
                )
            else:
                exam_attempt_id = False
            LOG.warning(u'Browser JS reported problem with proctoring desktop '
                        u'application. Did block user: %s, for attempt: %s',
                        should_block_user,
                        attempt['id'])
        elif action == 'decline':
            exam_attempt_id = update_attempt_status(
                attempt['proctored_exam']['id'],
                request.user.id,
                ProctoredExamStudentAttemptStatus.declined
            )
        data = {"exam_attempt_id": exam_attempt_id}
        return Response(data)