def get_all_exams_for_course(course_id): """ This method will return all exams for a course. This will return a list of dictionaries, whose schema is the same as what is returned in get_exam_by_id Returns a list containing dictionary version of the Django ORM object e.g. [{ "course_id": "edX/DemoX/Demo_Course", "content_id": "123", "external_id": "", "exam_name": "Midterm", "time_limit_mins": 90, "is_proctored": true, "is_active": true }, { ...: ..., ...: ... }, .. ] """ exams = ProctoredExam.get_all_exams_for_course(course_id) return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams]
def get_all_exams_for_course(course_id): """ This method will return all exams for a course. This will return a list of dictionaries, whose schema is the same as what is returned in get_exam_by_id Returns a list containing dictionary version of the Django ORM object e.g. [{ "course_id": "edX/DemoX/Demo_Course", "content_id": "123", "external_id": "", "exam_name": "Midterm", "time_limit_mins": 90, "is_proctored": true, "is_active": true }, { ...: ..., ...: ... }, .. ] """ exams = ProctoredExam.get_all_exams_for_course(course_id) return [ ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams ]
def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True, cascade_effects=True): """ Internal helper to handle state transitions of attempt status """ log_msg = ( 'Updating attempt status for exam_id {exam_id} ' 'for user_id {user_id} to status {to_status}'.format( exam_id=exam_id, user_id=user_id, to_status=to_status ) ) log.info(log_msg) # In some configuration we may treat timeouts the same # as the user saying he/she wises to submit the exam alias_timeout = ( to_status == ProctoredExamStudentAttemptStatus.timed_out and not settings.PROCTORING_SETTINGS.get('ALLOW_TIMED_OUT_STATE', False) ) if alias_timeout: to_status = ProctoredExamStudentAttemptStatus.submitted exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt(exam_id, user_id) if exam_attempt_obj is None: if raise_if_not_found: raise StudentExamAttemptDoesNotExistsException('Error. Trying to look up an exam that does not exist.') else: return # # don't allow state transitions from a completed state to an incomplete state # if a re-attempt is desired then the current attempt must be deleted # in_completed_status = ProctoredExamStudentAttemptStatus.is_completed_status(exam_attempt_obj.status) to_incompleted_status = ProctoredExamStudentAttemptStatus.is_incomplete_status(to_status) if in_completed_status and to_incompleted_status: err_msg = ( 'A status transition from {from_status} to {to_status} was attempted ' 'on exam_id {exam_id} for user_id {user_id}. This is not ' 'allowed!'.format( from_status=exam_attempt_obj.status, to_status=to_status, exam_id=exam_id, user_id=user_id ) ) raise ProctoredExamIllegalStatusTransition(err_msg) # special case logic, if we are in a completed status we shouldn't allow # for a transition to 'Error' state if in_completed_status and to_status == ProctoredExamStudentAttemptStatus.error: err_msg = ( 'A status transition from {from_status} to {to_status} was attempted ' 'on exam_id {exam_id} for user_id {user_id}. This is not ' 'allowed!'.format( from_status=exam_attempt_obj.status, to_status=to_status, exam_id=exam_id, user_id=user_id ) ) raise ProctoredExamIllegalStatusTransition(err_msg) # OK, state transition is fine, we can proceed exam_attempt_obj.status = to_status exam_attempt_obj.save() # see if the status transition this changes credit requirement status if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): # trigger credit workflow, as needed credit_service = get_runtime_service('credit') exam = get_exam_by_id(exam_id) if to_status == ProctoredExamStudentAttemptStatus.verified: verification = 'satisfied' elif to_status == ProctoredExamStudentAttemptStatus.submitted: verification = 'submitted' else: verification = 'failed' log_msg = ( 'Calling set_credit_requirement_status for ' 'user_id {user_id} on {course_id} for ' 'content_id {content_id}. Status: {status}'.format( user_id=exam_attempt_obj.user_id, course_id=exam['course_id'], content_id=exam_attempt_obj.proctored_exam.content_id, status=verification ) ) log.info(log_msg) credit_service.set_credit_requirement_status( user_id=exam_attempt_obj.user_id, course_key_or_id=exam['course_id'], req_namespace='proctored_exam', req_name=exam_attempt_obj.proctored_exam.content_id, status=verification ) if cascade_effects and ProctoredExamStudentAttemptStatus.is_a_cascadable_failure(to_status): if to_status == ProctoredExamStudentAttemptStatus.declined: # if user declines attempt, make sure we clear out the external_id and # taking_as_proctored fields exam_attempt_obj.taking_as_proctored = False exam_attempt_obj.external_id = None exam_attempt_obj.save() # some state transitions (namely to a rejected or declined status) # will mark other exams as declined because once we fail or decline # one exam all other (un-completed) proctored exams will be likewise # updated to reflect a declined status # get all other unattempted exams and mark also as declined _exams = ProctoredExam.get_all_exams_for_course( exam_attempt_obj.proctored_exam.course_id, active_only=True ) # we just want other exams which are proctored and are not practice exams = [ exam for exam in _exams if ( exam.content_id != exam_attempt_obj.proctored_exam.content_id and exam.is_proctored and not exam.is_practice_exam ) ] for exam in exams: # see if there was an attempt on those other exams already attempt = get_exam_attempt(exam.id, user_id) if attempt and ProctoredExamStudentAttemptStatus.is_completed_status(attempt['status']): # don't touch any completed statuses # we won't revoke those continue if not attempt: create_exam_attempt(exam.id, user_id, taking_as_proctored=False) # update any new or existing status to declined update_attempt_status( exam.id, user_id, ProctoredExamStudentAttemptStatus.declined, cascade_effects=False ) if to_status == ProctoredExamStudentAttemptStatus.submitted: # also mark the exam attempt completed_at timestamp # after we submit the attempt exam_attempt_obj.completed_at = datetime.now(pytz.UTC) exam_attempt_obj.save() # if we have transitioned to started and haven't set our # started_at timestamp, do so now add_start_time = ( to_status == ProctoredExamStudentAttemptStatus.started and not exam_attempt_obj.started_at ) if add_start_time: exam_attempt_obj.started_at = datetime.now(pytz.UTC) exam_attempt_obj.save() # email will be send when the exam is proctored and not practice exam # and the status is verified, submitted or rejected should_send_status_email = ( exam_attempt_obj.taking_as_proctored and not exam_attempt_obj.is_sample_attempt and ProctoredExamStudentAttemptStatus.needs_status_change_email(exam_attempt_obj.status) ) if should_send_status_email: # trigger credit workflow, as needed credit_service = get_runtime_service('credit') # call service to get course name. credit_state = credit_service.get_credit_state( exam_attempt_obj.user_id, exam_attempt_obj.proctored_exam.course_id, return_course_name=True ) send_proctoring_attempt_status_email( exam_attempt_obj, credit_state.get('course_name', _('your course')) ) return exam_attempt_obj.id
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