Commit f2c320fb by Andy Armstrong Committed by GitHub

Merge pull request #308 from edx/andya/clean-up-tests

Clean up the Python tests
parents 2356452f 1faad3ad
...@@ -53,4 +53,8 @@ coverage/ ...@@ -53,4 +53,8 @@ coverage/
htmlcov/ htmlcov/
acceptance_tests/*.png acceptance_tests/*.png
node_modules/ node_modules/
\ No newline at end of file
# Devstack
edx-proctoring
edx_proctoring.egg-info
...@@ -6,7 +6,6 @@ All tests for the api.py ...@@ -6,7 +6,6 @@ All tests for the api.py
""" """
import ddt import ddt
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.core import mail
from mock import patch from mock import patch
import pytz import pytz
from freezegun import freeze_time from freezegun import freeze_time
...@@ -24,7 +23,6 @@ from edx_proctoring.api import ( ...@@ -24,7 +23,6 @@ from edx_proctoring.api import (
get_active_exams_for_user, get_active_exams_for_user,
get_exam_attempt, get_exam_attempt,
create_exam_attempt, create_exam_attempt,
get_student_view,
get_allowances_for_course, get_allowances_for_course,
get_all_exams_for_course, get_all_exams_for_course,
get_exam_attempt_by_id, get_exam_attempt_by_id,
...@@ -61,304 +59,28 @@ from edx_proctoring.exceptions import ( ...@@ -61,304 +59,28 @@ from edx_proctoring.exceptions import (
from edx_proctoring.models import ( from edx_proctoring.models import (
ProctoredExam, ProctoredExam,
ProctoredExamStudentAllowance, ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttemptStatus,
ProctoredExamReviewPolicy, ProctoredExamReviewPolicy,
) )
from .utils import ( from .utils import (
LoggedInTestCase, ProctoredExamTestCase,
) )
from edx_proctoring.tests.test_services import ( from .test_services import (
MockCreditService, MockCreditServiceWithCourseEndDate, MockCreditService,
MockInstructorService, MockCreditServiceNone, MockCreditServiceNone,
MockCreditServiceWithCourseEndDate,
) )
from edx_proctoring.runtime import set_runtime_service, get_runtime_service from edx_proctoring.runtime import set_runtime_service, get_runtime_service
from eventtracking import tracker
from eventtracking.tracker import Tracker, TRACKERS
class MockTracker(Tracker):
"""
A mocked out tracker which implements the emit method
"""
def emit(self, name=None, data=None):
"""
Overload this method to do nothing
"""
pass
@ddt.ddt @ddt.ddt
class ProctoredExamApiTests(LoggedInTestCase): class ProctoredExamApiTests(ProctoredExamTestCase):
""" """
All tests for the models.py All tests for the models.py
""" """
def setUp(self):
"""
Build out test harnessing
"""
super(ProctoredExamApiTests, self).setUp()
self.default_time_limit = 21
self.course_id = 'test_course'
self.content_id_for_exam_with_due_date = 'test_content_due_date_id'
self.content_id = 'test_content_id'
self.content_id_timed = 'test_content_id_timed'
self.content_id_practice = 'test_content_id_practice'
self.disabled_content_id = 'test_disabled_content_id'
self.exam_name = 'Test Exam'
self.user_id = self.user.id
self.key = 'additional_time_granted'
self.value = '10'
self.external_id = 'test_external_id'
self.proctored_exam_id = self._create_proctored_exam()
self.timed_exam_id = self._create_timed_exam()
self.practice_exam_id = self._create_practice_exam()
self.disabled_exam_id = self._create_disabled_exam()
# Messages for get_student_view
self.start_an_exam_msg = 'This exam is proctored'
self.exam_expired_msg = 'The due date for this exam has passed'
self.timed_exam_msg = '{exam_name} is a Timed Exam'
self.timed_exam_submitted = 'You have submitted your timed exam.'
self.timed_exam_expired = 'The time allotted for this exam has expired.'
self.submitted_timed_exam_msg_with_due_date = 'After the due date has passed,'
self.exam_time_expired_msg = 'You did not complete the exam in the allotted time'
self.exam_time_error_msg = 'A technical error has occurred with your proctored exam'
self.chose_proctored_exam_msg = 'Follow these steps to set up and start your proctored exam'
self.proctored_exam_optout_msg = 'Take this exam as an open exam instead'
self.proctored_exam_completed_msg = 'Are you sure you want to end your proctored exam'
self.proctored_exam_waiting_for_app_shutdown_msg = 'You are about to complete your proctored exam'
self.proctored_exam_submitted_msg = 'You have submitted this proctored exam for review'
self.proctored_exam_verified_msg = 'Your proctoring session was reviewed and passed all requirements'
self.proctored_exam_rejected_msg = 'Your proctoring session was reviewed and did not pass requirements'
self.start_a_practice_exam_msg = 'Get familiar with proctoring for real exams later in the course'
self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam'
self.practice_exam_created_msg = 'Follow these steps to set up and start your proctored exam'
self.practice_exam_completion_msg = 'Are you sure you want to end your proctored exam'
self.ready_to_start_msg = 'Follow these instructions'
self.practice_exam_failed_msg = 'There was a problem with your practice proctoring session'
self.proctored_exam_email_subject = 'Proctoring Session Results Update'
self.proctored_exam_email_body = 'the status of your proctoring session review'
self.footer_msg = 'About Proctored Exams'
self.timed_footer_msg = 'Can I request additional time to complete my exam?'
set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True))
tracker.register_tracker(MockTracker())
self.prerequisites = [
{
'namespace': 'proctoring',
'name': 'proc1',
'order': 2,
'status': 'satisfied',
},
{
'namespace': 'reverification',
'name': 'rever1',
'order': 1,
'status': 'satisfied',
},
{
'namespace': 'grade',
'name': 'grade1',
'order': 0,
'status': 'pending',
},
{
'namespace': 'reverification',
'name': 'rever2',
'order': 3,
'status': 'failed',
},
{
'namespace': 'proctoring',
'name': 'proc2',
'order': 4,
'status': 'pending',
},
]
self.declined_prerequisites = [
{
'namespace': 'proctoring',
'name': 'proc1',
'order': 2,
'status': 'satisfied',
},
{
'namespace': 'reverification',
'name': 'rever1',
'order': 1,
'status': 'satisfied',
},
{
'namespace': 'grade',
'name': 'grade1',
'order': 0,
'status': 'pending',
},
{
'namespace': 'reverification',
'name': 'rever2',
'order': 3,
'status': 'declined',
},
{
'namespace': 'proctoring',
'name': 'proc2',
'order': 4,
'status': 'pending',
},
]
def tearDown(self):
"""
Cleanup
"""
del TRACKERS['default']
def _create_proctored_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit
)
def _create_exam_with_due_time(self, is_proctored=True, is_practice_exam=False, due_date=None):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_proctored=is_proctored,
is_practice_exam=is_practice_exam,
due_date=due_date
)
def _create_timed_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_timed,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_proctored=False
)
def _create_practice_exam(self):
"""
Calls the api's create_exam to create a practice exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_practice,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_practice_exam=True,
is_proctored=True
)
def _create_disabled_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
is_proctored=False,
content_id=self.disabled_content_id,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_active=False
)
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 _create_unstarted_exam_attempt(self, is_proctored=True, is_practice=False):
"""
Creates the ProctoredExamStudentAttempt object.
"""
if is_proctored:
if is_practice:
exam_id = self.practice_exam_id
else:
exam_id = self.proctored_exam_id
else:
exam_id = self.timed_exam_id
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=exam_id,
user_id=self.user_id,
external_id=self.external_id,
allowed_time_limit_mins=10,
status='created'
)
def _create_started_exam_attempt(self, started_at=None, is_proctored=True, is_sample_attempt=False):
"""
Creates the ProctoredExamStudentAttempt object.
"""
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.proctored_exam_id if is_proctored else self.timed_exam_id,
user_id=self.user_id,
external_id=self.external_id,
started_at=started_at if started_at else datetime.now(pytz.UTC),
status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10,
taking_as_proctored=is_proctored,
is_sample_attempt=is_sample_attempt
)
def _create_started_practice_exam_attempt(self, started_at=None):
"""
Creates the ProctoredExamStudentAttempt object.
"""
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.practice_exam_id,
taking_as_proctored=True,
user_id=self.user_id,
external_id=self.external_id,
started_at=started_at if started_at else datetime.now(pytz.UTC),
is_sample_attempt=True,
status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10
)
def _add_allowance_for_user(self): def _add_allowance_for_user(self):
""" """
creates allowance for user. creates allowance for user.
...@@ -1023,215 +745,6 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1023,215 +745,6 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEqual(all_exams[0]['id'], updated_exam_attempt_id) self.assertEqual(all_exams[0]['id'], updated_exam_attempt_id)
self.assertEqual(all_exams[1]['id'], exam_attempt.id) self.assertEqual(all_exams[1]['id'], exam_attempt.id)
def test_get_student_view(self):
"""
Test for get_student_view prompting the user to take the exam
as a timed exam or a proctored exam.
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'hide_after_due': False,
}
)
self.assertIn(
'data-exam-id="{proctored_exam_id}"'.format(proctored_exam_id=self.proctored_exam_id),
rendered_response
)
self.assertIn(self.start_an_exam_msg.format(exam_name=self.exam_name), rendered_response)
# try practice exam variant
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id + 'foo',
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'is_practice_exam': True,
'hide_after_due': False,
}
)
self.assertIn(self.start_a_practice_exam_msg.format(exam_name=self.exam_name), rendered_response)
def test_get_honor_view_with_practice_exam(self):
"""
Test for get_student_view prompting when the student is enrolled in non-verified
track for a practice exam, this should return not None, meaning
student will see proctored content
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'credit_state': {
'enrollment_mode': 'honor'
},
'is_practice_exam': True
}
)
self.assertIsNotNone(rendered_response)
def test_get_honor_view(self):
"""
Test for get_student_view prompting when the student is enrolled in non-verified
track, this should return None
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'credit_state': {
'enrollment_mode': 'honor'
},
'is_practice_exam': False
}
)
self.assertIsNone(rendered_response)
@ddt.data(
('reverification', None, 'The following prerequisites are in a <strong>pending</strong> state', True),
('reverification', 'pending', 'The following prerequisites are in a <strong>pending</strong> state', True),
('reverification', 'failed', 'You did not satisfy the following prerequisites', True),
('reverification', 'satisfied', 'To be eligible to earn credit for this course', False),
('reverification', 'declined', None, False),
('proctored_exam', None, 'The following prerequisites are in a <strong>pending</strong> state', True),
('proctored_exam', 'pending', 'The following prerequisites are in a <strong>pending</strong> state', True),
('proctored_exam', 'failed', 'You did not satisfy the following prerequisites', True),
('proctored_exam', 'satisfied', 'To be eligible to earn credit for this course', False),
('proctored_exam', 'declined', None, False),
('grade', 'failed', 'To be eligible to earn credit for this course', False),
# this is nonesense, but let's double check it
('grade', 'declined', 'To be eligible to earn credit for this course', False),
)
@ddt.unpack
def test_prereq_scenarios(self, namespace, req_status, expected_content, should_see_prereq):
"""
This test asserts that proctoring will not be displayed under the following
conditions:
- Verified student has not completed all 'reverification' requirements
"""
exam = get_exam_by_id(self.proctored_exam_id)
# user hasn't attempted reverifications
rendered_response = get_student_view(
user_id=self.user_id,
course_id=exam['course_id'],
content_id=exam['content_id'],
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'is_practice_exam': False,
'credit_state': {
'enrollment_mode': 'verified',
'credit_requirement_status': [
{
'namespace': namespace,
'name': 'foo',
'display_name': 'Foo Requirement',
'status': req_status,
'order': 0
}
]
}
}
)
if expected_content:
self.assertIn(expected_content, rendered_response)
else:
self.assertIsNone(rendered_response)
if req_status == 'declined' and not expected_content:
# also we should have auto-declined if a pre-requisite was declined
attempt = get_exam_attempt(exam['id'], self.user_id)
self.assertIsNotNone(attempt)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.declined)
if should_see_prereq:
self.assertIn('Foo Requirement', rendered_response)
def test_student_view_non_student(self):
"""
Make sure that if we ask for a student view if we are not in a student role,
then we don't see any proctoring views
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
},
user_role='staff'
)
self.assertIsNone(rendered_response)
def test_wrong_exam_combo(self):
"""
Verify that we get a None back when rendering a view
for a practice, non-proctored exam. This is unsupported.
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id='foo',
content_id='bar',
context={
'is_proctored': False,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'hide_after_due': False,
},
user_role='student'
)
self.assertIsNone(rendered_response)
def test_proctored_exam_passed_end_date(self):
"""
Verify that we get a None back on a proctored exam
if the course end date is passed
"""
set_runtime_service('credit', MockCreditServiceWithCourseEndDate())
rendered_response = get_student_view(
user_id=self.user_id,
course_id='foo',
content_id='bar',
context={
'is_proctored': True,
'is_practice_exam': False,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'due_date': None,
'hide_after_due': False,
},
user_role='student'
)
self.assertIsNone(rendered_response)
def test_proctored_status_summary_passed_end_date(self): def test_proctored_status_summary_passed_end_date(self):
""" """
Assert that we get the expected status summaries Assert that we get the expected status summaries
...@@ -1250,923 +763,89 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -1250,923 +763,89 @@ class ProctoredExamApiTests(LoggedInTestCase):
} }
self.assertIn(summary, [expected]) self.assertIn(summary, [expected])
def test_practice_exam_passed_end_date(self): def test_submitted_credit_state(self):
""" """
Verify that we get a None back on a practice exam Verify that putting an attempt into the submitted state will also mark
if the course end date is passed the credit requirement as submitted
""" """
exam_attempt = self._create_started_exam_attempt()
set_runtime_service('credit', MockCreditServiceWithCourseEndDate()) update_attempt_status(
exam_attempt.proctored_exam_id,
rendered_response = get_student_view( self.user.id,
user_id=self.user_id, ProctoredExamStudentAttemptStatus.submitted
course_id='foo',
content_id='bar',
context={
'is_proctored': True,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'due_date': None,
'hide_after_due': False,
},
user_role='student'
) )
self.assertIsNone(rendered_response)
def test_get_disabled_student_view(self): credit_service = get_runtime_service('credit')
""" credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
Assert that a disabled proctored exam will not override the
student_view self.assertEqual(len(credit_status['credit_requirement_status']), 1)
""" self.assertEqual(
self.assertIsNone( credit_status['credit_requirement_status'][0]['status'],
get_student_view( 'submitted'
user_id=self.user_id,
course_id=self.course_id,
content_id=self.disabled_content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
) )
def test_get_studentview_unstarted_exam(self): def test_error_credit_state(self):
""" """
Test for get_student_view proctored exam which has not started yet. Verify that putting an attempt into the error state will also mark
the credit requirement as failed
""" """
exam_attempt = self._create_started_exam_attempt()
self._create_unstarted_exam_attempt()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_msg, rendered_response)
# now make sure content remains the same if
# the status transitions to 'download_software_clicked'
update_attempt_status( update_attempt_status(
self.proctored_exam_id, exam_attempt.proctored_exam_id,
self.user_id, self.user.id,
ProctoredExamStudentAttemptStatus.download_software_clicked ProctoredExamStudentAttemptStatus.error
) )
rendered_response = get_student_view( credit_service = get_runtime_service('credit')
user_id=self.user_id, credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
course_id=self.course_id,
content_id=self.content_id, self.assertEqual(len(credit_status['credit_requirement_status']), 1)
context={ self.assertEqual(
'is_proctored': True, credit_status['credit_requirement_status'][0]['status'],
'display_name': self.exam_name, 'failed'
'default_time_limit_mins': 90
}
) )
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_msg, rendered_response)
def test_get_studentview_unstarted_practice_exam(self): @ddt.data(
(
ProctoredExamStudentAttemptStatus.declined,
False,
None,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
False,
None,
None
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.created
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.verified
),
(
ProctoredExamStudentAttemptStatus.declined,
True,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.submitted
),
)
@ddt.unpack
def test_cascading(self, to_status, create_attempt, second_attempt_status, expected_second_status):
""" """
Test for get_student_view Practice exam which has not started yet. Make sure that when we decline/reject one attempt all other exams in the course
are auto marked as declined
""" """
self._create_unstarted_exam_attempt(is_practice=True) # create other exams in course
second_exam_id = create_exam(
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'is_practice_exam': True,
'default_time_limit_mins': 90
}
)
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertNotIn(self.proctored_exam_optout_msg, rendered_response)
def test_declined_attempt(self):
"""
Make sure that a declined attempt does not show proctoring
"""
attempt_obj = self._create_unstarted_exam_attempt()
attempt_obj.status = ProctoredExamStudentAttemptStatus.declined
attempt_obj.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
def test_get_studentview_ready(self):
"""
Assert that we get the right content
when the exam is ready to be started
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_start
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.ready_to_start_msg, rendered_response)
def test_get_studentview_started_exam(self):
"""
Test for get_student_view proctored exam which has started.
"""
self._create_started_exam_attempt()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
def test_get_studentview_started_practice_exam(self):
"""
Test for get_student_view practice proctored exam which has started.
"""
self._create_started_practice_exam_attempt()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
def test_get_studentview_started_timed_exam(self):
"""
Test for get_student_view timed exam which has started.
"""
self._create_started_exam_attempt(is_proctored=False)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
@ddt.data(True, False)
def test_get_studentview_long_limit(self, under_exception):
"""
Test for hide_extra_time_footer on exams with > 20 hours time limit
"""
exam_id = self._create_exam_with_due_time(is_proctored=False, )
if under_exception:
update_exam(exam_id, time_limit_mins=((20 * 60))) # exactly 20 hours
else:
update_exam(exam_id, time_limit_mins=((20 * 60) + 1)) # 1 minute greater than 20 hours
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': False,
'display_name': self.exam_name,
}
)
if under_exception:
self.assertIn(self.timed_footer_msg, rendered_response)
else:
self.assertNotIn(self.timed_footer_msg, rendered_response)
@ddt.data(
(datetime.now(pytz.UTC) + timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(days=1), True),
)
@ddt.unpack
def test_get_studentview_submitted_timed_exam_with_past_due_date(self, due_date, hide_after_due):
"""
Test for get_student_view timed exam with the due date.
"""
# exam is created with due datetime which has already passed
exam_id = self._create_exam_with_due_time(is_proctored=False, due_date=due_date)
if hide_after_due:
update_exam(exam_id, hide_after_due=hide_after_due)
# now create the timed_exam attempt in the submitted state
self._create_exam_attempt(exam_id, status='submitted')
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': False,
'display_name': self.exam_name,
'default_time_limit_mins': 10,
'due_date': due_date,
}
)
if datetime.now(pytz.UTC) < due_date:
self.assertIn(self.timed_exam_submitted, rendered_response)
self.assertIn(self.submitted_timed_exam_msg_with_due_date, rendered_response)
elif hide_after_due:
self.assertIn(self.timed_exam_submitted, rendered_response)
self.assertNotIn(self.submitted_timed_exam_msg_with_due_date, rendered_response)
else:
self.assertIsNone(rendered_response)
def test_proctored_exam_attempt_with_past_due_datetime(self):
"""
Test for get_student_view for proctored exam with past due datetime
"""
due_date = datetime.now(pytz.UTC) + timedelta(days=1)
# exam is created with due datetime which has already passed
self._create_exam_with_due_time(due_date=due_date)
# due_date is exactly after 24 hours, if student arrives after 2 days
# then he can not attempt the proctored exam
reset_time = due_date + timedelta(days=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
# call the view again, because the first call set the exam attempt to 'expired'
# this second call will render the view based on the state
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
def test_timed_exam_attempt_with_past_due_datetime(self):
"""
Test for get_student_view for timed exam with past due datetime
"""
due_date = datetime.now(pytz.UTC) + timedelta(days=1)
# exam is created with due datetime which has already passed
self._create_exam_with_due_time(
due_date=due_date,
is_proctored=False
)
# due_date is exactly after 24 hours, if student arrives after 2 days
# then he can not attempt the proctored exam
reset_time = due_date + timedelta(days=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': False,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
# call the view again, because the first call set the exam attempt to 'expired'
# this second call will render the view based on the state
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
@patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_get_studentview_timedout(self):
"""
Verifies that if we call get_studentview when the timer has expired
it will automatically state transition into timed_out
"""
self._create_started_exam_attempt()
reset_time = datetime.now(pytz.UTC) + timedelta(days=1)
with freeze_time(reset_time):
with self.assertRaises(NotImplementedError):
get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
def test_get_studentview_submitted_status(self):
"""
Test for get_student_view proctored exam which has been submitted.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_waiting_for_app_shutdown_msg, rendered_response)
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
# now make sure if this status transitions to 'second_review_required'
# the student will still see a 'submitted' message
update_attempt_status(
exam_attempt.proctored_exam_id,
exam_attempt.user_id,
ProctoredExamStudentAttemptStatus.second_review_required
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
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.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_waiting_for_app_shutdown_msg, rendered_response)
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_submitted_msg, rendered_response)
@ddt.data(
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
)
def test_get_studentview_created_status_practiceexam(self, status):
"""
Test for get_student_view practice exam which has been created.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = status
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_created_msg, rendered_response)
def test_get_studentview_ready_to_start_status_practiceexam(self):
"""
Test for get_student_view practice exam which is ready to start.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_start
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.ready_to_start_msg, rendered_response)
def test_get_studentview_compelete_status_practiceexam(self):
"""
Test for get_student_view practice exam when it is complete/ready to submit.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_submit
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_completion_msg, rendered_response)
def test_get_studentview_rejected_status(self):
"""
Test for get_student_view proctored exam which has been rejected.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.rejected
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_rejected_msg, rendered_response)
def test_get_studentview_verified_status(self):
"""
Test for get_student_view proctored exam which has been verified.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.verified
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_verified_msg, rendered_response)
def test_get_studentview_completed_status(self):
"""
Test for get_student_view proctored exam which has been completed.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_submit
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_completed_msg, rendered_response)
@patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_get_studentview_expired(self):
"""
Test for get_student_view proctored exam which has expired. Since we don't have a template
for that view rendering, it will throw a NotImplementedError
"""
self._create_started_exam_attempt(started_at=datetime.now(pytz.UTC).replace(year=2010))
with self.assertRaises(NotImplementedError):
get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
def test_get_studentview_erroneous_exam(self):
"""
Test for get_student_view proctored exam which has exam status error.
"""
ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.proctored_exam_id,
user_id=self.user_id,
external_id=self.external_id,
started_at=datetime.now(pytz.UTC),
allowed_time_limit_mins=10,
status='error'
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 10
}
)
self.assertIn(self.exam_time_error_msg, rendered_response)
def test_get_studentview_erroneous_practice_exam(self):
"""
Test for get_student_view practice exam which has exam status error.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.error
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_failed_msg, rendered_response)
def test_get_studentview_unstarted_timed_exam(self):
"""
Test for get_student_view Timed exam which is not proctored and has not started yet.
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id="abc",
content_id=self.content_id,
context={
'is_proctored': False,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'hide_after_due': False,
}
)
self.assertNotIn(
'data-exam-id="{proctored_exam_id}"'.format(proctored_exam_id=self.proctored_exam_id),
rendered_response
)
self.assertIn(self.timed_exam_msg.format(exam_name=self.exam_name), rendered_response)
self.assertIn('1 hour and 30 minutes', rendered_response)
self.assertNotIn(self.start_an_exam_msg.format(exam_name=self.exam_name), rendered_response)
def test_get_studentview_unstarted_timed_exam_with_allowance(self):
"""
Test for get_student_view Timed exam which is not proctored and has not started yet.
But user has an allowance
"""
allowed_extra_time = 10
add_allowance_for_user(
self.timed_exam_id,
self.user.username,
ProctoredExamStudentAllowance.ADDITIONAL_TIME_GRANTED,
str(allowed_extra_time)
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={}
)
self.assertNotIn(
'data-exam-id="{proctored_exam_id}"'.format(proctored_exam_id=self.proctored_exam_id),
rendered_response
)
self.assertIn(self.timed_exam_msg.format(exam_name=self.exam_name), rendered_response)
self.assertIn('31 minutes', rendered_response)
self.assertNotIn(self.start_an_exam_msg.format(exam_name=self.exam_name), rendered_response)
@ddt.data(
(
ProctoredExamStudentAttemptStatus.ready_to_submit,
'Are you sure that you want to submit your timed exam?'
),
(
ProctoredExamStudentAttemptStatus.submitted,
'You have submitted your timed exam'
),
)
@ddt.unpack
def test_get_studentview_completed_timed_exam(self, status, expected_content):
"""
Test for get_student_view timed exam which has completed.
"""
exam_attempt = self._create_started_exam_attempt(is_proctored=False)
exam_attempt.status = status
if status == 'submitted':
exam_attempt.completed_at = datetime.now(pytz.UTC)
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'display_name': self.exam_name,
}
)
self.assertIn(expected_content, rendered_response)
def test_expired_exam(self):
"""
Test that an expired exam shows a difference message when the exam is expired just recently
"""
# create exam with completed_at equal to current time and started_at equal to allowed_time_limit_mins ago
attempt = self._create_started_exam_attempt(is_proctored=False)
attempt.status = "submitted"
attempt.started_at = attempt.started_at - timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.completed_at = attempt.started_at + timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'display_name': self.exam_name,
}
)
self.assertIn(self.timed_exam_expired, rendered_response)
# update start and completed time such that completed_time is allowed_time_limit_mins ago than the current time
attempt.started_at = attempt.started_at - timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.completed_at = attempt.completed_at - timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'display_name': self.exam_name,
}
)
self.assertIn(self.timed_exam_submitted, rendered_response)
def test_submitted_credit_state(self):
"""
Verify that putting an attempt into the submitted state will also mark
the credit requirement as submitted
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.submitted
)
credit_service = get_runtime_service('credit')
credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 1)
self.assertEqual(
credit_status['credit_requirement_status'][0]['status'],
'submitted'
)
def test_error_credit_state(self):
"""
Verify that putting an attempt into the error state will also mark
the credit requirement as failed
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
ProctoredExamStudentAttemptStatus.error
)
credit_service = get_runtime_service('credit')
credit_status = credit_service.get_credit_state(self.user.id, exam_attempt.proctored_exam.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 1)
self.assertEqual(
credit_status['credit_requirement_status'][0]['status'],
'failed'
)
@ddt.data(
(
ProctoredExamStudentAttemptStatus.declined,
False,
None,
ProctoredExamStudentAttemptStatus.declined
),
(
ProctoredExamStudentAttemptStatus.rejected,
False,
None,
None
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.created
),
(
ProctoredExamStudentAttemptStatus.rejected,
True,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.verified
),
(
ProctoredExamStudentAttemptStatus.declined,
True,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.submitted
),
)
@ddt.unpack
def test_cascading(self, to_status, create_attempt, second_attempt_status, expected_second_status):
"""
Make sure that when we decline/reject one attempt all other exams in the course
are auto marked as declined
"""
# create other exams in course
second_exam_id = create_exam(
course_id=self.course_id, course_id=self.course_id,
content_id="2nd exam", content_id="2nd exam",
exam_name="2nd exam", exam_name="2nd exam",
...@@ -2656,183 +1335,6 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -2656,183 +1335,6 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEquals(attempt['last_poll_timestamp'], now) self.assertEquals(attempt['last_poll_timestamp'], now)
self.assertEquals(attempt['last_poll_ipaddr'], '1.1.1.1') self.assertEquals(attempt['last_poll_ipaddr'], '1.1.1.1')
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
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)
@ddt.data(
ProctoredExamStudentAttemptStatus.second_review_required,
ProctoredExamStudentAttemptStatus.error
)
def test_email_not_sent(self, status):
"""
Assert than email is not sent on the following statuses of proctoring attempt
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
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)
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit,
ProctoredExamStudentAttemptStatus.declined,
ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.error
)
@patch.dict('settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_not_send_email(self, status):
"""
Assert that email is not sent on the following statuses of proctoring attempt.
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_sample_exam(self, status):
"""
Assert that email is not sent when there is practice/sample exam
"""
exam_attempt = self._create_started_exam_attempt(is_sample_attempt=True)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_timed_exam(self, status):
"""
Assert that email is not sent when exam is timed/not-proctoring
"""
exam_attempt = self._create_started_exam_attempt(is_proctored=False)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected,
)
def test_footer_present(self, status):
"""
Make sure the footer content is visible in the rendered output
"""
if status != ProctoredExamStudentAttemptStatus.eligible:
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = status
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNotNone(rendered_response)
if status == ProctoredExamStudentAttemptStatus.submitted:
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.footer_msg, rendered_response)
else:
self.assertIn(self.footer_msg, rendered_response)
def test_requirement_status_order(self): def test_requirement_status_order(self):
""" """
Make sure that we get a correct ordered list of all statuses sorted in the correct Make sure that we get a correct ordered list of all statuses sorted in the correct
......
# coding=utf-8
"""
All tests for proctored exam emails.
"""
import ddt
from django.core import mail
from mock import patch
from edx_proctoring.api import (
update_attempt_status,
)
from edx_proctoring.models import (
ProctoredExamStudentAttemptStatus,
)
from edx_proctoring.runtime import set_runtime_service, get_runtime_service
from .test_services import (
MockCreditService,
)
from .utils import (
ProctoredExamTestCase,
)
@ddt.ddt
class ProctoredExamEmailTests(ProctoredExamTestCase):
"""
All tests for proctored exam emails.
"""
def setUp(self):
"""
Build out test harnessing
"""
super(ProctoredExamEmailTests, self).setUp()
# Messages for get_student_view
self.proctored_exam_email_subject = 'Proctoring Session Results Update'
self.proctored_exam_email_body = 'the status of your proctoring session review'
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
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)
@ddt.data(
ProctoredExamStudentAttemptStatus.second_review_required,
ProctoredExamStudentAttemptStatus.error
)
def test_email_not_sent(self, status):
"""
Assert than email is not sent on the following statuses of proctoring attempt
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
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)
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.ready_to_start,
ProctoredExamStudentAttemptStatus.started,
ProctoredExamStudentAttemptStatus.ready_to_submit,
ProctoredExamStudentAttemptStatus.declined,
ProctoredExamStudentAttemptStatus.timed_out,
ProctoredExamStudentAttemptStatus.error
)
@patch.dict('settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_not_send_email(self, status):
"""
Assert that email is not sent on the following statuses of proctoring attempt.
"""
exam_attempt = self._create_started_exam_attempt()
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_sample_exam(self, status):
"""
Assert that email is not sent when there is practice/sample exam
"""
exam_attempt = self._create_started_exam_attempt(is_sample_attempt=True)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
@ddt.data(
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected
)
def test_not_send_email_timed_exam(self, status):
"""
Assert that email is not sent when exam is timed/not-proctoring
"""
exam_attempt = self._create_started_exam_attempt(is_proctored=False)
update_attempt_status(
exam_attempt.proctored_exam_id,
self.user.id,
status
)
self.assertEquals(len(mail.outbox), 0)
# coding=utf-8
# pylint: disable=too-many-lines, invalid-name
"""
All tests for the api.py
"""
import ddt
from datetime import datetime, timedelta
from mock import patch
import pytz
from freezegun import freeze_time
from edx_proctoring.api import (
update_exam,
get_exam_by_id,
add_allowance_for_user,
get_exam_attempt,
get_student_view,
update_attempt_status,
)
from edx_proctoring.models import (
ProctoredExam,
ProctoredExamStudentAllowance,
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus,
)
from edx_proctoring.runtime import set_runtime_service
from .test_services import MockCreditServiceWithCourseEndDate
from .utils import ProctoredExamTestCase
@ddt.ddt
class ProctoredExamStudentViewTests(ProctoredExamTestCase):
"""
All tests for the student view
"""
def setUp(self):
"""
Build out test harnessing
"""
super(ProctoredExamStudentViewTests, self).setUp()
# Messages for get_student_view
self.start_an_exam_msg = 'This exam is proctored'
self.exam_expired_msg = 'The due date for this exam has passed'
self.timed_exam_msg = '{exam_name} is a Timed Exam'
self.timed_exam_submitted = 'You have submitted your timed exam.'
self.timed_exam_expired = 'The time allotted for this exam has expired.'
self.submitted_timed_exam_msg_with_due_date = 'After the due date has passed,'
self.exam_time_expired_msg = 'You did not complete the exam in the allotted time'
self.exam_time_error_msg = 'A technical error has occurred with your proctored exam'
self.chose_proctored_exam_msg = 'Follow these steps to set up and start your proctored exam'
self.proctored_exam_optout_msg = 'Take this exam as an open exam instead'
self.proctored_exam_completed_msg = 'Are you sure you want to end your proctored exam'
self.proctored_exam_waiting_for_app_shutdown_msg = 'You are about to complete your proctored exam'
self.proctored_exam_submitted_msg = 'You have submitted this proctored exam for review'
self.proctored_exam_verified_msg = 'Your proctoring session was reviewed and passed all requirements'
self.proctored_exam_rejected_msg = 'Your proctoring session was reviewed and did not pass requirements'
self.start_a_practice_exam_msg = 'Get familiar with proctoring for real exams later in the course'
self.practice_exam_submitted_msg = 'You have submitted this practice proctored exam'
self.practice_exam_created_msg = 'Follow these steps to set up and start your proctored exam'
self.practice_exam_completion_msg = 'Are you sure you want to end your proctored exam'
self.ready_to_start_msg = 'Follow these instructions'
self.practice_exam_failed_msg = 'There was a problem with your practice proctoring session'
self.footer_msg = 'About Proctored Exams'
self.timed_footer_msg = 'Can I request additional time to complete my exam?'
def test_get_student_view(self):
"""
Test for get_student_view prompting the user to take the exam
as a timed exam or a proctored exam.
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'hide_after_due': False,
}
)
self.assertIn(
'data-exam-id="{proctored_exam_id}"'.format(proctored_exam_id=self.proctored_exam_id),
rendered_response
)
self.assertIn(self.start_an_exam_msg.format(exam_name=self.exam_name), rendered_response)
# try practice exam variant
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id + 'foo',
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'is_practice_exam': True,
'hide_after_due': False,
}
)
self.assertIn(self.start_a_practice_exam_msg.format(exam_name=self.exam_name), rendered_response)
def test_get_honor_view_with_practice_exam(self):
"""
Test for get_student_view prompting when the student is enrolled in non-verified
track for a practice exam, this should return not None, meaning
student will see proctored content
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'credit_state': {
'enrollment_mode': 'honor'
},
'is_practice_exam': True
}
)
self.assertIsNotNone(rendered_response)
def test_get_honor_view(self):
"""
Test for get_student_view prompting when the student is enrolled in non-verified
track, this should return None
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'credit_state': {
'enrollment_mode': 'honor'
},
'is_practice_exam': False
}
)
self.assertIsNone(rendered_response)
@ddt.data(
('reverification', None, 'The following prerequisites are in a <strong>pending</strong> state', True),
('reverification', 'pending', 'The following prerequisites are in a <strong>pending</strong> state', True),
('reverification', 'failed', 'You did not satisfy the following prerequisites', True),
('reverification', 'satisfied', 'To be eligible to earn credit for this course', False),
('reverification', 'declined', None, False),
('proctored_exam', None, 'The following prerequisites are in a <strong>pending</strong> state', True),
('proctored_exam', 'pending', 'The following prerequisites are in a <strong>pending</strong> state', True),
('proctored_exam', 'failed', 'You did not satisfy the following prerequisites', True),
('proctored_exam', 'satisfied', 'To be eligible to earn credit for this course', False),
('proctored_exam', 'declined', None, False),
('grade', 'failed', 'To be eligible to earn credit for this course', False),
# this is nonesense, but let's double check it
('grade', 'declined', 'To be eligible to earn credit for this course', False),
)
@ddt.unpack
def test_prereq_scenarios(self, namespace, req_status, expected_content, should_see_prereq):
"""
This test asserts that proctoring will not be displayed under the following
conditions:
- Verified student has not completed all 'reverification' requirements
"""
exam = get_exam_by_id(self.proctored_exam_id)
# user hasn't attempted reverifications
rendered_response = get_student_view(
user_id=self.user_id,
course_id=exam['course_id'],
content_id=exam['content_id'],
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'is_practice_exam': False,
'credit_state': {
'enrollment_mode': 'verified',
'credit_requirement_status': [
{
'namespace': namespace,
'name': 'foo',
'display_name': 'Foo Requirement',
'status': req_status,
'order': 0
}
]
},
}
)
if expected_content:
self.assertIn(expected_content, rendered_response)
else:
self.assertIsNone(rendered_response)
if req_status == 'declined' and not expected_content:
# also we should have auto-declined if a pre-requisite was declined
attempt = get_exam_attempt(exam['id'], self.user_id)
self.assertIsNotNone(attempt)
self.assertEqual(attempt['status'], ProctoredExamStudentAttemptStatus.declined)
if should_see_prereq:
self.assertIn('Foo Requirement', rendered_response)
def test_student_view_non_student(self):
"""
Make sure that if we ask for a student view if we are not in a student role,
then we don't see any proctoring views
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
},
user_role='staff'
)
self.assertIsNone(rendered_response)
def test_wrong_exam_combo(self):
"""
Verify that we get a None back when rendering a view
for a practice, non-proctored exam. This is unsupported.
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id='foo',
content_id='bar',
context={
'is_proctored': False,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'hide_after_due': False,
},
user_role='student'
)
self.assertIsNone(rendered_response)
def test_proctored_exam_passed_end_date(self):
"""
Verify that we get a None back on a proctored exam
if the course end date is passed
"""
set_runtime_service('credit', MockCreditServiceWithCourseEndDate())
rendered_response = get_student_view(
user_id=self.user_id,
course_id='foo',
content_id='bar',
context={
'is_proctored': True,
'is_practice_exam': False,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'due_date': None,
'hide_after_due': False,
},
user_role='student'
)
self.assertIsNone(rendered_response)
def test_practice_exam_passed_end_date(self):
"""
Verify that we get a None back on a practice exam
if the course end date is passed
"""
set_runtime_service('credit', MockCreditServiceWithCourseEndDate())
rendered_response = get_student_view(
user_id=self.user_id,
course_id='foo',
content_id='bar',
context={
'is_proctored': True,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'due_date': None,
'hide_after_due': False,
},
user_role='student'
)
self.assertIsNone(rendered_response)
def test_get_disabled_student_view(self):
"""
Assert that a disabled proctored exam will not override the
student_view
"""
self.assertIsNone(
get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.disabled_content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
)
def test_get_studentview_unstarted_exam(self):
"""
Test for get_student_view proctored exam which has not started yet.
"""
self._create_unstarted_exam_attempt()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_msg, rendered_response)
# now make sure content remains the same if
# the status transitions to 'download_software_clicked'
update_attempt_status(
self.proctored_exam_id,
self.user_id,
ProctoredExamStudentAttemptStatus.download_software_clicked
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertIn(self.proctored_exam_optout_msg, rendered_response)
def test_get_studentview_unstarted_practice_exam(self):
"""
Test for get_student_view Practice exam which has not started yet.
"""
self._create_unstarted_exam_attempt(is_practice=True)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'is_practice_exam': True,
'default_time_limit_mins': 90
}
)
self.assertIn(self.chose_proctored_exam_msg, rendered_response)
self.assertNotIn(self.proctored_exam_optout_msg, rendered_response)
def test_declined_attempt(self):
"""
Make sure that a declined attempt does not show proctoring
"""
attempt_obj = self._create_unstarted_exam_attempt()
attempt_obj.status = ProctoredExamStudentAttemptStatus.declined
attempt_obj.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
def test_get_studentview_ready(self):
"""
Assert that we get the right content
when the exam is ready to be started
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_start
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.ready_to_start_msg, rendered_response)
def test_get_studentview_started_exam(self):
"""
Test for get_student_view proctored exam which has started.
"""
self._create_started_exam_attempt()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
def test_get_studentview_started_practice_exam(self):
"""
Test for get_student_view practice proctored exam which has started.
"""
self._create_started_practice_exam_attempt()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
def test_get_studentview_started_timed_exam(self):
"""
Test for get_student_view timed exam which has started.
"""
self._create_started_exam_attempt(is_proctored=False)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNone(rendered_response)
@ddt.data(True, False)
def test_get_studentview_long_limit(self, under_exception):
"""
Test for hide_extra_time_footer on exams with > 20 hours time limit
"""
exam_id = self._create_exam_with_due_time(is_proctored=False, )
if under_exception:
update_exam(exam_id, time_limit_mins=((20 * 60))) # exactly 20 hours
else:
update_exam(exam_id, time_limit_mins=((20 * 60) + 1)) # 1 minute greater than 20 hours
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': False,
'display_name': self.exam_name,
}
)
if under_exception:
self.assertIn(self.timed_footer_msg, rendered_response)
else:
self.assertNotIn(self.timed_footer_msg, rendered_response)
@ddt.data(
(datetime.now(pytz.UTC) + timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(days=1), False),
(datetime.now(pytz.UTC) - timedelta(days=1), True),
)
@ddt.unpack
def test_get_studentview_submitted_timed_exam_with_past_due_date(self, due_date, hide_after_due):
"""
Test for get_student_view timed exam with the due date.
"""
# exam is created with due datetime which has already passed
exam_id = self._create_exam_with_due_time(is_proctored=False, due_date=due_date)
if hide_after_due:
update_exam(exam_id, hide_after_due=hide_after_due)
# now create the timed_exam attempt in the submitted state
self._create_exam_attempt(exam_id, status='submitted')
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': False,
'display_name': self.exam_name,
'default_time_limit_mins': 10,
'due_date': due_date,
}
)
if datetime.now(pytz.UTC) < due_date:
self.assertIn(self.timed_exam_submitted, rendered_response)
self.assertIn(self.submitted_timed_exam_msg_with_due_date, rendered_response)
elif hide_after_due:
self.assertIn(self.timed_exam_submitted, rendered_response)
self.assertNotIn(self.submitted_timed_exam_msg_with_due_date, rendered_response)
else:
self.assertIsNone(rendered_response)
def test_proctored_exam_attempt_with_past_due_datetime(self):
"""
Test for get_student_view for proctored exam with past due datetime
"""
due_date = datetime.now(pytz.UTC) + timedelta(days=1)
# exam is created with due datetime which has already passed
self._create_exam_with_due_time(due_date=due_date)
# due_date is exactly after 24 hours, if student arrives after 2 days
# then he can not attempt the proctored exam
reset_time = due_date + timedelta(days=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
# call the view again, because the first call set the exam attempt to 'expired'
# this second call will render the view based on the state
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
def test_timed_exam_attempt_with_past_due_datetime(self):
"""
Test for get_student_view for timed exam with past due datetime
"""
due_date = datetime.now(pytz.UTC) + timedelta(days=1)
# exam is created with due datetime which has already passed
self._create_exam_with_due_time(
due_date=due_date,
is_proctored=False
)
# due_date is exactly after 24 hours, if student arrives after 2 days
# then he can not attempt the proctored exam
reset_time = due_date + timedelta(days=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': False,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
# call the view again, because the first call set the exam attempt to 'expired'
# this second call will render the view based on the state
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
context={
'is_proctored': True,
'is_practice_exam': True,
'display_name': self.exam_name,
'default_time_limit_mins': self.default_time_limit,
'due_date': due_date,
}
)
self.assertIn(self.exam_expired_msg, rendered_response)
@patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_get_studentview_timedout(self):
"""
Verifies that if we call get_studentview when the timer has expired
it will automatically state transition into timed_out
"""
self._create_started_exam_attempt()
reset_time = datetime.now(pytz.UTC) + timedelta(days=1)
with freeze_time(reset_time):
with self.assertRaises(NotImplementedError):
get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
def test_get_studentview_submitted_status(self):
"""
Test for get_student_view proctored exam which has been submitted.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_waiting_for_app_shutdown_msg, rendered_response)
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_submitted_msg, rendered_response)
# now make sure if this status transitions to 'second_review_required'
# the student will still see a 'submitted' message
update_attempt_status(
exam_attempt.proctored_exam_id,
exam_attempt.user_id,
ProctoredExamStudentAttemptStatus.second_review_required
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
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.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.submitted
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_waiting_for_app_shutdown_msg, rendered_response)
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_submitted_msg, rendered_response)
@ddt.data(
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
)
def test_get_studentview_created_status_practiceexam(self, status):
"""
Test for get_student_view practice exam which has been created.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = status
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_created_msg, rendered_response)
def test_get_studentview_ready_to_start_status_practiceexam(self):
"""
Test for get_student_view practice exam which is ready to start.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_start
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.ready_to_start_msg, rendered_response)
def test_get_studentview_complete_status_practiceexam(self):
"""
Test for get_student_view practice exam when it is complete/ready to submit.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_submit
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_completion_msg, rendered_response)
def test_get_studentview_rejected_status(self):
"""
Test for get_student_view proctored exam which has been rejected.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.rejected
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_rejected_msg, rendered_response)
def test_get_studentview_verified_status(self):
"""
Test for get_student_view proctored exam which has been verified.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.verified
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_verified_msg, rendered_response)
def test_get_studentview_completed_status(self):
"""
Test for get_student_view proctored exam which has been completed.
"""
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.ready_to_submit
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.proctored_exam_completed_msg, rendered_response)
@patch.dict('django.conf.settings.PROCTORING_SETTINGS', {'ALLOW_TIMED_OUT_STATE': True})
def test_get_studentview_expired(self):
"""
Test for get_student_view proctored exam which has expired. Since we don't have a template
for that view rendering, it will throw a NotImplementedError
"""
self._create_started_exam_attempt(started_at=datetime.now(pytz.UTC).replace(year=2010))
with self.assertRaises(NotImplementedError):
get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
def test_get_studentview_erroneous_exam(self):
"""
Test for get_student_view proctored exam which has exam status error.
"""
ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.proctored_exam_id,
user_id=self.user_id,
external_id=self.external_id,
started_at=datetime.now(pytz.UTC),
allowed_time_limit_mins=10,
status='error'
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 10
}
)
self.assertIn(self.exam_time_error_msg, rendered_response)
def test_get_studentview_erroneous_practice_exam(self):
"""
Test for get_student_view practice exam which has exam status error.
"""
exam_attempt = self._create_started_practice_exam_attempt()
exam_attempt.status = ProctoredExamStudentAttemptStatus.error
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_practice,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.practice_exam_failed_msg, rendered_response)
def test_get_studentview_unstarted_timed_exam(self):
"""
Test for get_student_view Timed exam which is not proctored and has not started yet.
"""
rendered_response = get_student_view(
user_id=self.user_id,
course_id="abc",
content_id=self.content_id,
context={
'is_proctored': False,
'display_name': self.exam_name,
'default_time_limit_mins': 90,
'hide_after_due': False,
}
)
self.assertNotIn(
'data-exam-id="{proctored_exam_id}"'.format(proctored_exam_id=self.proctored_exam_id),
rendered_response
)
self.assertIn(self.timed_exam_msg.format(exam_name=self.exam_name), rendered_response)
self.assertIn('1 hour and 30 minutes', rendered_response)
self.assertNotIn(self.start_an_exam_msg.format(exam_name=self.exam_name), rendered_response)
def test_get_studentview_unstarted_timed_exam_with_allowance(self):
"""
Test for get_student_view Timed exam which is not proctored and has not started yet.
But user has an allowance
"""
allowed_extra_time = 10
add_allowance_for_user(
self.timed_exam_id,
self.user.username,
ProctoredExamStudentAllowance.ADDITIONAL_TIME_GRANTED,
str(allowed_extra_time)
)
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={}
)
self.assertNotIn(
'data-exam-id="{proctored_exam_id}"'.format(proctored_exam_id=self.proctored_exam_id),
rendered_response
)
self.assertIn(self.timed_exam_msg.format(exam_name=self.exam_name), rendered_response)
self.assertIn('31 minutes', rendered_response)
self.assertNotIn(self.start_an_exam_msg.format(exam_name=self.exam_name), rendered_response)
@ddt.data(
(
ProctoredExamStudentAttemptStatus.ready_to_submit,
'Are you sure that you want to submit your timed exam?'
),
(
ProctoredExamStudentAttemptStatus.submitted,
'You have submitted your timed exam'
),
)
@ddt.unpack
def test_get_studentview_completed_timed_exam(self, status, expected_content):
"""
Test for get_student_view timed exam which has completed.
"""
exam_attempt = self._create_started_exam_attempt(is_proctored=False)
exam_attempt.status = status
if status == 'submitted':
exam_attempt.completed_at = datetime.now(pytz.UTC)
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'display_name': self.exam_name,
}
)
self.assertIn(expected_content, rendered_response)
def test_expired_exam(self):
"""
Test that an expired exam shows a difference message when the exam is expired just recently
"""
# create exam with completed_at equal to current time and started_at equal to allowed_time_limit_mins ago
attempt = self._create_started_exam_attempt(is_proctored=False)
attempt.status = "submitted"
attempt.started_at = attempt.started_at - timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.completed_at = attempt.started_at + timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'display_name': self.exam_name,
}
)
self.assertIn(self.timed_exam_expired, rendered_response)
# update start and completed time such that completed_time is allowed_time_limit_mins ago than the current time
attempt.started_at = attempt.started_at - timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.completed_at = attempt.completed_at - timedelta(minutes=attempt.allowed_time_limit_mins)
attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id_timed,
context={
'display_name': self.exam_name,
}
)
self.assertIn(self.timed_exam_submitted, rendered_response)
@ddt.data(
ProctoredExamStudentAttemptStatus.eligible,
ProctoredExamStudentAttemptStatus.created,
ProctoredExamStudentAttemptStatus.download_software_clicked,
ProctoredExamStudentAttemptStatus.submitted,
ProctoredExamStudentAttemptStatus.verified,
ProctoredExamStudentAttemptStatus.rejected,
)
def test_footer_present(self, status):
"""
Make sure the footer content is visible in the rendered output
"""
if status != ProctoredExamStudentAttemptStatus.eligible:
exam_attempt = self._create_started_exam_attempt()
exam_attempt.status = status
exam_attempt.save()
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIsNotNone(rendered_response)
if status == ProctoredExamStudentAttemptStatus.submitted:
exam_attempt.last_poll_timestamp = datetime.now(pytz.UTC)
exam_attempt.save()
reset_time = datetime.now(pytz.UTC) + timedelta(minutes=2)
with freeze_time(reset_time):
rendered_response = get_student_view(
user_id=self.user_id,
course_id=self.course_id,
content_id=self.content_id,
context={
'is_proctored': True,
'display_name': self.exam_name,
'default_time_limit_mins': 90
}
)
self.assertIn(self.footer_msg, rendered_response)
else:
self.assertIn(self.footer_msg, rendered_response)
# coding=utf-8
# pylint: disable=invalid-name
""" """
Subclasses Django test client to allow for easy login Subclasses Django test client to allow for easy login
""" """
from datetime import datetime
from importlib import import_module from importlib import import_module
import pytz
from django.conf import settings from django.conf import settings
from django.contrib.auth import login from django.contrib.auth import login
...@@ -11,6 +16,22 @@ from django.test.client import Client ...@@ -11,6 +16,22 @@ from django.test.client import Client
from django.test import TestCase from django.test import TestCase
from django.contrib.auth.models import User from django.contrib.auth.models import User
from edx_proctoring.api import (
create_exam,
)
from edx_proctoring.models import (
ProctoredExamStudentAttempt,
ProctoredExamStudentAttemptStatus,
)
from edx_proctoring.tests.test_services import (
MockCreditService,
MockInstructorService,
)
from edx_proctoring.runtime import set_runtime_service
from eventtracking import tracker
from eventtracking.tracker import Tracker, TRACKERS
class TestClient(Client): class TestClient(Client):
""" """
...@@ -64,3 +85,256 @@ class LoggedInTestCase(TestCase): ...@@ -64,3 +85,256 @@ class LoggedInTestCase(TestCase):
self.user = User(username='tester', email='tester@test.com') self.user = User(username='tester', email='tester@test.com')
self.user.save() self.user.save()
self.client.login_user(self.user) self.client.login_user(self.user)
class MockTracker(Tracker):
"""
A mocked out tracker which implements the emit method
"""
def emit(self, name=None, data=None):
"""
Overload this method to do nothing
"""
pass
class ProctoredExamTestCase(LoggedInTestCase):
"""
All tests for the models.py
"""
def setUp(self):
"""
Build out test harnessing
"""
super(ProctoredExamTestCase, self).setUp()
self.default_time_limit = 21
self.course_id = 'test_course'
self.content_id_for_exam_with_due_date = 'test_content_due_date_id'
self.content_id = 'test_content_id'
self.content_id_timed = 'test_content_id_timed'
self.content_id_practice = 'test_content_id_practice'
self.disabled_content_id = 'test_disabled_content_id'
self.exam_name = 'Test Exam'
self.user_id = self.user.id
self.key = 'additional_time_granted'
self.value = '10'
self.external_id = 'test_external_id'
self.proctored_exam_id = self._create_proctored_exam()
self.timed_exam_id = self._create_timed_exam()
self.practice_exam_id = self._create_practice_exam()
self.disabled_exam_id = self._create_disabled_exam()
set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True))
tracker.register_tracker(MockTracker())
self.prerequisites = [
{
'namespace': 'proctoring',
'name': 'proc1',
'order': 2,
'status': 'satisfied',
},
{
'namespace': 'reverification',
'name': 'rever1',
'order': 1,
'status': 'satisfied',
},
{
'namespace': 'grade',
'name': 'grade1',
'order': 0,
'status': 'pending',
},
{
'namespace': 'reverification',
'name': 'rever2',
'order': 3,
'status': 'failed',
},
{
'namespace': 'proctoring',
'name': 'proc2',
'order': 4,
'status': 'pending',
},
]
self.declined_prerequisites = [
{
'namespace': 'proctoring',
'name': 'proc1',
'order': 2,
'status': 'satisfied',
},
{
'namespace': 'reverification',
'name': 'rever1',
'order': 1,
'status': 'satisfied',
},
{
'namespace': 'grade',
'name': 'grade1',
'order': 0,
'status': 'pending',
},
{
'namespace': 'reverification',
'name': 'rever2',
'order': 3,
'status': 'declined',
},
{
'namespace': 'proctoring',
'name': 'proc2',
'order': 4,
'status': 'pending',
},
]
def tearDown(self):
"""
Cleanup
"""
del TRACKERS['default']
def _create_proctored_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit
)
def _create_exam_with_due_time(self, is_proctored=True, is_practice_exam=False, due_date=None):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_for_exam_with_due_date,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_proctored=is_proctored,
is_practice_exam=is_practice_exam,
due_date=due_date
)
def _create_timed_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_timed,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_proctored=False
)
def _create_practice_exam(self):
"""
Calls the api's create_exam to create a practice exam object.
"""
return create_exam(
course_id=self.course_id,
content_id=self.content_id_practice,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_practice_exam=True,
is_proctored=True
)
def _create_disabled_exam(self):
"""
Calls the api's create_exam to create an exam object.
"""
return create_exam(
course_id=self.course_id,
is_proctored=False,
content_id=self.disabled_content_id,
exam_name=self.exam_name,
time_limit_mins=self.default_time_limit,
is_active=False
)
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 _create_unstarted_exam_attempt(self, is_proctored=True, is_practice=False):
"""
Creates the ProctoredExamStudentAttempt object.
"""
if is_proctored:
if is_practice:
exam_id = self.practice_exam_id
else:
exam_id = self.proctored_exam_id
else:
exam_id = self.timed_exam_id
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=exam_id,
user_id=self.user_id,
external_id=self.external_id,
allowed_time_limit_mins=10,
status='created'
)
def _create_started_exam_attempt(self, started_at=None, is_proctored=True, is_sample_attempt=False):
"""
Creates the ProctoredExamStudentAttempt object.
"""
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.proctored_exam_id if is_proctored else self.timed_exam_id,
user_id=self.user_id,
external_id=self.external_id,
started_at=started_at if started_at else datetime.now(pytz.UTC),
status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10,
taking_as_proctored=is_proctored,
is_sample_attempt=is_sample_attempt
)
def _create_started_practice_exam_attempt(self, started_at=None):
"""
Creates the ProctoredExamStudentAttempt object.
"""
return ProctoredExamStudentAttempt.objects.create(
proctored_exam_id=self.practice_exam_id,
taking_as_proctored=True,
user_id=self.user_id,
external_id=self.external_id,
started_at=started_at if started_at else datetime.now(pytz.UTC),
is_sample_attempt=True,
status=ProctoredExamStudentAttemptStatus.started,
allowed_time_limit_mins=10
)
...@@ -5,6 +5,8 @@ djangorestframework>=3.1,<3.2 ...@@ -5,6 +5,8 @@ djangorestframework>=3.1,<3.2
django-ipware==1.1.0 django-ipware==1.1.0
pytz>=2012h pytz>=2012h
pycrypto>=2.6 pycrypto>=2.6
python-dateutil==2.1
# edX packages
-e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1 -e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1
-e git+https://github.com/edx/opaque-keys.git@27dc382ea587483b1e3889a3d19cbd90b9023a06#egg=opaque-keys edx-opaque-keys==0.3.4
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