Commit fcaf59df by chrisndodge

Merge pull request #241 from edx/hasnain-naveed/PHX-216

PHX-216 / user can visit the exam once due has passed.
parents 5825f78b e723816a
......@@ -439,10 +439,19 @@ def update_exam_attempt(attempt_id, **kwargs):
update exam_attempt
"""
exam_attempt_obj = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
if not exam_attempt_obj:
err_msg = (
'Attempted to access of attempt object with attempt_id {attempt_id} but '
'it does not exist.'.format(
attempt_id=attempt_id
)
)
raise StudentExamAttemptDoesNotExistsException(err_msg)
for key, value in kwargs.items():
# only allow a limit set of fields to update
# namely because status transitions can trigger workflow
if key not in ['last_poll_timestamp', 'last_poll_ipaddr']:
if key not in ['last_poll_timestamp', 'last_poll_ipaddr', 'is_status_acknowledged']:
err_msg = (
'You cannot call into update_exam_attempt to change '
'field {key}'.format(key=key)
......@@ -463,6 +472,13 @@ def _has_due_date_passed(due_datetime):
return False
def _was_review_status_acknowledged(is_status_acknowledged, due_datetime):
"""
return True if review status has been acknowledged and due date has been passed
"""
return is_status_acknowledged and _has_due_date_passed(due_datetime)
def _create_and_decline_attempt(exam_id, user_id):
"""
It will create the exam attempt and change the attempt's status to decline.
......@@ -1490,6 +1506,7 @@ def _get_proctored_exam_context(exam, attempt, course_id, is_practice_exam=False
'exam_id': exam['id'],
'progress_page_url': progress_page_url,
'is_sample_attempt': is_practice_exam,
'has_due_date_passed': _has_due_date_passed(exam['due_date']),
'does_time_remain': _does_time_remain(attempt),
'enter_exam_endpoint': reverse('edx_proctoring.proctored_exam.attempt.collection'),
'exam_started_poll_url': reverse(
......@@ -1500,6 +1517,10 @@ def _get_proctored_exam_context(exam, attempt, course_id, is_practice_exam=False
'edx_proctoring.proctored_exam.attempt',
args=[attempt['id']]
) if attempt else '',
'update_is_status_acknowledge_url': reverse(
'edx_proctoring.proctored_exam.attempt.review_status',
args=[attempt['id']]
) if attempt else '',
'link_urls': settings.PROCTORING_SETTINGS.get('LINK_URLS', {}),
}
......@@ -1658,13 +1679,22 @@ def _get_proctored_exam_view(exam, context, exam_id, user_id, course_id):
raise NotImplementedError('There is no defined rendering for ProctoredExamStudentAttemptStatus.timed_out!')
elif attempt_status == ProctoredExamStudentAttemptStatus.submitted:
if has_client_app_shutdown(attempt):
student_view_template = 'proctored_exam/submitted.html'
student_view_template = None if _was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam['due_date']
) else 'proctored_exam/submitted.html'
else:
student_view_template = 'proctored_exam/waiting_for_app_shutdown.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.verified:
student_view_template = 'proctored_exam/verified.html'
student_view_template = None if _was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam['due_date']
) else 'proctored_exam/verified.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.rejected:
student_view_template = 'proctored_exam/rejected.html'
student_view_template = None if _was_review_status_acknowledged(
attempt['is_status_acknowledged'],
exam['due_date']
) else 'proctored_exam/rejected.html'
elif attempt_status == ProctoredExamStudentAttemptStatus.ready_to_submit:
student_view_template = 'proctored_exam/ready_to_submit.html'
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('edx_proctoring', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='proctoredexamstudentattempt',
name='is_status_acknowledged',
field=models.BooleanField(default=False),
),
]
......@@ -455,6 +455,10 @@ class ProctoredExamStudentAttempt(TimeStampedModel):
# this ID might point to a record that is in the History table
review_policy_id = models.IntegerField(null=True)
# if student has press the button to explore the exam then true
# else always false
is_status_acknowledged = models.BooleanField(default=False)
class Meta:
""" Meta class for this Django model """
db_table = 'proctoring_proctoredexamstudentattempt'
......
......@@ -81,7 +81,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id", "created", "modified", "user", "started_at", "completed_at",
"external_id", "status", "proctored_exam", "allowed_time_limit_mins",
"attempt_code", "is_sample_attempt", "taking_as_proctored", "last_poll_timestamp",
"last_poll_ipaddr", "review_policy_id", "student_name"
"last_poll_ipaddr", "review_policy_id", "student_name", "is_status_acknowledged"
)
......
......@@ -6,6 +6,8 @@
{% endblocktrans %}
</h3>
{% include 'proctored_exam/visit_exam_content.html' %}
<p>
{% blocktrans %}
You are no longer eligible for academic credit for this course, regardless of your final grade.
......
......@@ -6,6 +6,8 @@
{% endblocktrans %}
</h3>
{% include 'proctored_exam/visit_exam_content.html' %}
<p>
{% blocktrans %}
&#8226; After you quit the proctoring session, the recorded data is uploaded for review. </br>
......
......@@ -6,6 +6,8 @@
{% endblocktrans %}
</h3>
{% include 'proctored_exam/visit_exam_content.html' %}
<p>
{% blocktrans %}
You are eligible to purchase academic credit for this course if you complete all required exams
......
{% load i18n %}
{% if has_due_date_passed %}
<p>
{% blocktrans %}
If you want to view your exam questions and responses, click on the button “let me see my exam”.
When you clicked the button, you will be taken directly to the exam content. The exam’s status will
still be visible to you in the left navigation pane.
{% endblocktrans %}
</p>
<p>
<button type="button" name="visit-exam-content" class="visit-exam-button exam-action-button btn btn-pl-primary btn-base" data-action-url="{{update_is_status_acknowledge_url}}">
{% trans "Let me see my exam" %}
</button>
<div class="clearfix"></div>
</p>
<script type="text/javascript">
$('.visit-exam-button').click(
function(event) {
// cancel any warning messages to end user about leaving proctored exam
$(window).unbind('beforeunload');
var action_url = $(this).data('action-url');
// Update the state of the attempt
$.ajax({
url: action_url,
type: 'PUT',
data: {},
success: function() {
// Reloading page will reflect the new state of the attempt
location.reload()
}
});
}
);
</script>
{% endif %}
......@@ -1548,6 +1548,61 @@ class ProctoredExamApiTests(LoggedInTestCase):
)
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
def test_get_studentview_submitted_status_with_duedate(self):
"""
Test for get_student_view proctored exam which has been submitted
And due date has passed
"""
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
content_id='test_content',
exam_name='Test Exam',
external_id='123aXqe3',
time_limit_mins=30,
is_proctored=True,
is_active=True,
due_date=datetime.now(pytz.UTC) + timedelta(minutes=40)
)
exam_attempt = ProctoredExamStudentAttempt.objects.create(
proctored_exam=proctored_exam,
user=self.user,
allowed_time_limit_mins=30,
taking_as_proctored=True,
external_id=proctored_exam.external_id,
status=ProctoredExamStudentAttemptStatus.submitted,
last_poll_timestamp=datetime.now(pytz.UTC)
)
# due date is after 10 minutes
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=20)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user.id,
course_id='a/b/c',
content_id='test_content',
context={
'is_proctored': True,
'display_name': 'Test Exam',
'default_time_limit_mins': 30
}
)
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
exam_attempt.is_status_acknowledged = True
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user.id,
course_id='a/b/c',
content_id='test_content',
context={
'is_proctored': True,
'display_name': 'Test Exam',
'default_time_limit_mins': 30
}
)
self.assertIsNotNone(rendered_response)
def test_get_studentview_submitted_status_practiceexam(self):
"""
Test for get_student_view practice exam which has been submitted.
......
......@@ -22,7 +22,7 @@ from edx_proctoring.models import (
)
from edx_proctoring.exceptions import (
ProctoredExamIllegalStatusTransition,
)
StudentExamAttemptDoesNotExistsException, ProctoredExamPermissionDenied)
from edx_proctoring.views import require_staff, require_course_or_global_staff
from edx_proctoring.api import (
create_exam,
......@@ -1955,6 +1955,90 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
data = json.loads(response.content)
self.assertEqual(data['exam_type'], expected_exam_type)
def _create_proctored_exam_attempt_with_duedate(self, due_date=datetime.now(pytz.UTC), user=None):
"""
Test the ProctoredExamAttemptReviewStatus view
Create the proctored exam with due date
"""
proctored_exam = ProctoredExam.objects.create(
course_id='a/b/c',
external_id='123aXqe3',
time_limit_mins=30,
is_proctored=True,
due_date=due_date
)
return ProctoredExamStudentAttempt.objects.create(
proctored_exam=proctored_exam,
user=user if user else self.user,
allowed_time_limit_mins=30,
taking_as_proctored=True,
external_id=proctored_exam.external_id,
status=ProctoredExamStudentAttemptStatus.started
)
def test_attempt_review_status_callback(self):
"""
Test the ProctoredExamAttemptReviewStatus view
"""
attempt = self._create_proctored_exam_attempt_with_duedate(
due_date=datetime.now(pytz.UTC) + timedelta(minutes=40)
)
response = self.client.put(
reverse(
'edx_proctoring.proctored_exam.attempt.review_status',
args=[attempt.id]
),
{},
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
def test_attempt_review_status_callback_with_doesnotexit_exception(self):
"""
Test the ProctoredExamAttemptReviewStatus view with does not exit exception
"""
self._create_proctored_exam_attempt_with_duedate(
due_date=datetime.now(pytz.UTC) + timedelta(minutes=40)
)
response = self.client.put(
reverse(
'edx_proctoring.proctored_exam.attempt.review_status',
args=['5']
),
{},
content_type='application/json'
)
self.assertEqual(response.status_code, 400)
self.assertRaises(StudentExamAttemptDoesNotExistsException)
def test_attempt_review_status_callback_with_permission_exception(self):
"""
Test the ProctoredExamAttemptReviewStatus view with permission exception
"""
# creating new user for creating exam attempt
user = User(username='tester_', email='tester@test.com_')
user.save()
attempt = self._create_proctored_exam_attempt_with_duedate(
due_date=datetime.now(pytz.UTC) + timedelta(minutes=40),
user=user
)
response = self.client.put(
reverse(
'edx_proctoring.proctored_exam.attempt.review_status',
args=[attempt.id]
),
{},
content_type='application/json'
)
self.assertEqual(response.status_code, 400)
self.assertRaises(ProctoredExamPermissionDenied)
class TestExamAllowanceView(LoggedInTestCase):
"""
......
......@@ -52,6 +52,11 @@ urlpatterns = patterns( # pylint: disable=invalid-name
name='edx_proctoring.proctored_exam.attempt.collection'
),
url(
r'edx_proctoring/v1/proctored_exam/attempt/(?P<attempt_id>\d+)/review_status$',
views.ProctoredExamAttemptReviewStatus.as_view(),
name='edx_proctoring.proctored_exam.attempt.review_status'
),
url(
r'edx_proctoring/v1/proctored_exam/{}/allowance$'.format(settings.COURSE_ID_PATTERN),
views.ExamAllowanceView.as_view(),
name='edx_proctoring.proctored_exam.allowance'
......
......@@ -29,7 +29,8 @@ from edx_proctoring.api import (
get_all_exams_for_course,
get_exam_attempt_by_id,
remove_exam_attempt,
update_attempt_status
update_attempt_status,
update_exam_attempt
)
from edx_proctoring.exceptions import (
ProctoredBaseException,
......@@ -782,3 +783,42 @@ class ActiveExamsForUserView(AuthenticatedAPIView):
user_id=request.data.get('user_id', None),
course_id=request.data.get('course_id', None)
))
class ProctoredExamAttemptReviewStatus(AuthenticatedAPIView):
"""
Endpoint for updating exam attempt's review status to acknowledged.
edx_proctoring/v1/proctored_exam/attempt/(<attempt_id>)/review_status$
Supports:
HTTP PUT: Update the is_status_acknowledge flag
"""
def put(self, request, attempt_id): # pylint: disable=unused-argument
"""
Update the is_status_acknowledge flag for the specific attempt
"""
try:
attempt = get_exam_attempt_by_id(attempt_id)
# make sure the the attempt belongs to the calling user_id
if attempt and attempt['user']['id'] != request.user.id:
err_msg = (
'Attempted to access attempt_id {attempt_id} but '
'does not have access to it.'.format(
attempt_id=attempt_id
)
)
raise ProctoredExamPermissionDenied(err_msg)
update_exam_attempt(attempt_id, is_status_acknowledged=True)
return Response(
status=status.HTTP_200_OK
)
except (StudentExamAttemptDoesNotExistsException, ProctoredExamPermissionDenied) as ex:
LOG.exception(ex)
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"detail": str(ex)}
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment