def post(self, request, user_id): # pylint: disable=unused-argument """ Deletes all user data for the particular user_id from all configured backends """ if not request.user.has_perm('accounts.can_retire_user'): return Response(status=403) results = {} code = 200 seen = set() # pylint: disable=no-member attempts = ProctoredExamStudentAttempt.objects.filter(user_id=user_id).select_related('proctored_exam') if attempts: for attempt in attempts: backend_name = attempt.proctored_exam.backend if backend_name in seen or not attempt.taking_as_proctored: continue backend_user_id = obscured_user_id(user_id, backend_name) LOG.info(u'retiring user %s from %s', user_id, backend_name) try: result = get_backend_provider(name=backend_name).retire_user(backend_user_id) except ProctoredBaseException: LOG.exception(u'attempting to delete %s (%s) from %s', user_id, backend_user_id, backend_name) result = False if result is not None: results[backend_name] = result if not result: code = 500 seen.add(backend_name) return Response(data=results, status=code)
def post(self, request, user_id): # pylint: disable=unused-argument """ Deletes all user data for the particular user_id from all configured backends """ if not request.user.has_perm('accounts.can_retire_user'): return Response(status=403) results = {} code = 200 seen = set() attempts = ProctoredExamStudentAttempt.objects.filter(user_id=user_id).select_related('proctored_exam') if attempts: for attempt in attempts: backend_name = attempt.proctored_exam.backend if backend_name in seen or not attempt.taking_as_proctored: continue backend_user_id = obscured_user_id(user_id, backend_name) LOG.info('retiring user %s from %s', user_id, backend_name) try: result = get_backend_provider(name=backend_name).retire_user(backend_user_id) except ProctoredBaseException: LOG.exception('attempting to delete %s (%s) from %s', user_id, backend_user_id, backend_name) result = False if result is not None: results[backend_name] = result if not result: code = 500 seen.add(backend_name) return Response(data=results, status=code)
def post(self, request, user_id): # pylint: disable=unused-argument """ Deletes all user data for the particular user_id from all configured backends """ if not request.user.has_perm('accounts.can_retire_user'): return Response(status=403) from django.apps import apps choices = apps.get_app_config('edx_proctoring').get_backend_choices() results = {} code = 200 if ProctoredExamStudentAttempt.objects.filter(user_id=user_id): for backend_name, verbose_name in choices: backend_user_id = obscured_user_id(user_id, backend_name) LOG.info('retiring user %s from %s', user_id, backend_name) try: result = get_backend_provider(name=backend_name).retire_user(backend_user_id) except ProctoredBaseException: LOG.exception('attempting to delete %s (%s) from %s', user_id, backend_user_id, verbose_name) result = False if result is not None: results[backend_name] = result if not result: code = 500 return Response(data=results, status=code)
def get(self, request, course_id, exam_id=None): """ Redirect to dashboard for a given course and optional exam_id """ exam = None attempt_id = None ext_exam_id = None show_configuration_dashboard = False if exam_id: exam = get_exam_by_id(exam_id) # the exam_id in the url is our database id (for ease of lookups) # but the backend needs its external id for the instructor dashboard ext_exam_id = exam['external_id'] attempt_id = request.GET.get('attempt', None) # only show the configuration dashboard if an exam_id is passed in show_configuration_dashboard = request.GET.get('config', '').lower() == 'true' else: found_backend = None for exam in get_all_exams_for_course(course_id, True): exam_backend = exam['backend'] or settings.PROCTORING_BACKENDS.get('DEFAULT', None) if found_backend and exam_backend != found_backend: # In this case, what are we supposed to do?! # It should not be possible to get in this state, because # course teams will be prevented from updating the backend after the course start date error_message = "Multiple backends for course %r %r != %r" % (course_id, found_backend, exam['backend']) return Response(data=error_message, status=400) else: found_backend = exam_backend if exam is None: error = _('No exams in course {course_id}.').format(course_id=course_id) else: backend = get_backend_provider(exam) if backend: user = { 'id': obscured_user_id(request.user.id, exam['backend']), 'full_name': request.user.get_full_name(), 'email': request.user.email } url = backend.get_instructor_url( exam['course_id'], user, exam_id=ext_exam_id, attempt_id=attempt_id, show_configuration_dashboard=show_configuration_dashboard ) if url: return redirect(url) else: error = _('No instructor dashboard for {proctor_service}').format( proctor_service=backend.verbose_name) else: error = _('No proctored exams in course {course_id}').format(course_id=course_id) return Response(data=error, status=404, headers={'X-Frame-Options': 'sameorigin'})
def post(self, request): """ Post callback handler """ provider = get_backend_provider({'backend': 'software_secure'}) # call down into the underlying provider code attempt_code = request.data.get('examMetaData', {}).get('examCode') attempt_obj, is_archived = locate_attempt_by_attempt_code(attempt_code) if not attempt_obj: # still can't find, error out err_msg = (u'Could not locate attempt_code: {attempt_code}'.format( attempt_code=attempt_code)) raise StudentExamAttemptDoesNotExistsException(err_msg) serialized = ProctoredExamStudentAttemptSerializer(attempt_obj).data serialized['is_archived'] = is_archived self.make_review(serialized, request.data, backend=provider) return Response('OK')
def post(self, request): """ Post callback handler """ provider = get_backend_provider({'backend': 'software_secure'}) # call down into the underlying provider code attempt_code = request.data.get('examMetaData', {}).get('examCode') attempt_obj, is_archived = locate_attempt_by_attempt_code(attempt_code) if not attempt_obj: # still can't find, error out err_msg = ( 'Could not locate attempt_code: {attempt_code}'.format(attempt_code=attempt_code) ) raise StudentExamAttemptDoesNotExistsException(err_msg) serialized = ProctoredExamStudentAttemptSerializer(attempt_obj).data serialized['is_archived'] = is_archived self.make_review(serialized, request.data, backend=provider) return Response('OK')
def get(self, request, course_id, exam_id=None): """ Redirect to dashboard for a given course and optional exam_id """ exam = None backend = None ext_exam_id = None attempt_id = None show_configuration_dashboard = False if exam_id: exam = get_exam_by_id(exam_id) backend = get_backend_provider(exam=exam) # the exam_id in the url is our database id (for ease of lookups) # but the backend needs its external id for the instructor dashboard ext_exam_id = exam['external_id'] attempt_id = request.GET.get('attempt', None) # only show the configuration dashboard if an exam_id is passed in show_configuration_dashboard = request.GET.get('config', '').lower() == 'true' else: existing_backend_name = None for exam in get_all_exams_for_course(course_id, True): if not exam.get('is_proctored'): # We should only get backends of exams which are configured to be proctored continue exam_backend_name = exam.get('backend') backend = get_backend_provider(name=exam_backend_name) if existing_backend_name and exam_backend_name != existing_backend_name: # In this case, what are we supposed to do?! # It should not be possible to get in this state, because # course teams will be prevented from updating the backend after the course start date error_message = u"Multiple backends for course %r %r != %r" % ( course_id, existing_backend_name, exam_backend_name ) return Response(data=error_message, status=400) else: existing_backend_name = exam_backend_name if not exam: return Response( data=_(u'No exams in course {course_id}.').format(course_id=course_id), status=404, headers={'X-Frame-Options': 'sameorigin'} ) if not backend: return Response( data=_(u'No proctored exams in course {course_id}').format(course_id=course_id), status=404, headers={'X-Frame-Options': 'sameorigin'} ) user = { 'id': obscured_user_id(request.user.id, exam['backend']), 'full_name': request.user.profile.name, 'email': request.user.email } url = backend.get_instructor_url( exam['course_id'], user, exam_id=ext_exam_id, attempt_id=attempt_id, show_configuration_dashboard=show_configuration_dashboard ) if not url: return Response( data=_(u'No instructor dashboard for {proctor_service}').format( proctor_service=backend.verbose_name ), status=404, headers={'X-Frame-Options': 'sameorigin'} ) return redirect(url)
def make_review(self, attempt, data, backend=None): """ Save the review and review comments """ attempt_code = attempt['attempt_code'] if not backend: backend = get_backend_provider(attempt['proctored_exam']) # this method should convert the payload into a normalized format backend_review = backend.on_review_callback(attempt, data) # do we already have a review for this attempt?!? We may not allow updates review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) if review: if not constants.ALLOW_REVIEW_UPDATES: err_msg = ( u'We already have a review submitted regarding ' u'attempt_code {attempt_code}. We do not allow for updates!'.format( attempt_code=attempt_code ) ) raise ProctoredExamReviewAlreadyExists(err_msg) # we allow updates warn_msg = ( u'We already have a review submitted from our proctoring provider regarding ' u'attempt_code {attempt_code}. We have been configured to allow for ' u'updates and will continue...'.format( attempt_code=attempt_code ) ) LOG.warning(warn_msg) else: # this is first time we've received this attempt_code, so # make a new record in the review table review = ProctoredExamSoftwareSecureReview() # first, validate that the backend review status is valid ReviewStatus.validate(backend_review['status']) # For now, we'll convert the standard review status to the old # software secure review status. # In the future, the old data should be standardized. review.review_status = SoftwareSecureReviewStatus.from_standard_status.get(backend_review['status']) review.attempt_code = attempt_code review.raw_data = json.dumps(data) review.student_id = attempt['user']['id'] review.exam_id = attempt['proctored_exam']['id'] try: review.reviewed_by = get_user_model().objects.get(email=data['reviewed_by']) except (ObjectDoesNotExist, KeyError): review.reviewed_by = None # If the reviewing user is a user in the system (user may be None for automated reviews) and does # not have permission to submit a review, log a warning. course_id = attempt['proctored_exam']['course_id'] if review.reviewed_by is not None and not is_user_course_or_global_staff(review.reviewed_by, course_id): LOG.warning( u'User %(user)s does not have the required permissions to submit ' u'a review for attempt_code %(attempt_code)s.', {'user': review.reviewed_by, 'attempt_code': attempt_code} ) review.save() # go through and populate all of the specific comments for comment in backend_review.get('comments', []): comment = ProctoredExamSoftwareSecureComment( review=review, start_time=comment.get('start', 0), stop_time=comment.get('stop', 0), duration=comment.get('duration', 0), comment=comment['comment'], status=comment['status'] ) comment.save() if review.should_notify: instructor_service = get_runtime_service('instructor') request = get_current_request() if instructor_service and request: course_id = attempt['proctored_exam']['course_id'] exam_id = attempt['proctored_exam']['id'] review_url = request.build_absolute_uri( u'{}?attempt={}'.format( reverse('edx_proctoring:instructor_dashboard_exam', args=[course_id, exam_id]), attempt['external_id'] )) instructor_service.send_support_notification( course_id=attempt['proctored_exam']['course_id'], exam_name=attempt['proctored_exam']['exam_name'], student_username=attempt['user']['username'], review_status=review.review_status, review_url=review_url, )
def get(self, request): # pylint: disable=unused-argument """ HTTP GET Handler. Returns the status of the exam attempt. """ exams = get_active_exams_for_user(request.user.id) if exams: exam_info = exams[0] exam = exam_info['exam'] attempt = exam_info['attempt'] provider = get_backend_provider(exam) time_remaining_seconds = get_time_remaining_for_attempt(attempt) proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {}) low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2) critically_low_threshold_pct = proctoring_settings.get('critically_low_threshold_pct', .05) low_threshold = int(low_threshold_pct * float(attempt['allowed_time_limit_mins']) * 60) critically_low_threshold = int( critically_low_threshold_pct * float(attempt['allowed_time_limit_mins']) * 60 ) exam_url_path = '' try: # resolve the LMS url, note we can't assume we're running in # a same process as the LMS exam_url_path = reverse('jump_to', args=[exam['course_id'], exam['content_id']]) except NoReverseMatch: LOG.exception(u"Can't find exam url for course %s", exam['course_id']) response_dict = { 'in_timed_exam': True, 'taking_as_proctored': attempt['taking_as_proctored'], 'exam_type': ( _('a timed exam') if not attempt['taking_as_proctored'] else (_('a proctored exam') if not attempt['is_sample_attempt'] else (_('an onboarding exam') if provider.supports_onboarding else _('a practice exam'))) ), 'exam_display_name': exam['exam_name'], 'exam_url_path': exam_url_path, 'time_remaining_seconds': time_remaining_seconds, 'low_threshold_sec': low_threshold, 'critically_low_threshold_sec': critically_low_threshold, 'course_id': exam['course_id'], 'attempt_id': attempt['id'], 'accessibility_time_string': _(u'you have {remaining_time} remaining').format( remaining_time=humanized_time(int(round(time_remaining_seconds / 60.0, 0))) ), 'attempt_status': attempt['status'], 'exam_started_poll_url': reverse( 'edx_proctoring:proctored_exam.attempt', args=[attempt['id']] ), } if provider: response_dict['desktop_application_js_url'] = provider.get_javascript() response_dict['ping_interval'] = provider.ping_interval else: response_dict['desktop_application_js_url'] = '' else: response_dict = { 'in_timed_exam': False, 'is_proctored': False } return Response(data=response_dict, status=status.HTTP_200_OK)
def make_review(self, attempt, data, backend=None): """ Save the review and review comments """ attempt_code = attempt['attempt_code'] if not backend: backend = get_backend_provider(attempt['proctored_exam']) # this method should convert the payload into a normalized format backend_review = backend.on_review_callback(attempt, data) # do we already have a review for this attempt?!? We may not allow updates review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) if review: if not constants.ALLOW_REVIEW_UPDATES: err_msg = ( 'We already have a review submitted regarding ' 'attempt_code {attempt_code}. We do not allow for updates!'.format( attempt_code=attempt_code ) ) raise ProctoredExamReviewAlreadyExists(err_msg) # we allow updates warn_msg = ( 'We already have a review submitted from our proctoring provider regarding ' 'attempt_code {attempt_code}. We have been configured to allow for ' 'updates and will continue...'.format( attempt_code=attempt_code ) ) LOG.warning(warn_msg) else: # this is first time we've received this attempt_code, so # make a new record in the review table review = ProctoredExamSoftwareSecureReview() # first, validate that the backend review status is valid ReviewStatus.validate(backend_review['status']) # For now, we'll convert the standard review status to the old # software secure review status. # In the future, the old data should be standardized. review.review_status = SoftwareSecureReviewStatus.from_standard_status.get(backend_review['status']) review.attempt_code = attempt_code review.raw_data = json.dumps(data) review.student_id = attempt['user']['id'] review.exam_id = attempt['proctored_exam']['id'] try: review.reviewed_by = get_user_model().objects.get(email=data['reviewed_by']) except (ObjectDoesNotExist, KeyError): review.reviewed_by = None # If the reviewing user is a user in the system (user may be None for automated reviews) and does # not have permission to submit a review, log a warning. course_id = attempt['proctored_exam']['course_id'] if review.reviewed_by is not None and not is_user_course_or_global_staff(review.reviewed_by, course_id): LOG.warning( 'User %(user)s does not have the required permissions to submit ' 'a review for attempt_code %(attempt_code)s.', {'user': review.reviewed_by, 'attempt_code': attempt_code} ) review.save() # go through and populate all of the specific comments for comment in backend_review.get('comments', []): comment = ProctoredExamSoftwareSecureComment( review=review, start_time=comment.get('start', 0), stop_time=comment.get('stop', 0), duration=comment.get('duration', 0), comment=comment['comment'], status=comment['status'] ) comment.save() if review.should_notify: instructor_service = get_runtime_service('instructor') request = get_current_request() if instructor_service and request: course_id = attempt['proctored_exam']['course_id'] exam_id = attempt['proctored_exam']['id'] review_url = request.build_absolute_uri( u'{}?attempt={}'.format( reverse('edx_proctoring:instructor_dashboard_exam', args=[course_id, exam_id]), attempt['external_id'] )) instructor_service.send_support_notification( course_id=attempt['proctored_exam']['course_id'], exam_name=attempt['proctored_exam']['exam_name'], student_username=attempt['user']['username'], review_status=review.review_status, review_url=review_url, )
def get(self, request): # pylint: disable=unused-argument """ HTTP GET Handler. Returns the status of the exam attempt. """ exams = get_active_exams_for_user(request.user.id) if exams: exam_info = exams[0] exam = exam_info['exam'] attempt = exam_info['attempt'] provider = get_backend_provider(exam) time_remaining_seconds = get_time_remaining_for_attempt(attempt) proctoring_settings = getattr(settings, 'PROCTORING_SETTINGS', {}) low_threshold_pct = proctoring_settings.get('low_threshold_pct', .2) critically_low_threshold_pct = proctoring_settings.get('critically_low_threshold_pct', .05) low_threshold = int(low_threshold_pct * float(attempt['allowed_time_limit_mins']) * 60) critically_low_threshold = int( critically_low_threshold_pct * float(attempt['allowed_time_limit_mins']) * 60 ) exam_url_path = '' try: # resolve the LMS url, note we can't assume we're running in # a same process as the LMS exam_url_path = reverse('jump_to', args=[exam['course_id'], exam['content_id']]) except NoReverseMatch: LOG.exception("Can't find exam url for course %s", exam['course_id']) response_dict = { 'in_timed_exam': True, 'taking_as_proctored': attempt['taking_as_proctored'], 'exam_type': ( _('a timed exam') if not attempt['taking_as_proctored'] else (_('a proctored exam') if not attempt['is_sample_attempt'] else (_('an onboarding exam') if provider.supports_onboarding else _('a practice exam'))) ), 'exam_display_name': exam['exam_name'], 'exam_url_path': exam_url_path, 'time_remaining_seconds': time_remaining_seconds, 'low_threshold_sec': low_threshold, 'critically_low_threshold_sec': critically_low_threshold, 'course_id': exam['course_id'], 'attempt_id': attempt['id'], 'accessibility_time_string': _('you have {remaining_time} remaining').format( remaining_time=humanized_time(int(round(time_remaining_seconds / 60.0, 0))) ), 'attempt_status': attempt['status'], 'exam_started_poll_url': reverse( 'edx_proctoring:proctored_exam.attempt', args=[attempt['id']] ), } if provider: response_dict['desktop_application_js_url'] = provider.get_javascript() response_dict['ping_interval'] = provider.ping_interval else: response_dict['desktop_application_js_url'] = '' else: response_dict = { 'in_timed_exam': False, 'is_proctored': False } return Response(data=response_dict, status=status.HTTP_200_OK)
def make_review(self, attempt, data, backend=None): """ Save the review and review comments """ attempt_code = attempt['attempt_code'] if not backend: backend = get_backend_provider(attempt['proctored_exam']) # this method should convert the payload into a normalized format backend_review = backend.on_review_callback(attempt, data) # do we already have a review for this attempt?!? We may not allow updates review = ProctoredExamSoftwareSecureReview.get_review_by_attempt_code(attempt_code) if review: if not constants.ALLOW_REVIEW_UPDATES: err_msg = ( 'We already have a review submitted regarding ' 'attempt_code {attempt_code}. We do not allow for updates!'.format( attempt_code=attempt_code ) ) raise ProctoredExamReviewAlreadyExists(err_msg) # we allow updates warn_msg = ( 'We already have a review submitted from our proctoring provider regarding ' 'attempt_code {attempt_code}. We have been configured to allow for ' 'updates and will continue...'.format( attempt_code=attempt_code ) ) LOG.warning(warn_msg) else: # this is first time we've received this attempt_code, so # make a new record in the review table review = ProctoredExamSoftwareSecureReview() # first, validate that the backend review status is valid ReviewStatus.validate(backend_review['status']) # For now, we'll convert the standard review status to the old # software secure review status. # In the future, the old data should be standardized. review.review_status = SoftwareSecureReviewStatus.from_standard_status.get(backend_review['status']) review.attempt_code = attempt_code review.raw_data = json.dumps(backend_review) review.student_id = attempt['user']['id'] review.exam_id = attempt['proctored_exam']['id'] # set reviewed_by to None because it was reviewed by our 3rd party # service provider, not a user in our database review.reviewed_by = None review.save() # go through and populate all of the specific comments for comment in backend_review.get('comments', []): comment = ProctoredExamSoftwareSecureComment( review=review, start_time=comment.get('start', 0), stop_time=comment.get('stop', 0), duration=comment.get('duration', 0), comment=comment['comment'], status=comment['status'] ) comment.save()