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!'
def _check_for_attempt_timeout(attempt): """ Helper method to see if the status of an exam needs to be updated, e.g. timeout """ if not attempt: return attempt # right now the only adjustment to # status is transitioning to timeout has_started_exam = (attempt and attempt.get('started_at') and ProctoredExamStudentAttemptStatus.is_incomplete_status( attempt.get('status'))) if has_started_exam: now_utc = datetime.now(pytz.UTC) expires_at = attempt['started_at'] + timedelta( minutes=attempt['allowed_time_limit_mins']) has_time_expired = now_utc > expires_at if has_time_expired: update_attempt_status(attempt['proctored_exam']['id'], attempt['user']['id'], ProctoredExamStudentAttemptStatus.timed_out) attempt = get_exam_attempt_by_id(attempt['id']) return attempt
def _check_for_attempt_timeout(attempt): """ Helper method to see if the status of an exam needs to be updated, e.g. timeout """ if not attempt: return attempt # right now the only adjustment to # status is transitioning to timeout has_started_exam = ( attempt and attempt.get('started_at') and ProctoredExamStudentAttemptStatus.is_incomplete_status(attempt.get('status')) ) if has_started_exam: now_utc = datetime.now(pytz.UTC) expires_at = attempt['started_at'] + timedelta(minutes=attempt['allowed_time_limit_mins']) has_time_expired = now_utc > expires_at if has_time_expired: update_attempt_status( attempt['proctored_exam']['id'], attempt['user']['id'], ProctoredExamStudentAttemptStatus.timed_out ) attempt = get_exam_attempt_by_id(attempt['id']) return attempt
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!'
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 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 remove_exam_attempt(attempt_id): """ Removes an exam attempt given the attempt id. """ log_msg = ( 'Removing exam attempt {attempt_id}'.format(attempt_id=attempt_id) ) log.info(log_msg) existing_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id) if not existing_attempt: err_msg = ( 'Cannot remove attempt for attempt_id = {attempt_id} ' 'because it does not exist!' ).format(attempt_id=attempt_id) raise StudentExamAttemptDoesNotExistsException(err_msg) username = existing_attempt.user.username user_id = existing_attempt.user.id course_id = existing_attempt.proctored_exam.course_id content_id = existing_attempt.proctored_exam.content_id to_status = existing_attempt.status existing_attempt.delete_exam_attempt() instructor_service = get_runtime_service('instructor') if instructor_service: instructor_service.delete_student_attempt(username, course_id, content_id) # 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') credit_service.remove_credit_requirement_status( user_id=user_id, course_key_or_id=course_id, req_namespace=u'proctored_exam', req_name=content_id )
def _create_exam_attempt(self, exam_id, status='created'): """ Creates the ProctoredExamStudentAttempt object. """ attempt = ProctoredExamStudentAttempt(proctored_exam_id=exam_id, user_id=self.user_id, external_id=self.external_id, allowed_time_limit_mins=10, status=status) if status in (ProctoredExamStudentAttemptStatus.started, ProctoredExamStudentAttemptStatus.ready_to_submit, ProctoredExamStudentAttemptStatus.submitted): attempt.started_at = datetime.now(pytz.UTC) if ProctoredExamStudentAttemptStatus.is_completed_status(status): attempt.completed_at = datetime.now(pytz.UTC) attempt.save() return attempt
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.assertEquals(len(mail.outbox), 1) self.assertIn(self.proctored_exam_email_subject, mail.outbox[0].subject) self.assertIn(course_name, mail.outbox[0].subject) self.assertIn(self.proctored_exam_email_body, mail.outbox[0].body) self.assertIn( ProctoredExamStudentAttemptStatus.get_status_alias( ProctoredExamStudentAttemptStatus.submitted), mail.outbox[0].body) self.assertIn(credit_state['course_name'], mail.outbox[0].body)
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