Commit 37334953 by Muhammad Shoaib

SOL-1222

Update permissions in edx-proctoring so that course staff members can view list of attempts and allowances
parent e34ca44f
...@@ -198,11 +198,13 @@ def add_allowance_for_user(exam_id, user_info, key, value): ...@@ -198,11 +198,13 @@ def add_allowance_for_user(exam_id, user_info, key, value):
ProctoredExamStudentAllowance.add_allowance_for_user(exam_id, user_info, key, value) ProctoredExamStudentAllowance.add_allowance_for_user(exam_id, user_info, key, value)
def get_allowances_for_course(course_id): def get_allowances_for_course(course_id, timed_exams_only):
""" """
Get all the allowances for the course. Get all the allowances for the course.
""" """
student_allowances = ProctoredExamStudentAllowance.get_allowances_for_course(course_id) student_allowances = ProctoredExamStudentAllowance.get_allowances_for_course(
course_id, timed_exams_only=timed_exams_only
)
return [ProctoredExamStudentAllowanceSerializer(allowance).data for allowance in student_allowances] return [ProctoredExamStudentAllowanceSerializer(allowance).data for allowance in student_allowances]
...@@ -765,7 +767,7 @@ def remove_exam_attempt(attempt_id): ...@@ -765,7 +767,7 @@ def remove_exam_attempt(attempt_id):
) )
def get_all_exams_for_course(course_id): def get_all_exams_for_course(course_id, timed_exams_only):
""" """
This method will return all exams for a course. This will return a list This method will return all exams for a course. This will return a list
of dictionaries, whose schema is the same as what is returned in of dictionaries, whose schema is the same as what is returned in
...@@ -789,7 +791,7 @@ def get_all_exams_for_course(course_id): ...@@ -789,7 +791,7 @@ def get_all_exams_for_course(course_id):
.. ..
] ]
""" """
exams = ProctoredExam.get_all_exams_for_course(course_id) exams = ProctoredExam.get_all_exams_for_course(course_id, timed_exams_only=timed_exams_only)
return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams] return [ProctoredExamSerializer(proctored_exam).data for proctored_exam in exams]
......
...@@ -72,14 +72,18 @@ class ProctoredExam(TimeStampedModel): ...@@ -72,14 +72,18 @@ class ProctoredExam(TimeStampedModel):
return proctored_exam return proctored_exam
@classmethod @classmethod
def get_all_exams_for_course(cls, course_id, active_only=False): def get_all_exams_for_course(cls, course_id, active_only=False, timed_exams_only=False):
""" """
Returns all exams for a give course Returns all exams for a give course
""" """
result = cls.objects.filter(course_id=course_id) filtered_query = Q(course_id=course_id)
if active_only: if active_only:
result = result.filter(is_active=True) filtered_query = filtered_query & Q(is_active=True)
return result if timed_exams_only:
filtered_query = filtered_query & Q(is_proctored=False)
return cls.objects.filter(filtered_query)
class ProctoredExamStudentAttemptStatus(object): class ProctoredExamStudentAttemptStatus(object):
...@@ -237,20 +241,26 @@ class ProctoredExamStudentAttemptManager(models.Manager): ...@@ -237,20 +241,26 @@ class ProctoredExamStudentAttemptManager(models.Manager):
exam_attempt_obj = None exam_attempt_obj = None
return exam_attempt_obj return exam_attempt_obj
def get_all_exam_attempts(self, course_id): def get_all_exam_attempts(self, course_id, timed_exams_only=False):
""" """
Returns the Student Exam Attempts for the given course_id. Returns the Student Exam Attempts for the given course_id.
""" """
filtered_query = Q(proctored_exam__course_id=course_id)
return self.filter(proctored_exam__course_id=course_id).order_by('-created') if timed_exams_only:
filtered_query = filtered_query & Q(proctored_exam__is_proctored=False)
def get_filtered_exam_attempts(self, course_id, search_by): return self.filter(filtered_query).order_by('-created')
def get_filtered_exam_attempts(self, course_id, search_by, timed_exams_only=False):
""" """
Returns the Student Exam Attempts for the given course_id filtered by search_by. Returns the Student Exam Attempts for the given course_id filtered by search_by.
""" """
filtered_query = Q(proctored_exam__course_id=course_id) & ( filtered_query = Q(proctored_exam__course_id=course_id) & (
Q(user__username__contains=search_by) | Q(user__email__contains=search_by) Q(user__username__contains=search_by) | Q(user__email__contains=search_by)
) )
if timed_exams_only:
filtered_query = filtered_query & Q(proctored_exam__is_proctored=False)
return self.filter(filtered_query).order_by('-created') return self.filter(filtered_query).order_by('-created')
...@@ -465,11 +475,15 @@ class ProctoredExamStudentAllowance(TimeStampedModel): ...@@ -465,11 +475,15 @@ class ProctoredExamStudentAllowance(TimeStampedModel):
verbose_name = 'proctored allowance' verbose_name = 'proctored allowance'
@classmethod @classmethod
def get_allowances_for_course(cls, course_id): def get_allowances_for_course(cls, course_id, timed_exams_only=False):
""" """
Returns all the allowances for a course. Returns all the allowances for a course.
""" """
return cls.objects.filter(proctored_exam__course_id=course_id) filtered_query = Q(proctored_exam__course_id=course_id)
if timed_exams_only:
filtered_query = filtered_query & Q(proctored_exam__is_proctored=False)
return cls.objects.filter(filtered_query)
@classmethod @classmethod
def get_allowance_for_user(cls, exam_id, user_id, key): def get_allowance_for_user(cls, exam_id, user_id, key):
......
...@@ -115,7 +115,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -115,7 +115,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.proctored_exam_email_body = 'the status of your proctoring session review' self.proctored_exam_email_body = 'the status of your proctoring session review'
set_runtime_service('credit', MockCreditService()) set_runtime_service('credit', MockCreditService())
set_runtime_service('instructor', MockInstructorService()) set_runtime_service('instructor', MockInstructorService(is_user_course_staff=True))
def _create_proctored_exam(self): def _create_proctored_exam(self):
""" """
...@@ -303,9 +303,27 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -303,9 +303,27 @@ class ProctoredExamApiTests(LoggedInTestCase):
self.assertEqual(proctored_exam['content_id'], self.content_id) self.assertEqual(proctored_exam['content_id'], self.content_id)
self.assertEqual(proctored_exam['exam_name'], self.exam_name) self.assertEqual(proctored_exam['exam_name'], self.exam_name)
exams = get_all_exams_for_course(self.course_id) exams = get_all_exams_for_course(self.course_id, False)
self.assertEqual(len(exams), 4) self.assertEqual(len(exams), 4)
def test_get_timed_exam(self):
"""
test to get the exam by the exam_id and
then compare their values.
"""
timed_exam = get_exam_by_id(self.timed_exam)
self.assertEqual(timed_exam['course_id'], self.course_id)
self.assertEqual(timed_exam['content_id'], self.content_id_timed)
self.assertEqual(timed_exam['exam_name'], self.exam_name)
timed_exam = get_exam_by_content_id(self.course_id, self.content_id_timed)
self.assertEqual(timed_exam['course_id'], self.course_id)
self.assertEqual(timed_exam['content_id'], self.content_id_timed)
self.assertEqual(timed_exam['exam_name'], self.exam_name)
exams = get_all_exams_for_course(self.course_id, True)
self.assertEqual(len(exams), 1)
def test_get_invalid_proctored_exam(self): def test_get_invalid_proctored_exam(self):
""" """
test to get the exam by the invalid exam_id which will test to get the exam by the invalid exam_id which will
...@@ -354,7 +372,7 @@ class ProctoredExamApiTests(LoggedInTestCase): ...@@ -354,7 +372,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
Test to get all the allowances for a course. Test to get all the allowances for a course.
""" """
allowance = self._add_allowance_for_user() allowance = self._add_allowance_for_user()
course_allowances = get_allowances_for_course(self.course_id) course_allowances = get_allowances_for_course(self.course_id, False)
self.assertEqual(len(course_allowances), 1) self.assertEqual(len(course_allowances), 1)
self.assertEqual(course_allowances[0]['proctored_exam']['course_id'], allowance.proctored_exam.course_id) self.assertEqual(course_allowances[0]['proctored_exam']['course_id'], allowance.proctored_exam.course_id)
......
...@@ -86,12 +86,24 @@ class MockInstructorService(object): ...@@ -86,12 +86,24 @@ class MockInstructorService(object):
""" """
Simple mock of the Instructor Service Simple mock of the Instructor Service
""" """
def __init__(self, is_user_course_staff=True):
"""
Initializer
"""
self.is_user_course_staff = is_user_course_staff
def delete_student_attempt(self, student_identifier, course_id, content_id): # pylint: disable=unused-argument def delete_student_attempt(self, student_identifier, course_id, content_id): # pylint: disable=unused-argument
""" """
Mock implementation Mock implementation
""" """
return True return True
def is_course_staff(self, user, course_id):
"""
Mocked implementation of is_course_staff
"""
return self.is_user_course_staff
class TestProctoringService(unittest.TestCase): class TestProctoringService(unittest.TestCase):
""" """
......
...@@ -38,8 +38,9 @@ from edx_proctoring.exceptions import ( ...@@ -38,8 +38,9 @@ from edx_proctoring.exceptions import (
ProctoredExamPermissionDenied, ProctoredExamPermissionDenied,
StudentExamAttemptDoesNotExistsException, StudentExamAttemptDoesNotExistsException,
) )
from edx_proctoring.runtime import get_runtime_service
from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer from edx_proctoring.serializers import ProctoredExamSerializer, ProctoredExamStudentAttemptSerializer
from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt from edx_proctoring.models import ProctoredExamStudentAttemptStatus, ProctoredExamStudentAttempt, ProctoredExam
from .utils import AuthenticatedAPIView, get_time_remaining_for_attempt, humanized_time from .utils import AuthenticatedAPIView, get_time_remaining_for_attempt, humanized_time
...@@ -63,6 +64,48 @@ def require_staff(func): ...@@ -63,6 +64,48 @@ def require_staff(func):
return wrapped return wrapped
def require_course_or_global_staff(func):
"""View decorator that requires that the user have staff permissions. """
def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring
instructor_service = get_runtime_service('instructor')
course_id = kwargs['course_id'] if 'course_id' in kwargs else None
exam_id = request.DATA.get('exam_id', None)
attempt_id = kwargs['attempt_id'] if 'attempt_id' in kwargs else None
response_message = _("Must be a instructor of the course to Perform this request.")
if request.user.is_staff:
is_request_allowed = True
elif course_id is not None:
if instructor_service.is_course_staff(request.user, course_id):
is_request_allowed = True
else:
is_request_allowed = False
elif exam_id is not None:
# get the course_id from the exam
exam = ProctoredExam.get_exam_by_id(exam_id)
if instructor_service.is_course_staff(request.user, exam.course_id):
is_request_allowed = True
else:
is_request_allowed = False
elif attempt_id is not None:
exam_attempt = ProctoredExamStudentAttempt.objects.get_exam_attempt_by_id(attempt_id)
if instructor_service.is_course_staff(request.user, exam_attempt.proctored_exam.course_id):
is_request_allowed = True
else:
is_request_allowed = False
else:
is_request_allowed = False
response_message = _("Must be a Staff User to Perform this request.")
if is_request_allowed:
return func(request, *args, **kwargs)
else:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={"detail": response_message}
)
return wrapped
class ProctoredExamView(AuthenticatedAPIView): class ProctoredExamView(AuthenticatedAPIView):
""" """
Endpoint for the Proctored Exams Endpoint for the Proctored Exams
...@@ -215,8 +258,10 @@ class ProctoredExamView(AuthenticatedAPIView): ...@@ -215,8 +258,10 @@ class ProctoredExamView(AuthenticatedAPIView):
data={"detail": "The exam with course_id, content_id does not exist."} data={"detail": "The exam with course_id, content_id does not exist."}
) )
else: else:
timed_exams_only = not request.user.is_staff
result_set = get_all_exams_for_course( result_set = get_all_exams_for_course(
course_id=course_id course_id=course_id,
timed_exams_only=timed_exams_only
) )
return Response(result_set) return Response(result_set)
...@@ -378,7 +423,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView): ...@@ -378,7 +423,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
data={"detail": str(ex)} data={"detail": str(ex)}
) )
@method_decorator(require_staff) @method_decorator(require_course_or_global_staff)
def delete(self, request, attempt_id): # pylint: disable=unused-argument def delete(self, request, attempt_id): # pylint: disable=unused-argument
""" """
HTTP DELETE handler. Removes an exam attempt. HTTP DELETE handler. Removes an exam attempt.
...@@ -560,17 +605,23 @@ class StudentProctoredExamAttemptsByCourse(AuthenticatedAPIView): ...@@ -560,17 +605,23 @@ class StudentProctoredExamAttemptsByCourse(AuthenticatedAPIView):
A search parameter is optional A search parameter is optional
""" """
@method_decorator(require_staff) @method_decorator(require_course_or_global_staff)
def get(self, request, course_id, search_by=None): # pylint: disable=unused-argument def get(self, request, course_id, search_by=None): # pylint: disable=unused-argument
""" """
HTTP GET Handler. Returns the status of the exam attempt. HTTP GET Handler. Returns the status of the exam attempt.
""" """
# course staff only views attempts of timed exams. edx staff can view both timed and proctored attempts.
time_exams_only = not request.user.is_staff
if search_by is not None: if search_by is not None:
exam_attempts = ProctoredExamStudentAttempt.objects.get_filtered_exam_attempts(course_id, search_by) exam_attempts = ProctoredExamStudentAttempt.objects.get_filtered_exam_attempts(
course_id, search_by, time_exams_only
)
attempt_url = reverse('edx_proctoring.proctored_exam.attempts.search', args=[course_id, search_by]) attempt_url = reverse('edx_proctoring.proctored_exam.attempts.search', args=[course_id, search_by])
else: else:
exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts(course_id) exam_attempts = ProctoredExamStudentAttempt.objects.get_all_exam_attempts(
course_id, time_exams_only
)
attempt_url = reverse('edx_proctoring.proctored_exam.attempts.course', args=[course_id]) attempt_url = reverse('edx_proctoring.proctored_exam.attempts.course', args=[course_id])
paginator = Paginator(exam_attempts, ATTEMPTS_PER_PAGE) paginator = Paginator(exam_attempts, ATTEMPTS_PER_PAGE)
...@@ -644,17 +695,21 @@ class ExamAllowanceView(AuthenticatedAPIView): ...@@ -644,17 +695,21 @@ class ExamAllowanceView(AuthenticatedAPIView):
**Response Values** **Response Values**
* returns Nothing. deletes the allowance for the user proctored exam. * returns Nothing. deletes the allowance for the user proctored exam.
""" """
@method_decorator(require_staff) @method_decorator(require_course_or_global_staff)
def get(self, request, course_id): # pylint: disable=unused-argument def get(self, request, course_id): # pylint: disable=unused-argument
""" """
HTTP GET handler. Get all allowances for a course. HTTP GET handler. Get all allowances for a course.
""" """
# course staff only views attempts of timed exams. edx staff can view both timed and proctored attempts.
time_exams_only = not request.user.is_staff
result_set = get_allowances_for_course( result_set = get_allowances_for_course(
course_id=course_id course_id=course_id,
timed_exams_only=time_exams_only
) )
return Response(result_set) return Response(result_set)
@method_decorator(require_staff) @method_decorator(require_course_or_global_staff)
def put(self, request): def put(self, request):
""" """
HTTP GET handler. Adds or updates Allowance HTTP GET handler. Adds or updates Allowance
...@@ -674,7 +729,7 @@ class ExamAllowanceView(AuthenticatedAPIView): ...@@ -674,7 +729,7 @@ class ExamAllowanceView(AuthenticatedAPIView):
data={"detail": str(ex)} data={"detail": str(ex)}
) )
@method_decorator(require_staff) @method_decorator(require_course_or_global_staff)
def delete(self, request): def delete(self, request):
""" """
HTTP DELETE handler. Removes Allowance. HTTP DELETE handler. Removes Allowance.
......
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