def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring instructor_service = get_runtime_service('instructor') course_id = kwargs['course_id'] if 'course_id' in kwargs else None exam_id = request.data.get('exam_id', None) attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None if request.user.is_staff: return func(request, *args, **kwargs) else: if course_id is None: if exam_id is not None: exam = ProctoredExam.get_exam_by_id(exam_id) course_id = exam.course_id elif attempt_id is not None: exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id) course_id = exam_attempt.proctored_exam.course_id else: response_message = _("could not determine the course_id") return Response( status=status.HTTP_403_FORBIDDEN, data={"detail": response_message} ) if instructor_service.is_course_staff(request.user, course_id): return func(request, *args, **kwargs) else: return Response( status=status.HTTP_403_FORBIDDEN, data={"detail": _("Must be a Staff User to Perform this request.")} )
def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring instructor_service = get_runtime_service('instructor') course_id = kwargs['course_id'] if 'course_id' in kwargs else None exam_id = request.data.get('exam_id', None) attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None if request.user.is_staff: return func(request, *args, **kwargs) else: if course_id is None: if exam_id is not None: exam = ProctoredExam.get_exam_by_id(exam_id) course_id = exam.course_id elif attempt_id is not None: exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id( attempt_id) course_id = exam_attempt.proctored_exam.course_id else: response_message = _("could not determine the course_id") return Response(status=status.HTTP_403_FORBIDDEN, data={"detail": response_message}) if instructor_service.is_course_staff(request.user, course_id): return func(request, *args, **kwargs) else: return Response( status=status.HTTP_403_FORBIDDEN, data={ "detail": _("Must be a Staff User to Perform this request.") })
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_exam(exam_id, exam_name=None, time_limit_mins=None, is_proctored=None, is_practice_exam=None, external_id=None, is_active=None): """ Given a Django ORM id, update the existing record, otherwise raise exception if not found. If an argument is not passed in, then do not change it's current value. Returns: id """ log_msg = ( u'Updating exam_id {exam_id} with parameters ' u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'external_id={external_id}, is_active={is_active}'.format( exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins, is_proctored=is_proctored, is_practice_exam=is_practice_exam, external_id=external_id, is_active=is_active)) log.info(log_msg) proctored_exam = ProctoredExam.get_exam_by_id(exam_id) if proctored_exam is None: raise ProctoredExamNotFoundException if exam_name is not None: proctored_exam.exam_name = exam_name if time_limit_mins is not None: proctored_exam.time_limit_mins = time_limit_mins if is_proctored is not None: proctored_exam.is_proctored = is_proctored if is_practice_exam is not None: proctored_exam.is_practice_exam = is_practice_exam if external_id is not None: proctored_exam.external_id = external_id if is_active is not None: proctored_exam.is_active = is_active proctored_exam.save() return proctored_exam.id
def create_exam(course_id, content_id, exam_name, time_limit_mins, is_proctored=True, is_practice_exam=False, external_id=None, is_active=True): """ Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist. If that pair already exists, then raise exception. Returns: id (PK) """ if ProctoredExam.get_exam_by_content_id(course_id, content_id) is not None: raise ProctoredExamAlreadyExists proctored_exam = ProctoredExam.objects.create( course_id=course_id, content_id=content_id, external_id=external_id, exam_name=exam_name, time_limit_mins=time_limit_mins, is_proctored=is_proctored, is_practice_exam=is_practice_exam, is_active=is_active) log_msg = ( u'Created exam ({exam_id}) with parameters: course_id={course_id}, ' u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'external_id={external_id}, is_active={is_active}'.format( exam_id=proctored_exam.id, course_id=course_id, content_id=content_id, exam_name=exam_name, time_limit_mins=time_limit_mins, is_proctored=is_proctored, is_practice_exam=is_practice_exam, external_id=external_id, is_active=is_active)) log.info(log_msg) return proctored_exam.id
def get_exam_by_content_id(course_id, content_id): """ Looks up exam by the course_id/content_id pair. Raises exception if not found. Returns 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 } """ proctored_exam = ProctoredExam.get_exam_by_content_id(course_id, content_id) if proctored_exam is None: raise ProctoredExamNotFoundException serialized_exam_object = ProctoredExamSerializer(proctored_exam) return serialized_exam_object.data
def update_exam(exam_id, exam_name=None, time_limit_mins=None, is_proctored=None, is_practice_exam=None, external_id=None, is_active=None): """ Given a Django ORM id, update the existing record, otherwise raise exception if not found. If an argument is not passed in, then do not change it's current value. Returns: id """ log_msg = ( u'Updating exam_id {exam_id} with parameters ' u'exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'external_id={external_id}, is_active={is_active}'.format( exam_id=exam_id, exam_name=exam_name, time_limit_mins=time_limit_mins, is_proctored=is_proctored, is_practice_exam=is_practice_exam, external_id=external_id, is_active=is_active ) ) log.info(log_msg) proctored_exam = ProctoredExam.get_exam_by_id(exam_id) if proctored_exam is None: raise ProctoredExamNotFoundException if exam_name is not None: proctored_exam.exam_name = exam_name if time_limit_mins is not None: proctored_exam.time_limit_mins = time_limit_mins if is_proctored is not None: proctored_exam.is_proctored = is_proctored if is_practice_exam is not None: proctored_exam.is_practice_exam = is_practice_exam if external_id is not None: proctored_exam.external_id = external_id if is_active is not None: proctored_exam.is_active = is_active proctored_exam.save() return proctored_exam.id
def get_exam_by_id(exam_id): """ Looks up exam by the Primary Key. Raises exception if not found. Returns 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 } """ proctored_exam = ProctoredExam.get_exam_by_id(exam_id) if proctored_exam is None: raise ProctoredExamNotFoundException serialized_exam_object = ProctoredExamSerializer(proctored_exam) return serialized_exam_object.data
def test_get_practice_proctored_exams_for_course(self): """ Test get_practice_proctored_exams_for_course method returns only active practice proctored exams. """ course_id = 'test_course' # create proctored exam ProctoredExam.objects.create( course_id=course_id, content_id='test_content_1', exam_name='Test Exam', external_id='123aXqe3', time_limit_mins=90, is_active=True, is_proctored=True, ) # create practice proctored exam ProctoredExam.objects.create( course_id=course_id, content_id='test_content_2', exam_name='Test Exam', external_id='123aXqe3', time_limit_mins=90, is_active=True, is_proctored=True, is_practice_exam=True, ) practice_proctored_exams = ProctoredExam.objects.filter( course_id=course_id, is_active=True, is_proctored=True, is_practice_exam=True ) self.assertQuerysetEqual( ProctoredExam.get_practice_proctored_exams_for_course(course_id), [repr(exam) for exam in practice_proctored_exams] )
def create_exam(course_id, content_id, exam_name, time_limit_mins, is_proctored=True, is_practice_exam=False, external_id=None, is_active=True): """ Creates a new ProctoredExam entity, if the course_id/content_id pair do not already exist. If that pair already exists, then raise exception. Returns: id (PK) """ if ProctoredExam.get_exam_by_content_id(course_id, content_id) is not None: raise ProctoredExamAlreadyExists proctored_exam = ProctoredExam.objects.create( course_id=course_id, content_id=content_id, external_id=external_id, exam_name=exam_name, time_limit_mins=time_limit_mins, is_proctored=is_proctored, is_practice_exam=is_practice_exam, is_active=is_active ) log_msg = ( u'Created exam ({exam_id}) with parameters: course_id={course_id}, ' u'content_id={content_id}, exam_name={exam_name}, time_limit_mins={time_limit_mins}, ' u'is_proctored={is_proctored}, is_practice_exam={is_practice_exam}, ' u'external_id={external_id}, is_active={is_active}'.format( exam_id=proctored_exam.id, course_id=course_id, content_id=content_id, exam_name=exam_name, time_limit_mins=time_limit_mins, is_proctored=is_proctored, is_practice_exam=is_practice_exam, external_id=external_id, is_active=is_active ) ) log.info(log_msg) return proctored_exam.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 """ 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