Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-proctoring
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-proctoring
Commits
52e861b9
Commit
52e861b9
authored
Aug 09, 2017
by
Tyler Hallada
Committed by
GitHub
Aug 09, 2017
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #365 from edx/EDUCATOR-927
EDUCATOR-927 Override grade to zero when exam attempt is rejected
parents
952ad87c
7c401352
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
306 additions
and
4 deletions
+306
-4
AUTHORS
+1
-0
edx_proctoring/__init__.py
+1
-1
edx_proctoring/api.py
+58
-2
edx_proctoring/backends/tests/test_software_secure.py
+3
-1
edx_proctoring/management/commands/tests/test_set_attempt_status.py
+2
-0
edx_proctoring/models.py
+9
-0
edx_proctoring/tests/test_api.py
+166
-0
edx_proctoring/tests/test_services.py
+66
-0
No files found.
AUTHORS
View file @
52e861b9
...
...
@@ -4,3 +4,4 @@ Afzal Wali <afzal@edx.org>
Mushtaq Ali <mushtaak@gmail.com>
Christina Roberts <christina@edx.org>
Dennis Jen <djen@edx.org>
Tyler Hallada <thallada@edx.org>
edx_proctoring/__init__.py
View file @
52e861b9
...
...
@@ -4,6 +4,6 @@ The exam proctoring subsystem for the Open edX platform.
from
__future__
import
absolute_import
__version__
=
'
0.19
.0'
__version__
=
'
1.0
.0'
default_app_config
=
'edx_proctoring.apps.EdxProctoringConfig'
# pylint: disable=invalid-name
edx_proctoring/api.py
View file @
52e861b9
...
...
@@ -58,6 +58,8 @@ SHOW_EXPIRY_MESSAGE_DURATION = 1 * 60 # duration within which expiry message is
APPROVED_STATUS
=
'approved'
REJECTED_GRADE_OVERRIDE_EARNED
=
0.0
def
create_exam
(
course_id
,
content_id
,
exam_name
,
time_limit_mins
,
due_date
=
None
,
is_proctored
=
True
,
is_practice_exam
=
False
,
external_id
=
None
,
is_active
=
True
,
hide_after_due
=
False
):
...
...
@@ -762,6 +764,7 @@ def update_attempt_status(exam_id, user_id, to_status,
else
:
return
from_status
=
exam_attempt_obj
.
status
exam
=
get_exam_by_id
(
exam_id
)
#
...
...
@@ -776,7 +779,7 @@ def update_attempt_status(exam_id, user_id, to_status,
'A status transition from {from_status} to {to_status} was attempted '
'on exam_id {exam_id} for user_id {user_id}. This is not '
'allowed!'
.
format
(
from_status
=
exam_attempt_obj
.
status
,
from_status
=
from_
status
,
to_status
=
to_status
,
exam_id
=
exam_id
,
user_id
=
user_id
...
...
@@ -791,7 +794,7 @@ def update_attempt_status(exam_id, user_id, to_status,
'A status transition from {from_status} to {to_status} was attempted '
'on exam_id {exam_id} for user_id {user_id}. This is not '
'allowed!'
.
format
(
from_status
=
exam_attempt_obj
.
status
,
from_status
=
from_
status
,
to_status
=
to_status
,
exam_id
=
exam_id
,
user_id
=
user_id
...
...
@@ -901,6 +904,59 @@ def update_attempt_status(exam_id, user_id, to_status,
cascade_effects
=
False
)
if
ProctoredExamStudentAttemptStatus
.
needs_grade_override
(
to_status
):
grades_service
=
get_runtime_service
(
'grades'
)
if
grades_service
.
should_override_grade_on_rejected_exam
(
exam
[
'course_id'
]):
log_msg
=
(
'Overriding exam subsection grade for '
'user_id {user_id} on {course_id} for '
'content_id {content_id}. Override '
'earned_all: {earned_all}, '
'earned_graded: {earned_graded}.'
.
format
(
user_id
=
exam_attempt_obj
.
user_id
,
course_id
=
exam
[
'course_id'
],
content_id
=
exam_attempt_obj
.
proctored_exam
.
content_id
,
earned_all
=
REJECTED_GRADE_OVERRIDE_EARNED
,
earned_graded
=
REJECTED_GRADE_OVERRIDE_EARNED
)
)
log
.
info
(
log_msg
)
grades_service
.
override_subsection_grade
(
user_id
=
exam_attempt_obj
.
user_id
,
course_key_or_id
=
exam
[
'course_id'
],
usage_key_or_id
=
exam_attempt_obj
.
proctored_exam
.
content_id
,
earned_all
=
REJECTED_GRADE_OVERRIDE_EARNED
,
earned_graded
=
REJECTED_GRADE_OVERRIDE_EARNED
)
if
(
to_status
==
ProctoredExamStudentAttemptStatus
.
verified
and
ProctoredExamStudentAttemptStatus
.
needs_grade_override
(
from_status
)):
grades_service
=
get_runtime_service
(
'grades'
)
if
grades_service
.
should_override_grade_on_rejected_exam
(
exam
[
'course_id'
]):
log_msg
=
(
'Deleting override of exam subsection grade for '
'user_id {user_id} on {course_id} for '
'content_id {content_id}. Override '
'earned_all: {earned_all}, '
'earned_graded: {earned_graded}.'
.
format
(
user_id
=
exam_attempt_obj
.
user_id
,
course_id
=
exam
[
'course_id'
],
content_id
=
exam_attempt_obj
.
proctored_exam
.
content_id
,
earned_all
=
REJECTED_GRADE_OVERRIDE_EARNED
,
earned_graded
=
REJECTED_GRADE_OVERRIDE_EARNED
)
)
log
.
info
(
log_msg
)
grades_service
.
undo_override_subsection_grade
(
user_id
=
exam_attempt_obj
.
user_id
,
course_key_or_id
=
exam
[
'course_id'
],
usage_key_or_id
=
exam_attempt_obj
.
proctored_exam
.
content_id
,
)
# call service to get course name.
credit_service
=
get_runtime_service
(
'credit'
)
credit_state
=
credit_service
.
get_credit_state
(
...
...
edx_proctoring/backends/tests/test_software_secure.py
View file @
52e861b9
...
...
@@ -43,7 +43,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAllowance
)
from
edx_proctoring.backends.tests.test_review_payload
import
create_test_review_payload
from
edx_proctoring.tests.test_services
import
MockCreditService
,
MockInstructorService
from
edx_proctoring.tests.test_services
import
MockCreditService
,
MockInstructorService
,
MockGradesService
from
edx_proctoring.backends.software_secure
import
SOFTWARE_SECURE_INVALID_CHARS
...
...
@@ -104,6 +104,7 @@ class SoftwareSecureTests(TestCase):
set_runtime_service
(
'credit'
,
MockCreditService
())
set_runtime_service
(
'instructor'
,
MockInstructorService
())
set_runtime_service
(
'grades'
,
MockGradesService
())
def
tearDown
(
self
):
"""
...
...
@@ -111,6 +112,7 @@ class SoftwareSecureTests(TestCase):
"""
super
(
SoftwareSecureTests
,
self
)
.
tearDown
()
set_runtime_service
(
'credit'
,
None
)
set_runtime_service
(
'grades'
,
None
)
def
test_provider_instance
(
self
):
"""
...
...
edx_proctoring/management/commands/tests/test_set_attempt_status.py
View file @
52e861b9
...
...
@@ -15,6 +15,7 @@ from edx_proctoring.management.commands import set_attempt_status
from
edx_proctoring.models
import
ProctoredExamStudentAttemptStatus
,
ProctoredExamStudentAttempt
from
edx_proctoring.tests.test_services
import
(
MockCreditService
,
MockGradesService
)
from
edx_proctoring.runtime
import
set_runtime_service
...
...
@@ -31,6 +32,7 @@ class SetAttemptStatusTests(LoggedInTestCase):
"""
super
(
SetAttemptStatusTests
,
self
)
.
setUp
()
set_runtime_service
(
'credit'
,
MockCreditService
())
set_runtime_service
(
'grades'
,
MockGradesService
())
self
.
exam_id
=
create_exam
(
course_id
=
'foo'
,
content_id
=
'bar'
,
...
...
edx_proctoring/models.py
View file @
52e861b9
...
...
@@ -214,6 +214,15 @@ class ProctoredExamStudentAttemptStatus(object):
]
@classmethod
def
needs_grade_override
(
cls
,
to_status
):
"""
Returns a boolean if the passed in to_status calls for an override of the learner's grade.
"""
return
to_status
in
[
cls
.
rejected
]
@classmethod
def
is_a_cascadable_failure
(
cls
,
to_status
):
"""
Returns a boolean if the passed in to_status has a failure that needs to be cascaded
...
...
edx_proctoring/tests/test_api.py
View file @
52e861b9
...
...
@@ -72,6 +72,7 @@ from .test_services import (
MockCreditService
,
MockCreditServiceNone
,
MockCreditServiceWithCourseEndDate
,
MockGradesService
)
from
.utils
import
ProctoredExamTestCase
...
...
@@ -862,6 +863,7 @@ class ProctoredExamApiTests(ProctoredExamTestCase):
are auto marked as declined
"""
set_runtime_service
(
'grades'
,
MockGradesService
())
# create other exams in course
second_exam_id
=
create_exam
(
course_id
=
self
.
course_id
,
...
...
@@ -930,6 +932,170 @@ class ProctoredExamApiTests(ProctoredExamTestCase):
self
.
assertIsNone
(
get_exam_attempt
(
timed_exam_id
,
self
.
user_id
))
self
.
assertIsNone
(
get_exam_attempt
(
inactive_exam_id
,
self
.
user_id
))
def
test_grade_override
(
self
):
"""
Verify that putting an attempt into the rejected state will also override
the learner's subsection grade for the exam
"""
set_runtime_service
(
'grades'
,
MockGradesService
())
grades_service
=
get_runtime_service
(
'grades'
)
exam_attempt
=
self
.
_create_started_exam_attempt
()
# Pretend learner answered 5 graded questions in the exam correctly
grades_service
.
init_grade
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
,
earned_all
=
5.0
,
earned_graded
=
5.0
)
update_attempt_status
(
exam_attempt
.
proctored_exam_id
,
self
.
user
.
id
,
ProctoredExamStudentAttemptStatus
.
rejected
)
# Rejected exam attempt should override learner's grade to 0
override
=
grades_service
.
get_subsection_grade_override
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
self
.
assertDictEqual
({
'earned_all'
:
override
.
earned_all_override
,
'earned_graded'
:
override
.
earned_graded_override
},
{
'earned_all'
:
0.0
,
'earned_graded'
:
0.0
})
# The MockGradeService updates the PersistentSubsectionGrade synchronously, but in the real GradesService, this
# would be updated by an asynchronous recalculation celery task.
grade
=
grades_service
.
get_subsection_grade
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
self
.
assertDictEqual
({
'earned_all'
:
grade
.
earned_all
,
'earned_graded'
:
grade
.
earned_graded
},
{
'earned_all'
:
0.0
,
'earned_graded'
:
0.0
})
# Verify that transitioning an attempt from the rejected state to the verified state
# will remove the override for the learner's subsection grade on the exam that was created
# when the attempt entered the rejected state.
update_attempt_status
(
exam_attempt
.
proctored_exam_id
,
self
.
user
.
id
,
ProctoredExamStudentAttemptStatus
.
verified
)
override
=
grades_service
.
get_subsection_grade_override
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
self
.
assertIsNone
(
override
)
grade
=
grades_service
.
get_subsection_grade
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
# Grade has returned to original score
self
.
assertDictEqual
({
'earned_all'
:
grade
.
earned_all
,
'earned_graded'
:
grade
.
earned_graded
},
{
'earned_all'
:
5.0
,
'earned_graded'
:
5.0
})
def
test_disabled_grade_override
(
self
):
"""
Verify that when the REJECTED_EXAM_OVERRIDES_GRADE flag is disabled for a course,
the learner's subsection grade for the exam will not be overriden.
"""
set_runtime_service
(
'grades'
,
MockGradesService
(
rejected_exam_overrides_grade
=
False
))
grades_service
=
get_runtime_service
(
'grades'
)
exam_attempt
=
self
.
_create_started_exam_attempt
()
# Pretend learner answered 5 graded questions in the exam correctly
grades_service
.
init_grade
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
,
earned_all
=
5.0
,
earned_graded
=
5.0
)
update_attempt_status
(
exam_attempt
.
proctored_exam_id
,
self
.
user
.
id
,
ProctoredExamStudentAttemptStatus
.
rejected
)
# Rejected exam attempt should not override learner's grade
override
=
grades_service
.
get_subsection_grade_override
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
self
.
assertIsNone
(
override
)
grade
=
grades_service
.
get_subsection_grade
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
# Grade is not overriden
self
.
assertDictEqual
({
'earned_all'
:
grade
.
earned_all
,
'earned_graded'
:
grade
.
earned_graded
},
{
'earned_all'
:
5.0
,
'earned_graded'
:
5.0
})
# Transitioning from rejected to verified will also have no effect
update_attempt_status
(
exam_attempt
.
proctored_exam_id
,
self
.
user
.
id
,
ProctoredExamStudentAttemptStatus
.
verified
)
override
=
grades_service
.
get_subsection_grade_override
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
self
.
assertIsNone
(
override
)
grade
=
grades_service
.
get_subsection_grade
(
user_id
=
self
.
user
.
id
,
course_key_or_id
=
exam_attempt
.
proctored_exam
.
course_id
,
usage_key_or_id
=
exam_attempt
.
proctored_exam
.
content_id
)
# Grade has still the original score
self
.
assertDictEqual
({
'earned_all'
:
grade
.
earned_all
,
'earned_graded'
:
grade
.
earned_graded
},
{
'earned_all'
:
5.0
,
'earned_graded'
:
5.0
})
@ddt.data
(
(
ProctoredExamStudentAttemptStatus
.
declined
,
ProctoredExamStudentAttemptStatus
.
eligible
),
(
ProctoredExamStudentAttemptStatus
.
timed_out
,
ProctoredExamStudentAttemptStatus
.
created
),
...
...
edx_proctoring/tests/test_services.py
View file @
52e861b9
...
...
@@ -173,3 +173,69 @@ class TestProctoringService(unittest.TestCase):
service1
=
ProctoringService
()
service2
=
ProctoringService
()
self
.
assertIs
(
service1
,
service2
)
class
MockGrade
(
object
):
"""Fake PersistentSubsectionGrade instance."""
def
__init__
(
self
,
earned_all
=
0.0
,
earned_graded
=
0.0
):
self
.
earned_all
=
earned_all
self
.
earned_graded
=
earned_graded
class
MockGradeOverride
(
object
):
"""Fake PersistentSubsectionGradeOverride instance."""
def
__init__
(
self
,
earned_all
=
0.0
,
earned_graded
=
0.0
):
self
.
earned_all_override
=
earned_all
self
.
earned_graded_override
=
earned_graded
class
MockGradesService
(
object
):
"""
Simple mock of the Grades Service
"""
def
__init__
(
self
,
rejected_exam_overrides_grade
=
True
):
"""Initialize empty data stores for grades and overrides (just dicts)"""
self
.
grades
=
{}
self
.
overrides
=
{}
self
.
rejected_exam_overrides_grade
=
rejected_exam_overrides_grade
def
init_grade
(
self
,
user_id
,
course_key_or_id
,
usage_key_or_id
,
earned_all
,
earned_graded
):
"""Initialize a grade in MockGradesService for testing. Actual GradesService does not have this method."""
self
.
grades
[
str
(
user_id
)
+
str
(
course_key_or_id
)
+
str
(
usage_key_or_id
)]
=
MockGrade
(
earned_all
=
earned_all
,
earned_graded
=
earned_graded
)
def
get_subsection_grade
(
self
,
user_id
,
course_key_or_id
,
usage_key_or_id
):
"""Returns entered grade for key (user_id + course_key + subsection) or None"""
key
=
str
(
user_id
)
+
str
(
course_key_or_id
)
+
str
(
usage_key_or_id
)
if
key
in
self
.
overrides
:
# pretend override was applied
return
MockGrade
(
earned_all
=
self
.
overrides
[
key
]
.
earned_all_override
,
earned_graded
=
self
.
overrides
[
key
]
.
earned_graded_override
)
return
self
.
grades
.
get
(
str
(
user_id
)
+
str
(
course_key_or_id
)
+
str
(
usage_key_or_id
))
def
get_subsection_grade_override
(
self
,
user_id
,
course_key_or_id
,
usage_key_or_id
):
"""Returns entered grade override for key (user_id + course_key + subsection) or None"""
return
self
.
overrides
.
get
(
str
(
user_id
)
+
str
(
course_key_or_id
)
+
str
(
usage_key_or_id
))
def
override_subsection_grade
(
self
,
user_id
,
course_key_or_id
,
usage_key_or_id
,
earned_all
=
None
,
earned_graded
=
None
):
"""Sets grade override earned points for key (user_id + course_key + subsection)"""
key
=
str
(
user_id
)
+
str
(
course_key_or_id
)
+
str
(
usage_key_or_id
)
self
.
overrides
[
key
]
=
MockGradeOverride
(
earned_all
=
earned_all
,
earned_graded
=
earned_graded
)
def
undo_override_subsection_grade
(
self
,
user_id
,
course_key_or_id
,
usage_key_or_id
):
"""Deletes grade override for key (user_id + course_key + subsection)"""
key
=
str
(
user_id
)
+
str
(
course_key_or_id
)
+
str
(
usage_key_or_id
)
if
key
in
self
.
overrides
:
del
self
.
overrides
[
key
]
def
should_override_grade_on_rejected_exam
(
self
,
course_key
):
"""Mock will always return instance variable: rejected_exam_overrides_grade"""
return
self
.
rejected_exam_overrides_grade
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment