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)
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()
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()
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', {}) }) ) )
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', {}) }) ) )
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', {}) })))
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', {}) })))
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)
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)
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
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
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
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
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
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